Python-极客指南-全-

Python 极客指南(全)

原文:zh.annas-archive.org/md5/9527f6aeabe10ebef28ba0b2f3ec1cad

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Python 是一种多用途语言,可用于解决多个领域中的中等至复杂问题。"Python for Geeks"将教你如何借助专家的技巧和窍门在职业生涯中取得进步。

你将从探索从设计和实现角度优化使用 Python 的不同方法开始。接下来,你将了解大型 Python 项目的生命周期。随着你的进步,你将专注于通过模块化 Python 项目来创建优雅设计的不同方法,并学习使用 Python 的最佳实践和设计模式。你还将发现如何将 Python 扩展到单线程之外,以及如何在 Python 中实现多进程和多线程。此外,你将了解如何不仅使用 Python 在单台机器上部署,还可以在私有环境中以及公共云计算环境中使用集群进行部署。然后,你将探索数据处理技术,关注可重用、可扩展的数据管道,并学习如何使用这些高级技术进行网络自动化、无服务器函数和机器学习。最后,你将专注于使用本书中涵盖的技术和最佳实践来制定 Web 开发设计策略。

在阅读完这本 Python 书籍之后,你将能够为大型复杂项目进行一些严肃的 Python 编程。

本书面向对象

本书面向任何领域的中级 Python 开发者,他们希望提升自己的技能以开发和管理大型复杂项目。希望创建可重用模块和 Python 库的开发者以及构建云部署应用的云开发者也会发现本书很有用。具备 Python 经验将帮助你更好地利用本书。

本书涵盖内容

第一章最优 Python 开发生命周期,帮助你理解典型 Python 项目的生命周期及其各个阶段,并讨论了编写 Python 代码的最佳实践。

第二章使用模块化处理复杂项目,专注于理解 Python 中的模块和包的概念。

第三章高级面向对象 Python 编程,讨论了如何使用 Python 实现面向编程的高级概念。

第四章Python 高级编程库,探讨了迭代器、生成器、错误和异常处理、文件处理和日志记录等高级概念。

第五章使用 Python 进行测试和自动化,不仅介绍了单元测试、集成测试和系统测试等不同类型的测试自动化,还讨论了如何使用流行的测试框架来实现单元测试。

第六章, Python 高级技巧和窍门,讨论了 Python 在数据转换、构建装饰器以及如何使用包括 pandas DataFrames 在内的数据结构进行数据分析的高级特性。

第七章, 多进程、多线程和异步编程,帮助你了解使用 Python 内置库构建多线程或多进程应用程序的不同选项。

第八章, 使用集群扩展 Python,探讨了如何使用 Apache Spark,以及我们如何编写 Python 应用程序用于大型数据处理应用,这些应用可以通过 Apache Spark 集群执行。

第九章, 云计算中的 Python 编程,讨论了如何开发和部署应用程序到云平台,以及如何一般性地使用 Apache Beam,特别是对于 Google Cloud Platform。

第十章, 使用 Python 进行 Web 开发和 REST API,专注于使用 Flask 框架开发 Web 应用程序,与数据库交互,并构建 REST API 或 Web 服务。

第十一章, 使用 Python 进行微服务开发,介绍了微服务以及如何使用 Django 框架构建一个示例微服务并将其与基于 Flask 的微服务集成。

第十二章, 使用 Python 构建无服务器函数,讨论了无服务器函数在云计算中的作用以及如何使用 Python 构建它们。

第十三章, Python 与机器学习,帮助你了解如何使用 Python 构建、训练和评估机器学习模型,以及如何在云中部署它们。

第十四章, 使用 Python 进行网络自动化,讨论了使用 Python 库从网络设备中获取数据以及网络管理系统(NMSes)的使用,以及如何将配置数据推送到设备或 NMSes。

为了最大限度地利用这本书

为了真正从这本书中获得益处,你必须具备 Python 知识。你需要在你的系统上安装 Python 3.7 或更高版本。所有代码示例都已使用 Python 3.7 和 Python 3.8 进行测试,并预期与任何未来的 3.x 版本兼容。拥有一个 Google Cloud Platform 账户(免费试用版即可)将有助于在云中部署一些代码示例。

图片

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

下载示例代码文件

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

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

下载彩色图像

我们还提供了一个包含本书中使用的截图和图表的彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781801070119_ColorImages.pdf

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”

代码块设置如下:

resource = {
    "api_key": "AIzaSyDYKmm85kebxddKrGns4z0",
    "id": "0B8TxHW2Ci6dbckVwTRtTl3RUU",
    "fields": "files(name, id, webContentLink)",
}

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

#casestudy1.py: Pi calculator
from operator import add
from random import random
from pyspark.sql import SparkSession
spark = SparkSession.builder.
master("spark://192.168.64.2:7077") \
    .appName("Pi claculator app") \
    .getOrCreate()
partitions = 2
n = 10000000 * partitions
def func(_):
    x = random() * 2 - 1
    y = random() * 2 - 1
    return 1 if x ** 2 + y ** 2 <= 1 else 0
count = spark.sparkContext.parallelize(range(1, n + 1),   partitions).map(func).reduce(add)
print("Pi is roughly %f" % (4.0 * count / n))

任何命令行输入或输出都按以下方式编写:

Pi is roughly 3.141479 

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“如前所述,Cloud Shell 附带一个编辑器工具,可以通过使用打开编辑器按钮启动。”

小贴士或重要提示

看起来像这样。

联系我们

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

一般反馈:如果您对本书的任何方面有疑问,请通过 customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。

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

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并提供材料的链接。

如果您想成为一名作者:如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问 authors.packtpub.com.

第一部分:Python,超越基础

我们从探索使用 Python 的最佳方式开始,从设计和实现的角度来看。我们提供了对大型 Python 项目生命周期及其各个阶段的深入理解。一旦我们有了这种理解,我们就研究通过模块化 Python 项目来创建优雅设计的不同方法。在必要时,我们深入内部来理解 Python 的内部工作原理。接下来是对 Python 面向对象编程的深入研究。

本节包含以下章节:

  • 第一章最优 Python 开发生命周期

  • 第二章使用模块化处理复杂项目

  • 第三章高级面向对象 Python 编程

第一章:第一章:最优 Python 开发生命周期

考虑到你之前对 Python 的经验,我们在本章中跳过了 Python 语言的入门细节。首先,我们将简要讨论更广泛的 Python 开源社区及其特定的文化。这个介绍很重要,因为这种文化体现在 Python 社区编写的和共享的代码中。然后,我们将介绍典型 Python 项目的不同阶段。接下来,我们将探讨制定典型 Python 项目开发策略的不同方法。

接下来,我们将探讨不同的方法来记录 Python 代码。稍后,我们将研究各种有效的命名方案选项,这可以极大地帮助提高代码的维护性。我们还将探讨在 Python 项目中使用源控制的各种选项,包括开发者主要使用 Jupyter 笔记本进行开发的情况。最后,我们将探讨一旦开发并测试后,如何部署代码以供使用的最佳实践。

本章将涵盖以下主题:

  • Python 文化和社区

  • Python 项目的不同阶段

  • 制定开发策略

  • 有效地记录 Python 代码

  • 制定有效的命名方案

  • 探索源控制的选择

  • 理解部署代码的策略

  • Python 开发环境

本章将帮助你了解典型 Python 项目的生命周期及其各个阶段,以便你能够充分利用 Python 的强大功能。

Python 文化和社区

Python 是一种由 Guido van Rossum 于 1991 年最初开发的解释型高级语言。Python 社区的特殊之处在于它非常关注代码的编写方式。为此,自 Python 的早期以来,Python 社区在其设计哲学中创造并维护了一种特定的风格。如今,Python 被广泛应用于各种行业,从教育到医学。但无论在哪个行业中使用,充满活力的 Python 社区的独特文化通常被视为 Python 项目的组成部分。

尤其是 Python 社区希望我们编写简洁的代码,并在可能的情况下避免复杂性。事实上,有一个形容词,Pythonic,意味着完成某个任务有多种方法,但根据 Python 社区的传统和语言的基础哲学,有一种首选的方法。Python 爱好者们尽力创造尽可能符合 Pythonic 的成果。显然,非 Pythonic 代码意味着在这些爱好者眼中,我们并不是好的程序员。在这本书中,我们将尽力使我们的代码和设计尽可能符合 Pythonic。

除此之外,Pythonic 还有一些官方的规定。Tim Peters 将 Python 的哲学简洁地写在一个简短的文档中,名为《Python 之禅》。我们知道 Python 被认为是最容易阅读的语言之一,而《Python 之禅》希望保持这种风格。它期望 Python 通过良好的文档保持清晰和简洁。我们可以自己阅读《Python 之禅》,如下所述。

为了阅读《Python 之禅》,请打开 Python 控制台并运行import this命令,如下截图所示:

![图 1.1 – Python 之禅图片 B17189_01_01.jpg

图 1.1 – Python 之禅

《Python 之禅》似乎是一篇在古老的埃及墓穴中发现的神秘文本。尽管它是故意以这种随意的神秘方式写成的,但每行文字都有更深的意义。实际上,仔细看看--它可以作为在 Python 中编码的指南。本书将参考《Python 之禅》的不同行。让我们首先看看它的一些摘录,如下所示:

  • 美观比丑陋好:编写易于阅读、易于理解且自我解释的代码很重要。不仅应该能工作,而且应该写得美观。在编码时,我们应该避免使用捷径,而应该选择自我解释的风格。

  • 简单比复杂好:我们不应该无谓地使事情复杂化。面对选择时,我们应该选择更简单的解决方案。反对那些笨拙的、不必要的和复杂的编码方式。即使这会增加一些源代码的行数,简单仍然比复杂的替代方案更好。

  • 应该只有一个--最好是只有一个--明显的做法:从更广泛的角度来看,对于给定的问题,应该有一个可能的最佳解决方案。我们应该努力发现这个解决方案。在我们迭代设计以改进它时,无论我们的方法如何,我们的解决方案都应预期会发展和收敛到那个更可取的解决方案。

  • 现在比永远好:与其等待完美,不如利用我们已有的信息、假设、技能、工具和基础设施开始解决给定的问题。通过迭代过程,我们将不断改进解决方案。让我们保持前进,而不是停滞不前。在等待完美的时机时不要放松。可能性很大,完美的时机永远不会到来。

  • 明确比隐晦好:代码应该尽可能自我解释。这应该体现在变量名、类和函数设计的选择上,以及整体端到端E2E)架构中。在面临选择时,最好谨慎行事。总是使它更加明确。

  • 扁平比嵌套好:嵌套结构简洁,但也会造成混淆。尽可能选择扁平结构。

Python 项目的不同阶段

在我们讨论最佳开发生命周期之前,让我们首先确定 Python 项目的不同阶段。每个阶段可以被视为一组性质相似的活动,如下面的图所示:

![图 1.2 – Python 项目的各个阶段]

![img/B17189_01_02.jpg]

图 1.2 – Python 项目的各个阶段

典型 Python 项目的各个阶段如下概述:

  • 需求分析:这个阶段是关于从所有关键利益相关者那里收集需求,然后分析它们以了解需要做什么,并随后考虑如何去做。利益相关者可以是我们的软件的实际用户或业务所有者。尽可能详细地收集需求是很重要的。在可能的情况下,需求应在开始设计和开发之前,与最终用户和利益相关者充分展开、理解和讨论。

    一个重要的要点是确保需求分析阶段不应包含在设计、开发和测试阶段的迭代循环中。需求分析应该在进入下一阶段之前完全进行并完成。需求应包括功能性需求FRs)和非功能性需求NFRs)。FRs 应分组到模块中。在每个模块内,需求应编号,以便尽可能紧密地与代码模块对应。

  • 设计:设计是我们对需求阶段中提出的技术的响应。在设计阶段,我们确定等式中的“如何”部分。这是一个创造性的过程,我们利用我们的经验和技能,以最有效和最优化的方式提出正确的模块集和结构,以及它们之间的交互。

    注意,提出正确的设计是 Python 项目的一个重要部分。在设计阶段犯的任何错误,其纠正成本将远高于后续阶段的错误。在某些程度上,改变设计和在后续阶段(例如编码阶段)实施设计变更所需的努力是编码阶段发生类似程度变更的 20 倍(例如,无法正确识别类或确定正确的数据和计算项目的维度将产生重大影响,与实现函数时的错误相比)。此外,因为提出正确的设计是一个概念过程,错误可能不明显,无法通过测试来捕捉。另一方面,编码中的错误将通过精心设计的异常处理系统被捕捉到。

    在设计阶段,我们执行以下活动:

    a) 我们设计代码的结构并确定代码中的模块。

    b) 我们决定基本方法,并决定是否应该使用函数式编程、面向对象编程或混合方法。

    c) 我们还识别出类和函数,并选择这些较高层次组件的名称。

    我们还生成较高层次的文档。

  • 编码:这是我们将使用 Python 实现设计的阶段。我们首先实现设计中所识别的较高层次的抽象、组件和模块,然后进行详细编码。在本节中,我们将尽量减少对编码阶段的讨论,因为我们在整本书中会对其进行详细讨论。

  • 测试:测试是验证我们代码的过程。

  • 部署:一旦彻底测试,我们需要将解决方案交给最终用户。最终用户不应看到我们的设计、编码或测试的细节。部署是将解决方案提供给最终用户的过程,该解决方案可以用来解决需求中详细说明的问题。例如,如果我们正在开发一个预测渥太华降雨的机器学习(ML)项目,部署就是找出如何向最终用户提供一个可用的解决方案。

在理解了项目的不同阶段之后,我们将继续探讨如何制定整体过程。

制定开发过程策略

制定开发过程策略是关于规划每个阶段,并查看从一个阶段到另一个阶段的过程流程。为了制定开发过程策略,我们首先需要回答以下问题:

  1. 我们是否寻求最小化设计方法,并直接进入编码阶段,而设计较少?

  2. 我们是否希望采用测试驱动开发TDD),即首先使用需求创建测试,然后编写代码?

  3. 我们是希望首先创建一个最小可行产品MVP)并迭代地完善解决方案吗?

  4. 验证 NFRs(如安全性和性能)的策略是什么?

  5. 我们是寻求单节点开发,还是希望在集群或云端进行开发和部署?

  6. 我们输入和输出的数据量、速度和种类是什么?是Hadoop 分布式文件系统HDFS)或简单存储服务S3)基于文件的架构,还是结构化查询语言SQL)或 NoSQL 数据库?数据是在本地还是云端?

  7. 我们是否在处理特定的用例,例如具有创建数据管道、测试模型、部署和维护特定要求的机器学习(ML)?

根据这些问题的答案,我们可以为我们的开发过程制定策略。在最近的时间里,总是更倾向于以某种形式使用迭代开发过程。将 MVP 作为起始目标的概念也很流行。我们将在下一小节中讨论这些内容,以及特定领域的开发需求。

遍历各个阶段

现代软件开发哲学基于设计、开发和测试的短迭代周期。在代码开发中曾经使用的传统瀑布模型已经死去。选择这些阶段的正确粒度、重点和频率取决于项目的性质和我们的代码开发策略选择。如果我们想选择一个设计最少、直接进入编码的代码开发策略,那么设计阶段就非常薄。但即使直接开始编码,也需要对最终将实现的设计模块进行一些思考。

无论我们选择什么策略,设计和开发、测试阶段之间都存在固有的迭代关系。我们最初从设计阶段开始,将其在编码阶段实现,然后通过测试来验证它。一旦我们发现了缺陷,我们就需要通过重新访问设计阶段回到起点。

首先追求最小可行产品 (MVP)

有时,我们会选择最重要的需求的一个小主题,首先实现 MVP,目的是迭代地改进它。在迭代过程中,我们设计、编码和测试,直到我们创建一个可以部署和使用的最终产品。

现在,让我们谈谈我们将如何用 Python 实现一些特定领域的解决方案。

为特定领域制定开发策略

Python 目前被用于各种场景。让我们看看以下五个重要的用例,看看我们如何根据它们的具体需求制定每个用例的开发策略:

  • 机器学习

  • 云计算和集群计算

  • 系统编程

  • 网络编程

  • 无服务器计算

我们将在以下各节中讨论每个部分。

机器学习

多年来,Python 已经成为实现机器学习算法最常用的语言。机器学习项目需要一个结构良好的环境。Python 拥有一系列高质量库,这些库可用于机器学习。

对于典型的机器学习项目,有一个 数据挖掘跨行业标准流程 (CRISP-DM) 生命周期,它规定了机器学习项目的各个阶段。CRISP-DM 生命周期看起来是这样的:

图 1.3 – CRISP-DM 生命周期

图 1.3 – CRISP-DM 生命周期

对于机器学习项目,设计和实现数据管道预计占开发工作的 70% 左右。在设计数据处理管道时,我们应该记住,理想情况下,管道将具有以下特征:

  • 它们应该是可扩展的。

  • 在可能的情况下,它们应该是可重用的。

  • 他们应该通过遵循 Apache Beam 标准来处理流式和批量数据。

  • 它们应该主要是 fit 和 transform 函数的连接,正如我们将在 第六章 中讨论的,Python 高级技巧与窍门

此外,机器学习项目测试阶段的一个重要部分是模型评估。我们需要确定根据问题的需求、数据的性质和所实现的算法类型,哪个性能指标是最好的来量化模型的性能。我们是关注准确度、精确度、召回率、F1 分数,还是这些性能指标的组合?模型评估是测试过程中的一个重要部分,并且需要在其他软件项目中进行的标准测试之外进行。

云计算和集群计算

云计算和集群计算给底层基础设施增加了额外的复杂性。云服务提供商提供需要专用库的服务。Python 的架构从最基本的核心包开始,并具有导入任何其他包的能力,这使得它非常适合云计算。Python 环境提供的平台独立性对于云计算和集群计算至关重要。PythonAmazon Web Services (AWS), Windows Azure 和 Google Cloud Platform (GCP) 的首选语言。

云计算和集群计算项目有独立的开发、测试和生产环境。保持开发和生产环境同步非常重要。

当使用 基础设施即服务 (IaaS) 时,Docker 容器可以大有帮助,并且建议使用它们。一旦我们使用 Docker 容器,代码在哪里运行就无关紧要了,因为代码将具有完全相同的环境和依赖项。

系统编程

Python 具有操作系统服务的接口。其核心库有 可移植操作系统接口 (POSIX) 绑定,允许开发者创建所谓的 shell 工具,这些工具可用于系统管理和各种实用程序。用 Python 编写的 shell 工具可以在各种平台上兼容。相同的工具可以在 Linux、Windows 和 macOS 上使用,无需任何更改,这使得它们非常强大且易于维护。

例如,一个在 Linux 上开发和测试的完整目录的 shell 工具可以在 Windows 上无需更改地运行。Python 对系统编程的支持包括以下内容:

  • 定义环境变量

  • 支持文件、套接字、管道、进程和多线程

  • 能够指定用于模式匹配的 正则表达式 (regex)

  • 能够提供命令行参数

  • 支持标准流接口、shell 命令启动器和文件名扩展

  • 能够压缩文件实用工具

  • 能够解析 可扩展标记语言 (XML) 和 JavaScript 对象表示法 (JSON) 文件

当使用 Python 进行系统开发时,部署阶段是最小的,可能只是将代码打包成可执行文件。重要的是要提到,Python 不打算用于系统级驱动程序或操作系统库的开发。

网络编程

在数字化转型时代,信息技术IT)系统正快速向自动化方向发展,网络被认为是全栈自动化的主要瓶颈。其原因是不同供应商的专有网络操作系统以及缺乏开放性,但数字化转型的前提条件正在改变这一趋势,许多工作正在进行中,以使网络可编程并作为服务(网络即服务,或NaaS)提供。真正的问题是:我们能否使用 Python 进行网络编程? 答案是肯定的 YES。事实上,它是用于网络自动化的最受欢迎的语言之一。

Python 对网络编程的支持包括以下内容:

  • 包括传输控制协议TCP)和用户数据报协议UDP)套接字的套接字编程

  • 支持客户端和服务器通信

  • 支持端口监听和处理数据

  • 在远程安全外壳SSH)系统上执行命令

  • 使用安全复制协议SCP)/文件传输协议FTP)上传和下载文件

  • 支持用于简单网络管理协议SNMP)的库

  • 支持用于检索和更新配置的表示状态传输RESTCONF)和网络配置NETCONF)协议

无服务器计算

无服务器计算是一种基于云的应用程序执行模型,其中云服务提供商(CSPs)提供计算机资源和应用程序服务器,以允许开发者无需管理计算资源和服务器即可部署和执行应用程序。所有主要公共云供应商(Microsoft Azure Serverless Functions、AWS Lambda 和谷歌云平台,或GCP)都支持 Python 的无服务器计算。

我们需要理解,在无服务器环境中仍然存在服务器,但这些服务器由云服务提供商(CSPs)管理。作为应用程序开发者,我们不需要负责安装和维护服务器,也没有直接责任于服务器的可扩展性和性能。

对于 Python,有流行的无服务器库和框架可用。这些将在下面进行描述:

  • 无服务器:无服务器框架是一个开源框架,用于无服务器函数或 AWS Lambda 服务,并使用 Node.js 编写。无服务器是第一个为在 AWS Lambda 上构建应用程序而开发的框架。

  • Chalice:这是一个由 AWS 开发的 Python 无服务器微框架。这是开发者使用 AWS Lambda 服务快速启动和部署 Python 应用程序的首选,因为它允许你快速启动和部署一个可自动扩展和缩放的运行服务器,使用 AWS Lambda。Chalice 的另一个关键特性是它提供了一个工具,可以在将应用程序推送到云端之前在本地模拟你的应用程序。

  • Zappa:这是一个内置在 Python 中的部署工具,使得部署你的 Web 服务器网关接口(WSGI) 应用变得简单。

现在,让我们看看开发 Python 代码的有效方法。

有效记录 Python 代码

找到一个有效的方式来记录代码始终很重要。挑战在于开发一种全面而简单的方式来开发 Python 代码。让我们首先看看 Python 注释,然后是文档字符串。

Python 注释

与文档字符串相反,Python 注释对运行时编译器不可见。它们用作解释代码的注释。Python 中的注释以 # 符号开始,如下面的屏幕截图所示:

图 1.4 – Python 中注释的一个示例

图 1.4 – Python 中注释的一个示例

文档字符串

记录代码的主要工具是多行注释块,称为 """)。

在创建文档字符串时,以下是一些一般性指南:

  • 文档字符串应放置在函数或类定义之后。

  • 文档字符串应提供一个简短的总结,然后是更详细的描述。

  • 应有策略地使用空白空间来组织注释,但不应过度使用。你可以使用空白行来组织代码,但不要过度使用。

在以下章节中,让我们看看文档字符串的更多详细概念。

文档字符串风格

Python 文档字符串有以下略微不同的风格:

  • Google

  • NumPy/SciPy

  • Epytext

  • 重新结构化

文档字符串类型

在开发代码时,需要产生各种类型的文档,包括以下内容:

  • 行内注释

  • 函数或类级别文档

  • 算法细节

让我们逐一讨论它们。

行内注释

文档字符串的一个简单用途是使用它来创建多行注释,如下所示:

图 1.5 – Python 中行内注释类型文档字符串的示例

](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/py-gk/img/B17189_01_05.jpg)

图 1.5 – Python 中行内注释类型文档字符串的示例

函数或类级别文档

文档字符串的一个强大用途是用于函数或类级别的文档。如果我们将文档字符串放置在函数或类的定义之后,Python 会将文档字符串与该函数或类关联起来。这被放置在该特定函数或类的 __doc__ 属性中。我们可以在运行时通过使用 __doc__ 属性或使用 help 函数来打印出来,如下面的示例所示:

图 1.6 – 帮助函数的示例

图片

图 1.6 – 帮助函数的示例

在使用文档字符串记录类时,建议的结构如下:

  • 摘要:通常是一行

  • 第一空行

  • 关于文档字符串的任何进一步解释

  • 第二空行

这里展示了在类级别使用文档字符串的示例:

图 1.7 – 类级别文档字符串的示例

图 1.7 – 类级别文档字符串的示例

算法细节

越来越多的 Python 项目使用描述性或预测性分析以及其他复杂逻辑。所使用的算法的细节需要清楚地指定,包括所有做出的假设。如果一个算法作为函数实现,那么在函数签名之前写算法逻辑的总结是最好的地方。

开发有效的命名方案

如果在代码中开发和实现正确的逻辑是科学,那么使其美观和可读是艺术。Python 开发者因其特别关注命名方案并将 Python 的禅意 带入其中而闻名。Python 是少数几种由 Guido van Rossum 编写的关于命名方案的全面指南的语言之一。它们写在 PEP 8 文档中,该文档有一个关于命名约定的完整部分,许多代码库都遵循它。PEP 8 提供了命名和风格指南的建议。您可以在 www.Python.org/dev/peps/pep-0008/ 上了解更多信息。

PEP 8 中建议的命名方案可以总结如下:

  • 通常,所有模块名称都应该使用 all_lower_case

  • 所有类名和异常名应该使用 CamelCase

  • 所有全局和局部变量都应该使用 all_lower_case

  • 所有函数和方法名称应该使用 all_lower_case

  • 所有常量都应该使用 ALL_UPPER_CASE

这里给出了一些关于 PEP 8 中代码结构的指南:

  • 在 Python 中缩进很重要。不要使用 Tab 进行缩进。相反,使用四个空格。

  • 限制嵌套到四级。

  • 记住要限制行数为 79 个字符。使用 \ 符号来断开长行。

  • 为了使代码可读,在函数之间插入两个空行来分隔。

  • 在不同的逻辑部分之间插入一个单行。

记住,PEP 指南只是一些建议,可能被不同的团队定制。任何定制的命名方案都应该仍然使用 PEP 8 作为基本指南。

现在,让我们更详细地看看在 Python 语言结构的上下文中的命名方案。

方法

方法名称应使用小写。名称应由一个单词或多个单词组成,单词之间用下划线分隔。您可以在这里看到示例:

calculate_sum

为了使代码可读,方法最好是一个动词,与该方法应执行的处理相关。

如果一个方法是非公共的,它应该有一个前导下划线。以下是一个示例:

_my_calculate_sum

双下划线魔术方法是具有前导和尾随下划线的方法。双下划线或魔术方法的示例如下所示:

  • __init__

  • __add__

从不使用两个前导和尾随下划线来命名方法是一个好主意,并且开发者被鼓励不要使用这些方法。这种命名方案是为 Python 方法设计的。

变量

使用小写单词或由下划线分隔的单词来表示变量。变量应该是名词,与它们所代表的实体相对应。

这里给出了变量的示例:

  • x

  • my_var

私有变量的名称应该以下划线开头。例如,_my_secret_variable

布尔变量

ishas开头布尔变量使其更易读。您可以在以下示例中看到几个这样的例子:

class Patient:
    is_admitted = False
    has_heartbeat = False

集合变量

由于集合是变量的桶,因此最好以复数格式命名它们,如下所示:

class Patient:
    admitted_patients = ['John','Peter']

字典变量

字典的名称建议尽可能明确。例如,如果我们有一个将人映射到他们居住城市的字典,则可以创建如下字典:

persons_cities = {'Imran': 'Ottawa', 'Steven': 'Los Angeles'}

常量

Python 没有不可变变量。例如,在 C++中,我们可以使用const关键字来指定变量是不可变的,并且是常量。Python 依赖于命名约定来指定常量。如果代码试图将常量作为普通变量处理,Python 不会给出错误。

对于常量,建议使用大写单词或由下划线分隔的单词。以下是一个常量的示例:

CONVERSION_FACTOR

类应该遵循驼峰式命名风格——换句话说,它们应该以大写字母开头。如果我们需要使用多个单词,单词之间不应使用下划线分隔,但附加的每个单词都应该以大写字母开头。类应该使用名词,并且应该以最能代表类对应实体的方式命名。使代码可读的一种方法是为与它们的类型或性质有关的类使用后缀,如下所示:

  • HadoopEngine

  • ParquetType

  • TextboxWidget

这里有一些需要注意的点:

  • 有异常类处理错误。它们的名称应该始终以Error结尾。以下是一个示例:

    FileNotFoundError
    
  • 一些 Python 的内置类不遵循此命名指南。

  • 为了提高可读性,对于基类或抽象类,可以使用BaseAbstract前缀。以下是一个示例:

    AbstractCar
    BaseClass
    

在命名包时,不建议使用下划线。名称应简短且全部小写。如果需要使用多个单词,附加的单词或词组也应全部小写。以下是一个示例:

mypackage

模块

在命名模块时,应使用简短、直接的名字。它们需要是小写的,并且多个单词将通过下划线连接。以下是一个示例:

main_module.py

导入约定

多年来,Python 社区已经形成了一套用于常用包的别名约定。您可以在以下示例中看到这一点:

import numpy as np
import pandas as pd
import seaborn as sns
import statsmodels as sm
import matplotlib.pyplot as plt 

论点

建议参数的命名约定与变量相似,因为函数的参数实际上是一些临时变量。

有用的工具

有一些工具可以用来测试您的代码与 PEP 8 指南的符合程度。让我们逐一探讨。

Pylint

可以通过运行以下命令来安装 Pylint:

$ pip install pylint

Pylint 是一个源代码分析器,它根据 PEP 89 检查代码的命名约定,然后打印出报告。它可以定制以用于其他命名约定。

PEP 8

可以通过运行以下命令来安装 PEP 8

 pip: $ pip install pep8

pep8 会根据 PEP 8 指南检查代码。

到目前为止,我们已经了解了 Python 中的各种命名约定。接下来,我们将探讨使用源代码控制进行 Python 开发的不同选择。

探索源代码控制的选择

首先,我们将简要回顾源代码控制系统的历史,以提供背景信息。现代源代码控制系统非常强大。源代码控制系统的演变经历了以下阶段:

  • 第一阶段:源代码最初是由存储在硬盘上的本地源代码控制系统启动的。这个本地代码集合被称为本地仓库。

  • 第二阶段:但本地使用源代码控制对于大型团队来说并不合适。这个解决方案最终演变成一个基于中央服务器的仓库,由特定项目上的团队成员共享。它解决了团队成员之间代码共享的问题,但也为多用户环境中的文件锁定带来了额外的挑战。

  • 第三阶段:现代版本控制仓库,如 Git,进一步发展了这一模型。团队的所有成员现在都有一个存储的仓库的完整副本。团队成员现在可以在代码上离线工作。他们只需要在需要共享代码时连接到仓库。

什么不属于源代码仓库?

让我们看看不应该提交到源代码仓库中的内容。

首先,除了源代码文件之外,其他任何内容都不应该被提交。计算机生成的文件不应该被提交到源代码控制中。例如,假设我们有一个名为 main.py 的 Python 源代码文件。如果我们编译它,生成的代码不属于仓库。编译后的代码是一个派生文件,不应该被提交到源代码控制中。这里有三个原因,如下所述:

  • 一旦我们有了源代码,团队中的任何成员都可以生成派生文件。

  • 在许多情况下,编译后的代码比源代码大得多,将其添加到仓库会使系统变慢。此外,记住如果团队中有 16 名成员,那么他们每个人都会不必要地获得该生成文件的副本,这将不必要地减慢整个系统的速度。

  • 版本控制系统旨在存储自上次提交以来对源文件所做的更改或增量。除了源代码文件之外,其他文件通常是二进制文件。版本控制系统很可能无法为这些文件提供diff工具,每次提交时都需要存储整个文件。这将对版本控制框架的性能产生负面影响。

其次,任何机密信息都不应属于源代码控制。这包括 API 密钥和密码。

对于源代码库,GitHub 是 Python 社区的首选。许多著名的 Python 包的源代码控制也位于 GitHub 上。如果 Python 代码要在多个团队之间使用,那么就需要开发和维护正确的协议和程序。

理解部署代码的策略

对于开发团队不是最终用户的工程项目,制定一个为最终用户部署代码的策略非常重要。对于相对大规模的项目,当存在明确的DEVPROD环境时,部署代码和制定策略变得很重要。

Python 是云和集群计算环境的首选语言。

部署代码相关的问题如下列出:

  • DEVTESTPROD环境中,需要发生完全相同的转换。

  • 随着代码在DEV环境中的不断更新,如何将这些更改同步到PROD环境?

  • 你计划在DEVPROD环境中进行哪种类型的测试?

让我们来看看部署代码的两种主要策略。

批量开发

这就是传统的开发流程。我们编写代码,编译它,然后测试它。这个过程会迭代重复,直到所有需求都得到满足。然后,开发的代码就会被部署。

采用持续集成和持续交付

在 Python 的上下文中,持续集成/持续交付CI/CD)指的是持续集成和部署,而不是作为批量过程执行。它通过弥合开发和运维之间的差距,有助于创建开发-运维DevOps)环境。

CI指的是在代码更新时持续集成、构建和测试代码的各个模块。对于一个团队来说,这意味着每个团队成员独立开发的代码会被集成、构建和测试,通常每天多次。一旦测试通过,源代码控制中的仓库就会被更新。

CI 的优势在于问题或错误在初期就被修复。一个典型的错误在它被创建的那天修复,立即解决它所需的时间比几天、几周或几个月后解决它要少得多,那时它已经渗透到其他模块,受影响的成员可能已经创建了多层依赖。

与 Java 或 C++不同,Python 是一种解释型语言,这意味着构建的代码可以在任何带有解释器的目标机器上执行。相比之下,编译的代码通常为一种目标机器构建,可能由团队的不同成员开发。一旦我们弄清楚每次更改需要遵循哪些步骤,我们就可以自动化它。

由于 Python 代码依赖于外部包,跟踪它们的名称和版本是自动化构建过程的一部分。一个良好的做法是将所有这些包列在一个名为requirements.txt的文件中。名称可以是任何东西,但 Python 社区通常倾向于将其称为requirements.txt

要安装包,我们将执行以下命令:

$pip install -r requirements.txt

要创建一个表示我们代码中使用的包的requirements文件,我们可以使用以下命令:

$pip freeze > requirements.txt

集成的目标是尽早捕捉错误和缺陷,但它有可能使开发过程不稳定。有时团队成员可能会引入一个严重的错误,从而破坏代码,如果其他团队成员可能必须等待该错误解决,他们可能需要等待。团队成员进行稳健的自我测试和选择适当的集成频率将有助于解决问题。为了进行稳健的测试,每次更改时都应该运行测试。这个过程最终应该完全自动化。在出现错误的情况下,构建应该失败,并且应该通知负责有缺陷模块的团队成员。该团队成员可以选择在花费时间解决和全面测试问题之前先提供一个快速修复,以确保其他团队成员不会受阻。

一旦代码构建和测试完成,我们可以选择更新部署的代码。这将实现CD部分。如果我们选择拥有完整的 CI/CD 流程,这意味着每次进行更改时,都会进行构建和测试,并且更改会反映在部署的代码中。如果管理得当,最终用户将受益于不断演变的解决方案。在某些用例中,每个 CI/CD 周期可能是一个从 MVP 到完整解决方案的迭代移动。在其他用例中,我们试图捕捉和制定快速变化的现实世界问题,摒弃过时的假设,并纳入新信息。一个例子是对 COVID-19 形势的图案分析,它每小时都在变化。此外,新信息正在以极快的速度涌现,与其相关的任何用例都可能从 CI/CD 中受益,其中开发者不断根据新出现的事实和信息更新他们的解决方案。

接下来,我们将讨论 Python 常用的开发环境。

Python 开发环境

文本编辑器是编辑 Python 代码的诱人选择。但对于任何中等或大型项目,我们必须认真考虑 Python 集成开发环境IDEs),这对于使用版本控制编写、调试和故障排除代码以及简化部署非常有帮助。市场上有很多 IDE,大多数都是免费的。在本节中,我们将回顾其中的一些。请注意,我们不会尝试按任何顺序对它们进行排名,而是强调每个 IDE 带来的价值,读者可以根据自己的以往经验、项目需求和项目的复杂性来做出最佳选择。

IDLE

集成开发和学习环境IDLE)是 Python 的默认编辑器,适用于所有主要平台(Windows、macOS 和 Linux)。它是免费的,对于学习目的而言是一个不错的 IDE。它不推荐用于高级编程。

Sublime Text

Sublime Text 是另一个流行的代码编辑器,支持多种语言。它仅用于评估目的且免费。它也适用于所有主要平台(Windows、macOS 和 Linux)。它提供了基本的 Python 支持,但凭借其强大的扩展框架,我们可以自定义它以创建一个需要额外技能和时间才能构建的完整开发环境。通过插件可以实现与版本控制系统(如 Git 或 SubversionSVN))的集成,但可能不会完全暴露版本控制功能。

Atom 是另一个流行的编辑器,与 Sublime Text 同属一类。它是免费的。

PyCharm

PyCharm 是适用于 Python 编程的最好的 Python IDE 编辑器之一,适用于 Windows、macOS 和 Linux。它是一个专为 Python 编程定制的完整 IDE,帮助程序员完成代码、调试、重构、智能搜索、访问流行的数据库服务器、与版本控制系统集成以及更多功能。IDE 为开发者提供了一个插件平台,以便根据需要扩展基本功能。PyCharm 可以下列格式提供:

  • 社区版,免费且适用于纯 Python 开发

  • 专业版,不免费,并支持网页开发,如 超文本标记语言HTML)、JavaScript 和 SQL

Visual Studio Code

Visual Studio CodeVS Code)是由微软开发的开源环境。对于 Windows,VS Code 是最好的 Python IDE。它默认不包含 Python 开发环境。VS Code 的 Python 扩展可以使它成为一个 Python 开发环境。

它轻量且功能强大。它是免费的,也适用于 macOS 和 Linux。它包含诸如代码补全、调试、重构、搜索、访问数据库服务器、版本控制系统集成等功能。

PyDev

如果您使用过或正在使用 Eclipse,您可能会考虑 PyDev,这是一个 Eclipse 的第三方编辑器。它属于最佳 Python IDE 之一,也可以用于 Jython 和 IronPython。它是免费的。由于 PyDev 只是 Eclipse 上的一个插件,因此它适用于所有主要平台,如 Eclipse。这个 IDE 拥有 Eclipse 的所有功能,并且在此基础上,它简化了与 Django、单元测试和 Google App EngineGAE)的集成。

Spyder

如果您计划使用 Python 进行数据科学和机器学习,您可能希望考虑将 Spyder 作为您的 IDE。Spyder 是用 Python 编写的。这个 IDE 提供了完整的编辑、调试、交互式执行、深度检查和高级可视化功能。此外,它支持与 Matplotlib、SciPy、NumPy、Pandas、Cython、IPython 和 SymPy 集成,使其成为数据科学家的默认 IDE。

根据本节对不同 IDE 的回顾,我们可以推荐 PyCharm 和 PyDev 给专业应用程序开发者。但如果您更倾向于数据科学和机器学习,Spyder 确实值得探索。

摘要

在本章中,我们为本书中后续章节讨论的高级 Python 概念奠定了基础。我们首先介绍了 Python 项目的风味、指导和环境。我们通过首先确定 Python 项目的不同阶段,然后根据我们正在处理的使用案例探索不同的优化方式开始了技术讨论。对于像 Python 这样简洁的语言,高质量的文档对于使代码可读和明确大有裨益。

我们还研究了记录 Python 代码的各种方法。接下来,我们调查了在 Python 中创建文档的推荐方法。我们还研究了可以帮助我们使代码更易读的命名方案。接下来,我们探讨了我们可以使用的不同源代码控制方法。我们还弄清楚了部署 Python 代码的不同方式。最后,我们回顾了几种 Python 开发环境,以帮助您根据他们的背景和您将要工作的项目类型选择开发环境。

本章涉及的主题对任何开始新项目涉及 Python 的人来说都有益。这些讨论有助于迅速有效地制定新项目的策略和设计决策。在下一章中,我们将探讨如何模块化 Python 项目的代码。

问题

  1. 什么是 Python 的禅宗

  2. 在 Python 中,运行时可以提供哪些类型的文档?

  3. 什么是 CRISP-DM 生命周期?

进一步阅读

  • 《现代 Python 食谱 第二版》,作者 Steven F. Lott

  • 《Python 编程蓝图》,作者 Daniel Furtado

  • 《Python 忍者秘籍》,作者 Cody Jackson

答案

  1. Tim Peters 编写的 19 条指南的集合,适用于 Python 项目的开发设计。

  2. 与常规注释不同,文档字符串在编译时对编译器是可用的。

  3. CRISP-DM 代表 跨行业数据挖掘标准流程。它适用于 ML 领域的 Python 项目生命周期,并确定了项目的不同阶段。

第二章:第二章:使用模块化处理复杂项目

当您开始用 Python 编程时,将所有程序代码放入一个文件中是非常诱人的。在您的主程序文件中定义函数和类没有问题。这个选项对初学者有吸引力,因为程序的执行简单,可以避免在多个文件中管理代码。但是,对于中到大型项目,单文件程序方法不可扩展。跟踪您定义的所有各种函数和类变得具有挑战性。

为了克服这种情况,模块化编程是中到大型项目的最佳选择。模块化是减少项目复杂性的关键工具。模块化还促进了高效的编程、易于调试和管理、协作和重用。在本章中,我们将讨论如何在 Python 中构建和消费模块和软件包。

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

  • 模块和软件包简介

  • 导入模块

  • 加载和初始化模块

  • 编写可重用模块

  • 构建软件包

  • 从任何位置访问软件包

  • 分享软件包

本章将帮助您理解 Python 中模块和软件包的概念。

技术要求

以下为本章的技术要求:

  • 您需要在您的计算机上安装 Python 3.7 或更高版本。

  • 您需要在 Test PyPI 上注册一个账户,并在您的账户下创建一个 API 令牌。

本章的示例代码可以在github.com/PacktPublishing/Python-for-Geeks/tree/master/Chapter02找到。

模块和软件包简介

Python 中的模块是具有 .py 扩展名的 Python 文件。实际上,它们是通过一个或多个 Python 文件组织函数、类和变量的方式,使得它们易于管理、跨不同模块重用,并在程序变得复杂时扩展。

Python 软件包是模块化编程的下一级。软件包就像一个文件夹,用于组织多个模块或子软件包,这对于模块的重用性共享是基本的。

仅使用标准库的 Python 源文件易于共享和分发,可以通过电子邮件、GitHub 和共享驱动器进行,唯一的缺点是应该有 Python 版本兼容性。但是,对于拥有相当数量的文件并依赖于第三方库的项目,以及可能为特定版本的 Python 开发的项目,这种共享方法将无法扩展。为了解决这个问题,构建和共享软件包对于 Python 程序的有效共享和重用是必不可少的。

接下来,我们将讨论如何导入模块以及 Python 支持的不同类型的导入技术。

导入模块

Python 模块中的一个模块可以通过一个称为导入模块的过程访问另一个模块中的 Python 代码。

为了详细说明不同的模块和包概念,我们将构建两个模块和一个主脚本,这些脚本将使用这两个模块。这两个模块将在本章中更新或重用。

要创建一个新的模块,我们将创建一个以模块名称命名的 .py 文件。我们将创建一个 mycalculator.py 文件,其中包含两个函数:addsubtractadd 函数计算传递给函数作为参数的两个数字的和,并返回计算值。subtract 函数计算传递给函数作为参数的两个数字之间的差,并返回计算值。

接下来展示 mycalculator.py 的代码片段:

# mycalculator.py with add and subtract functions
def add(x, y):
    """This function adds two numbers"""
    return x + y
def subtract(x, y):
    """This function subtracts two numbers"""
    return x - y

注意,模块的名称就是文件的名称。

我们将通过添加一个名为 myrandom.py 的新文件来创建第二个模块。此模块有两个函数:random_1drandom_2drandom_1d 函数用于生成介于 1 和 9 之间的随机数,而 random_2d 函数用于生成介于 10 和 99 之间的随机数。请注意,此模块也使用了 random 库,这是 Python 的内置模块。

接下来展示 myrandom.py 的代码片段:

# myrandom.py with default and custom random functions
import random
def random_1d():
   """This function generates a random number between 0 \
    and 9"""
   return random.randint (0,9)
def random_2d():
   """This function generates a random number between 10 \
    and 99"""
   return random.randint (10,99)

为了使用这两个模块,我们还创建了主 Python 脚本(calcmain1.py),它导入这两个模块并使用它们来实现这两个计算器功能。import 语句是导入内置或自定义模块最常见的方式。

接下来展示 calcmain1.py 的代码片段:

# calcmain1.py with a main function
import mycalculator
import myrandom
def my_main( ):
    """ This is a main function which generates two random\     numbers and then apply calculator functions on them """
    x = myrandom.random_2d( )
    y = myrandom.random_1d( )
    sum = mycalculator.add(x, y)
    diff = mycalculator.subtract(x, y)
    print("x = {}, y = {}".format(x, y))
    print("sum is {}".format(sum))
    print("diff is {}".format(diff))
 """ This is executed only if the special variable '__name__'  is set as main"""
if __name__ == "__main__":
    my_main()

在这个主脚本(另一个模块)中,我们使用 import 语句导入两个模块。我们定义了主函数(my_main),它只会在脚本或 calcmain1 模块作为主程序执行时运行。主程序中执行主函数的细节将在后面的 设置特殊变量 部分进行介绍。在 my_main 函数中,我们使用 myrandom 模块生成两个随机数,然后使用 mycalculator 模块计算这两个随机数的和与差。最后,我们使用 print 语句将结果发送到控制台。

重要提示

模块只加载一次。如果一个模块被另一个模块或主 Python 脚本导入,该模块将通过执行模块中的代码来初始化。如果程序中的另一个模块再次导入相同的模块,它将不会加载两次,而只加载一次。这意味着如果模块内部有任何局部变量,它们将作为单例(只初始化一次)。

导入模块还有其他选项,例如 importlib.import_module() 和内置的 __import__() 函数。让我们讨论一下 import 和其他替代选项是如何工作的。

使用导入语句

如前所述,import 语句是导入模块的一种常见方式。下面的代码片段是使用 import 语句的一个示例:

import math

import语句负责两个操作:首先,它搜索import关键字后面的模块,然后将搜索结果绑定到执行局部作用域中的一个变量名(与模块名相同)。在接下来的两个小节中,我们将讨论import是如何工作的,以及如何从模块或包中导入特定元素。

学习如何使用导入

接下来,我们需要了解import语句是如何工作的。首先,我们需要提醒自己,Python 解释器在执行开始时会将所有全局变量和函数添加到全局命名空间中。为了说明这个概念,我们可以编写一个小型的 Python 程序来输出globals命名空间的内容,如下所示:

# globalmain.py with globals() function
def print_globals():
    print (globals())
def hello():
    print ("Hello")
if __name__ == "__main__":
    print_globals()

这个程序有两个函数:print_globalshelloprint_globals函数将输出全局命名空间的内容。hello函数将不会执行,这里添加它是为了在全局命名空间的控制台输出中显示其引用。执行此 Python 代码后的控制台输出将类似于以下内容:

{
   "__name__":"__main__",
   "__doc__":"None",
   "__package__":"None",
   "__loader__":"<_frozen_importlib_external.\
    SourceFileLoader object at 0x101670208>",
   "__spec__":"None",
   "__annotations__":{
   },
   "__builtins__":"<module 'builtins' (built-in)>",
   "__file__":"/ PythonForGeeks/source_code/chapter2/\
     modules/globalmain.py",
   "__cached__":"None",
   "print_globals":"<function print_globals at \
     0x1016c4378>",
   "hello":"<function hello at 0x1016c4400>"
}

在这个控制台输出中需要注意的关键点如下:

  • __name__变量被设置为__main__值。这将在加载和初始化模块部分进行更详细的讨论。

  • __file__变量被设置为主模块的文件路径。

  • 在末尾添加每个函数的引用。

如果我们在calcmain1.py脚本中添加print(globals()),那么添加此语句后的控制台输出将类似于以下内容:

{
   "__name__":"__main__",
   "__doc__":"None",
   "__package__":"None",
   "__loader__":"<_frozen_importlib_external.\
    SourceFileLoader object at 0x100de1208>",
   "__spec__":"None",
   "__annotations__":{},
   "__builtins__":"<module 'builtins' (built-in)>",
   "__file__":"/PythonForGeeks/source_code/chapter2/module1/     main.py",
   "__cached__":"None",
   "mycalculator":"<module 'mycalculator' from \
    '/PythonForGeeks/source_code/chapter2/modules/\
    mycalculator.py'>",
   "myrandom":"<module 'myrandom' from '/PythonForGeeks/source_     code/chapter2/modules/myrandom.py'>",
   "my_main":"<function my_main at 0x100e351e0>"
}

需要注意的一个重要点是,对于每个使用import语句导入这些模块的情况,都会在全局命名空间中添加两个额外的变量(mycalculatormyrandom)。每次我们导入一个库时,都会创建一个具有相同名称的变量,该变量持有对模块的引用,就像全局函数的变量(在这种情况下是my_main)一样。

我们将看到,在其他导入模块的方法中,我们可以为每个模块显式定义一些这些变量。import语句会自动为我们完成这项工作。

特定导入

我们也可以从模块中导入特定的内容(变量、函数或类),而不是导入整个模块。这是通过使用from语句实现的,如下所示:

from math import pi

另一个最佳实践是,为了方便或有时在两个不同的库中使用相同名称为不同资源时,为导入的模块使用不同的名称。为了说明这个想法,我们将更新之前的calcmain1.py文件(更新的程序是calcmain2.py),通过使用calcrand别名分别代表mycalculatormyrandom模块。这个更改将使在主脚本中使用模块变得更加简单,如下所示:

# calcmain2.py with alias for modules
import mycalculator as calc
import myrandom as rand
def my_main():
    """ This is a main function which generates two random\
     numbers and then apply calculator functions on them """
    x = rand.random_2d()
    y = rand.random_1d()
    sum = calc.add(x,y)
    diff = calc.subtract(x,y)
    print("x = {}, y = {}".format(x,y))
    print("sum is {}".format(sum))
    print("diff is {}".format(diff))
""" This is executed only if the special variable '__name__' is set as main"""
if __name__ == "__main__":
    my_main()

作为下一步,我们将在calcmain1.py程序的下一迭代中结合之前讨论的两个概念(更新后的程序是calcmain3.py)。在这个更新中,我们将使用from语句与模块名称一起,然后从每个模块中导入单个函数。对于addsubtract函数,我们使用了as语句来定义一个不同的本地模块资源定义,以供说明。

calcmain3.py的代码片段如下:

# calcmain3.py with from and alias combined
from mycalculator import add as my_add
from mycalculator import subtract as my_subtract
from myrandom import random_2d, random_1d
def my_main():
    """ This is a main function which generates two random
     numbers and then apply calculator functions on them """
    x = random_2d()
    y = random_1d()
    sum =  my_add(x,y)
    diff = my_subtract(x,y)
    print("x = {}, y = {}".format(x,y))
    print("sum is {}".format(sum))
    print("diff is {}".format(diff))
    print (globals())
""" This is executed only if the special variable '__name__' is set as main"""
if __name__ == "__main__":
    my_main()

由于我们使用了print (globals())语句,这个程序的控制台输出将显示每个函数对应的变量按照我们的别名创建。示例控制台输出如下:

{
   "__name__":"__main__",
   "__doc__":"None",
   "__package__":"None",
   "__loader__":"<_frozen_importlib_external.\
    SourceFileLoader object at 0x1095f1208>",
   "__spec__":"None",
   "__annotations__":{},
   "__builtins__":"<module 'builtins' (built-in)>", "__    file__":"/PythonForGeeks/source_code/chapter2/module1/     main_2.py",
   "__cached__":"None",
   "my_add":"<function add at 0x109645400>",
   "my_subtract":"<function subtract at 0x109645598>",
   "random_2d":"<function random_2d at 0x10967a840>",
   "random_1d":"<function random_1d at 0x1096456a8>",
   "my_main":"<function my_main at 0x109645378>"
}

注意,粗体变量对应我们在calcmain3.py文件中的import语句所做的更改。

使用__import__语句

__import__语句是 Python 中的一个低级函数,它接受一个字符串作为输入并触发实际的导入操作。低级函数是 Python 核心语言的一部分,通常用于库开发或访问操作系统资源,并不常用于应用程序开发。我们可以使用这个关键字在我们的myrandom.py模块中导入random库,如下所示:

#import random
random = __import__('random')

myrandom.py中的其余代码可以原样使用,无需任何更改。

我们为了学术目的说明了使用__import__方法的一个简单案例,并将跳过那些对进一步探索感兴趣的人的详细内容。原因是__import__方法不建议用于用户应用程序;它设计得更多是为了解释器。

importlib.import_module语句是除了常规导入之外用于高级功能的语句。

使用 importlib.import_module 语句

我们可以使用importlib库导入任何模块。importlib库提供了一系列与以更灵活方式导入模块相关的函数,包括__import__。以下是一个如何在我们的myrandom.py模块中使用importlib导入random模块的简单示例:

import importlib
random = importlib.import_module('random')

myrandom.py中的其余代码可以原样使用,无需任何更改。

importlib模块最出名的是动态导入模块,在模块名称事先未知且需要在运行时导入模块的情况下非常有用。这是插件和扩展开发中的常见需求。

importlib模块中可用的常用函数如下:

  • __import__:这是__import__函数的实现,如前所述。

  • import_module:这个函数用于导入一个模块,最常用于动态加载模块。在这个方法中,你可以指定是否使用绝对路径或相对路径来导入模块。import_module 函数是 importlib.__import__ 的包装器。请注意,前一个函数返回的是由函数指定的包或模块(例如,packageA.module1),而后一个函数总是返回顶级包或模块(例如,packageA)。

  • importlib.util.find_spec:这是 find_loader 方法的替代方法,自 Python 3.4 版本以来已被弃用。这个方法可以用来验证模块是否存在且有效。

  • invalidate_caches:这个方法可以用来使存储在 sys.meta_path 中的查找器的内部缓存失效。内部缓存对于快速加载模块很有用,无需再次触发查找器方法。但是,如果我们正在动态导入一个模块,尤其是如果它在解释器开始执行之后创建,那么调用 invalidate_caches 方法是一种最佳实践。这个函数将清除所有模块或库从缓存,以确保请求的模块是通过 import 系统从系统路径加载的。

  • reload:正如其名所示,这个函数用于重新加载之前导入的模块。我们需要为这个函数提供模块对象作为输入参数。这意味着 import 函数必须成功执行。当模块源代码预期将被编辑或更改,并且你想要加载新版本而不重新启动程序时,这个函数非常有用。

绝对导入与相对导入

我们对如何使用 import 语句有了相当好的了解。现在是时候理解绝对相对导入,尤其是在我们导入自定义或项目特定模块时。为了说明这两个概念,让我们以一个具有不同包、子包和模块的项目为例,如下所示:

project
  ├── pkg1
  │   ├── module1.py
  │   └── module2.py (contains a function called func1 ())
  └── pkg2
      ├── __init__.py
      ├── module3.py
      └── sub_pkg1
          └── module6.py (contains a function called func2 ())
  ├── pkg3
  │   ├── module4.py
  │   ├── module5.py
      └── sub_pkg2
          └── module7.py

使用这种项目结构,我们将讨论如何使用绝对和相对导入。

绝对导入

我们可以使用从顶级包开始,向下钻取到子包和模块级别的绝对路径。这里展示了导入不同模块的一些示例:

from pkg1 import module1
from pkg1.module2 import func1
from pkg2 import module3
from pkg2.sub_pkg1.module6 import func2
from pkg3 import module4, module5
from pkg3.sub_pkg2 import module7

对于绝对导入语句,我们必须为每个包或文件提供一个详细的路径,从顶级包文件夹开始,类似于文件路径。

绝对导入推荐使用,因为它们易于阅读,也易于跟踪导入资源的确切位置。绝对导入受项目共享和当前 import 语句位置变化的影响最小。实际上,PEP 8 明确推荐使用绝对导入。

然而,有时绝对导入的语句相当长,这取决于项目文件夹结构的大小,这不利于维护。

相对导入

相对导入指定了相对于当前位置要导入的资源,这主要是使用 import 语句的 Python 代码文件当前位置。

对于前面讨论的项目示例,以下是相对导入的一些场景。等效的相对导入语句如下:

  • module1.py 中的 funct1

     from .) only because module2.py is in the same folder as module1.py.
    
  • module1.py 中的 module4

    from ..) because module4.py is in the sibling folder of module1.py.
    
  • module1.py 中的 Func2

    from ..) because the target module (module2.py) is inside a folder that is in the sibling folder of module1.py. We used one dot to access the sub_pkg_1 package and another dot to access module2.
    

相对导入的一个优点是它们简单,并且可以显著减少长的 import 语句。但是,当项目在团队和组织之间共享时,相对导入语句可能会变得混乱且难以维护。相对导入不易阅读和管理。

加载和初始化模块

每当 Python 解释器与 import 或等效语句交互时,它执行三个操作,这些操作将在下一节中描述。

加载模块

Python 解释器在 sys.path(将在 从任何位置访问包 部分讨论)上搜索指定的模块,并加载源代码。这已在 学习 import 的工作原理 部分中解释。

设置特殊变量

在这一步,Python 解释器定义了一些特殊变量,例如 __name__,它基本上定义了 Python 模块运行的命名空间。__name__ 变量是最重要的变量之一。

在我们的示例中,calcmain1.pymycalculator.pymyrandom.py 模块的情况下,每个模块的 __name__ 变量将被设置为以下内容:

表 2.1 – 不同模块的 __name__ 属性值

表 2.1 – 不同模块的 name 属性值

设置 __name__ 变量有两种情况,下面将进行描述。

情况 A – 模块作为主程序

如果你将你的模块作为主程序运行,无论 Python 文件或模块的名称是什么,__name__ 变量都将被设置为 __main__ 值。例如,当执行 calcmain1.py 时,解释器将硬编码的 __main__ 字符串分配给 __name__ 变量。如果我们以主程序的方式运行 myrandom.pymycalculator.py__name__ 变量将自动获取 __main__ 的值。

因此,我们在所有主脚本中添加了 if __name__ == '__main__' 行来检查这是否是主执行程序。

情况 B – 模块被另一个模块导入

在这种情况下,你的模块不是主程序,但它被另一个模块导入。在我们的示例中,myrandommycalculatorcalcmain1.py 中被导入。一旦 Python 解释器找到 myrandom.pymycalculator.py 文件,它将把 import 语句中的 myrandommycalculator 名称分配给每个模块的 __name__ 变量。这个分配是在执行这些模块内的代码之前完成的。这反映在 表 2.1 中。

一些其他值得注意的特殊变量如下:

  • __file__:此变量包含当前正在导入的模块的路径。

  • __doc__:此变量将输出在类或方法中添加的文档字符串。如在第第一章中讨论的,最佳 Python 开发生命周期,文档字符串是在类或方法定义后添加的注释行。

  • __package__:这用于指示模块是否为包。其值可以是包名、空字符串或none

  • __dict__:这将返回一个类实例的所有属性作为字典。

  • dir:这实际上是一个返回每个相关方法或属性列表的方法。

  • Localsglobals:这些也被用作显示局部和全局变量作为字典条目的方法。

执行代码

在设置特殊变量后,Python 解释器将逐行执行文件中的代码。重要的是要知道,除非被其他代码行调用,否则函数(以及类下的代码)不会执行。以下是当运行calcmain1.py时,从执行点对三个模块的快速分析:

  • mycalculator.py:在设置特殊变量后,在初始化时此模块没有代码要执行。

  • myrandom.py:在设置特殊变量和导入语句后,在初始化时此模块没有其他代码要执行。

  • calcmain1.py:在设置特殊变量和执行导入语句后,它将执行以下if语句:if __name__ == "__main__"。这将返回true,因为我们启动了calcmain1.py文件。在if语句内部,将调用my_main()函数,该函数实际上调用myrandom.pymycalculator.py模块的方法。

我们可以在任何模块中添加if __name__ == "__main__"语句,无论它是否是主程序。使用此方法的优势是模块既可以作为模块使用,也可以作为主程序使用。还有使用此方法的另一种应用,即在模块中添加单元测试。

标准模块

Python 自带超过 200 个标准模块的库。确切的数量因分发版而异。这些模块可以被导入到你的程序中。这些模块的列表非常广泛,但在此仅列举一些常用模块作为标准模块的示例:

  • math:此模块提供算术运算的数学函数。

  • random:此模块有助于使用不同类型的分布生成伪随机数。

  • statistics:此模块提供诸如meanmedianvariance之类的统计函数。

  • base64:此模块提供编码和解码数据的函数。

  • calendar:此模块提供与日历相关的函数,有助于基于日历的计算。

  • collections:此模块包含除通用内置容器(如dictlistset)之外的特殊容器数据类型。这些特殊数据类型包括dequeCounterChainMap

  • csv:此模块有助于从基于逗号的分隔文件中读取和写入。

  • datetime:此模块提供通用日期和时间函数。

  • decimal:此模块专门用于基于十进制的算术运算。

  • logging:此模块用于简化应用程序的日志记录。

  • osos.path:这些模块用于访问操作系统相关的功能。

  • socket:此模块提供基于套接字的网络通信的低级函数。

  • sys:此模块提供对 Python 解释器低级变量和函数的访问。

  • time:此模块提供时间相关的函数,如转换到不同的时间单位。

编写可重用模块

为了使模块可重用,它必须具有以下特征:

  • 独立功能

  • 通用功能

  • 传统的编码风格

  • 明确的文档

如果一个模块或包没有这些特征,那么在其他程序中重用它将非常困难,甚至不可能。我们将逐一讨论每个特征。

独立功能

模块中的函数应提供与其他模块以及任何局部或全局变量无关的功能。函数越独立,模块的可重用性就越高。如果它必须使用其他模块,那么它必须是最小的。

在我们的mycalculator.py示例中,这两个函数是完全独立的,可以被其他程序重用:

图 2.1 – mycalculator 模块的加法和减法功能

图 2.1 – mycalculator 模块的加法和减法功能

myrandom.py的情况下,我们使用random系统库来提供生成随机数的功能。这仍然是一个非常可重用的模块,因为random库是 Python 中的内置模块之一:

图 2.2 – myrandom 模块与 random 库的功能依赖关系

图 2.2 – myrandom 模块与 random 库的功能依赖关系

在我们必须在我们的模块中使用第三方库的情况下,如果目标环境尚未安装第三方库,那么在与其他人共享我们的模块时可能会遇到问题。

为了进一步阐述这个问题,我们将引入一个新的模块mypandas.py,它将利用著名pandas库的基本功能。为了简单起见,我们只向其中添加了一个函数,该函数根据作为函数输入变量的字典打印 DataFrame。

mypandas.py的代码片段如下:

#mypandas.py
import pandas
def print_dataframe(dict):
   """This function output a dictionary as a data frame """
   brics = pandas.DataFrame(dict)
   print(brics)

我们的 mypandas.py 模块将使用 pandas 库从字典中创建 dataframe 对象。这种依赖关系也在下一个块图中显示:

图 2.3 – mypandas 模块依赖于第三方 pandas 库

图 2.3 – mypandas 模块依赖于第三方 pandas 库

注意,pandas 库不是一个内置库或系统库。当我们试图与他人共享此模块而不定义对第三方库(在这种情况下为 pandas)的清晰依赖关系时,尝试使用此模块的程序将给出以下错误信息:

ImportError: No module named pandas'

这就是为什么使模块尽可能独立很重要。如果我们必须使用第三方库,我们需要定义清晰的依赖关系并使用适当的打包方法。这将在“共享包”部分进行讨论。

泛化功能

一个理想的可重用模块应该专注于解决一个通用问题,而不是一个非常具体的问题。例如,我们有一个将英寸转换为厘米的需求。我们可以轻松地编写一个函数,通过应用转换公式将英寸转换为厘米。那么,编写一个将英制系统中的任何值转换为公制系统中的值的函数呢?我们可以有一个函数来处理英寸到厘米、英尺到米或英里到千米的转换,或者为每种类型的转换编写单独的函数。那么,反向函数(厘米到英寸)呢?这可能现在不是必需的,但将来可能需要,或者由重新使用此模块的人需要。这种泛化将使模块的功能不仅全面,而且更易于重用,而无需扩展它。

为了说明泛化概念,我们将修改 myrandom 模块的设计,使其更加通用,从而更易于重用。在当前的设计中,我们为一位数和两位数定义了单独的函数。如果我们需要生成一个三位数的随机数或生成一个介于 20 到 30 之间的随机数怎么办?为了泛化需求,我们在同一模块中引入了一个新的函数 get_random,它接受用户输入随机数的下限和上限。这个新添加的函数是对我们已定义的两个随机函数的泛化。在这个模块中,通过这个新函数,可以删除两个现有的函数,或者为了方便使用,它们可以保留在模块中。请注意,这个新添加的函数也是由 random 库直接提供的;在我们的模块中提供此函数的原因纯粹是为了说明泛化函数(在这种情况下为 get_random)与特定函数(在这种情况下为 random_1drandom_2d)之间的区别。

myrandom.py 模块的更新版本(myrandomv2.py)如下:

# myrandomv2.py with default and custom random functions
import random
def random_1d():
   """This function get a random number between 0 and 9"""
   return random.randint(0,9)
def random_2d():
   """This function get a random number between 10 and 99"""
   return random.randint(10,99)
def get_random(lower, upper):
   """This function get a random number between lower and\
    upper"""
   return random.randint(lower,upper)

传统的编码风格

这主要关注我们如何编写函数名、变量名和模块名。Python 有一个编码系统和命名约定,这在本书的前一章中已经讨论过。遵循编码和命名约定非常重要,尤其是在构建可重用模块和软件包时。否则,我们将讨论这些模块作为可重用模块的坏例子。

为了说明这一点,我们将展示以下代码片段,其中函数和参数名使用了驼峰式命名法:

def addNumbers(numParam1, numParam2)
  #function code is omitted
Def featureCount(moduleName)
  #function code is omitted 

如果你来自 Java 背景,这种代码风格看起来不错。但在 Python 中,这被认为是不良实践。使用非 Python 风格的编码使得此类模块的重用非常困难。

这里是一个模块的代码片段示例,其中函数名采用了适当的编码风格:

def add_numbers(num_param1, num_param2)
  #function code is omitted
Def feature_count(module_name)
  #function code is omitted 

下一个截图展示了另一个良好的可重用编码风格的例子,该截图来自 PyCharm IDE 中的pandas库:

图 2.4 – PyCharm IDE 中的 pandas 库视图

图 2.4 – PyCharm IDE 中的 pandas 库视图

即使不阅读任何文档,函数和变量名也很容易理解。遵循标准编码风格可以使重用更方便。

明确的文档

明确且清晰的文档与遵循 Python 编码指南编写的通用和独立模块一样重要。没有清晰的文档,模块将不会增加开发者方便重用的兴趣。但作为程序员,我们更关注代码而不是文档。编写几行文档可以使我们 100 行代码更易于使用和维护。

我们将通过使用我们的mycalculator.py模块示例,从模块的角度提供几个良好的文档示例:

 """mycalculator.py 
 This module provides functions for add and subtract of two   numbers"""
def add(x,  y):
   """ This function adds two numbers. 
   usage: add (3, 4) """
   return x + y
def subtract(x, y):
   """ This function subtracts two numbers
   usage: subtract (17, 8) """
   return x - y

在 Python 中,重要的是要记住以下几点:

  • 我们可以使用三个引号字符来标记跨越 Python 源文件多行的字符串。

  • 三引号字符串用于模块的开头,然后这个字符串被用作整个模块的文档。

  • 如果任何函数以三引号字符串开头,那么这个字符串将用作该函数的文档。

作为一般结论,我们可以通过编写数百行代码来创建尽可能多的模块,但要创建可重用的模块,除了编写代码之外,还需要包括泛化、编码风格,最重要的是文档。

构建软件包

有许多技术和工具可用于创建和分发软件包。事实是,Python 在标准化打包过程方面并没有一个很好的历史。在 21 世纪的前十年中,已经启动了多个项目来简化这一过程,但并没有取得很大的成功。在过去的十年中,我们取得了一些成功,这要归功于Python 打包权威机构PyPA)的倡议。

在本节中,我们将介绍构建包的技术、访问程序中的包以及根据 PyPA 提供的指南发布和共享包的方法。

我们将从包名称开始,然后是初始化文件的使用,接着进入构建示例包。

命名

包名称应遵循与模块相同的命名规则,即小写且不带下划线。包类似于结构化模块。

包初始化文件

一个包可以有一个可选的源文件,名为__init__.py(或简单地称为init文件)。建议存在init文件(即使是空的)来标记文件夹为包。自 Python 3.3 或更高版本以来,使用init文件是可选的(PEP 420:隐式命名空间包)。使用此init文件可能有多个目的,并且总是有关于什么可以放入init文件以及什么不可以放入的争论。这里讨论了init文件的一些用途:

  • 空的__init__.py:这将迫使开发者使用显式导入并按他们喜欢的管理命名空间。正如预期的那样,开发者必须导入单独的模块,对于大型包来说可能会很繁琐。

  • __init__文件。

  • 从不同的模块创建init文件并管理它们在包命名空间下。这提供了额外的优势,即在底层模块的功能周围提供一个包装器。如果万一我们需要重构底层模块,我们有选项保持命名空间不变,特别是对于 API 消费者。这种方法的唯一缺点是,需要额外的努力来管理和维护这样的init文件。

有时,开发者会在init文件中添加代码,当从包中导入模块时执行。这类代码的一个例子是为远程系统(如数据库或远程 SSH 服务器)创建一个会话。

构建一个包

现在我们将讨论如何使用一个示例包来构建一个包。我们将使用以下模块和一个子包来构建masifutil包:

  • mycalculator.py模块:我们已经在导入模块部分构建了这个模块。

  • myrandom.py模块:这个模块也是为导入模块部分构建的。

  • advcalc子包:这将是一个子包,并包含一个模块(advcalculator.py)。我们将为这个子包定义一个init文件,但它将是空的。

advcalculator.py模块具有额外的函数,用于使用 10 和 2 的底数计算平方根和对数。此模块的源代码如下:

# advcalculator.py with sqrt, log and ln functions
import math
def sqrt(x):
   """This function takes square root of a number"""
   return math.sqrt(x)
def log(x):
   """This function returns log of base 10"""
   return math.log(x,10)
def ln(x):
   """This function returns log of base 2"""
   return math.log(x,2)

masifutil的文件结构以及init文件将看起来像这样:

![图 2.5 – 包含模块和子包的 masifutil 包的文件夹结构![图 2.5 – 包含模块和子包的 masifutil 包的文件夹结构图 2.5 – 包含模块和子包的 masifutil 包的文件夹结构在下一步中,我们将构建一个新的主脚本(pkgmain1.py)来消费包或masifutil子文件夹中的模块。在这个脚本中,我们将使用文件夹结构从主包和子包导入模块,然后使用模块函数计算两个随机数,这两个数的和与差,以及第一个随机数的平方根和对数值。pkgmain1.py的源代码如下:py# pkgmain0.py with direct import import masifutil.mycalculator as calcimport masifutil.myrandom as randimport masifutil.advcalc.advcalculator as acalcdef my_main():    """ This is a main function which generates two random\     numbers and then apply calculator functions on them """    x = rand.random_2d()    y = rand.random_1d()    sum = calc.add(x,y)    diff = calc.subtract(x,y)    sroot = acalc.sqrt(x)    log10x = acalc.log(x)    log2x = acalc.ln(x)    print("x = {}, y = {}".format(x, y))    print("sum is {}".format(sum))    print("diff is {}".format(diff))    print("square root is {}".format(sroot))    print("log base of 10 is {}".format(log10x))    print("log base of 2 is {}".format(log2x))""" This is executed only if the special variable '__name__' is set as main"""if __name__ == "__main__":    my_main()在这里,我们将使用包名和模块名来导入模块,这在需要导入子包时尤其繁琐。我们还可以使用以下语句,结果相同:py# mypkgmain1.py with from statementsfrom masifutil import mycalculator as calcfrom masifutil import myrandom as randfrom masifutil.advcalc import advcalculator as acalc#rest of the code is the same as in mypkgmain1.py如前所述,使用空的__init__.py文件是可选的。但在这个例子中,我们添加了它以供说明。接下来,我们将探讨如何在init文件中添加一些import语句。让我们从在init文件中导入模块开始。在这个顶级init文件中,我们将导入所有函数,如下所示:py#__init__ file for package 'masifutil'from .mycalculator import add, subtractfrom .myrandom import random_1d, random_2dfrom .advcalc.advcalculator import sqrt, log, ln注意模块名称前的.的使用。这对于 Python 来说,是严格使用相对导入所必需的。由于init文件中的这三行,新的主脚本将变得简单,下面的示例代码展示了这一点:py# pkgmain2.py with main functionimport masifutildef my_main():    """ This is a main function which generates two random\     numbers and then apply calculator functions on them """    x = masifutil.random_2d()    y = masifutil.random_1d()    sum = masifutil.add(x,y)    diff = masifutil.subtract(x,y)    sroot = masifutil.sqrt(x)    log10x = masifutil.log(x)    log2x = masifutil.ln(x)    print("x = {}, y = {}".format(x, y))    print("sum is {}".format(sum))    print("diff is {}".format(diff))    print("square root is {}".format(sroot))    print("log base of 10 is {}".format(log10x))    print("log base of 2 is {}".format(log2x))""" This is executed only if the special variable '__name__' is set as main"""if __name__ == "__main__":    my_main()两个主要模块和子包模块的功能在主包级别可用,开发者不需要知道包内模块的底层层次和结构。这是我们之前讨论的在使用init文件内的import语句时的便利性。我们通过将包源代码保存在主程序或脚本所在的同一文件夹中来构建包。这仅适用于在项目内共享模块。接下来,我们将讨论如何从其他项目和从任何程序从任何地方访问包。# 从任何位置访问包我们在上一小节中构建的包,只有当调用模块的程序与包位置处于同一级别时才能访问。这一要求对于代码重用和代码共享来说并不实用。在本节中,我们将讨论一些使包在任何程序和系统中的任何位置可用和可用的技术。### 追加 sys.path 这是一个设置sys.path动态的有用选项。请注意,sys.path是 Python 解释器在执行源程序中的每个import语句时搜索的目录列表。通过使用这种方法,我们将包含我们的包的目录或文件夹的路径追加到sys.path中。对于masifutil包,我们将构建一个新的程序,pkgmain3.py,它是pkgmain2.py(稍后更新)的副本,但被保存在我们的masifutil包所在文件夹之外。pkgmain3.py可以放在除mypackages文件夹之外的任何文件夹中。以下是包含新主脚本(pkgmain3.py)和masifutil包的文件夹结构,供参考:Figure 2.6 – Folder structure of the masifutil package and a new main script, pkgmain3.py

img/B17189_02_06.jpg

图 2.6 – masifutil 包的文件夹结构和新的主脚本,pkgmain3.py

当我们执行pkgmain3.py程序时,它返回一个错误:ModuleNotFoundError: No module named 'masifutil'。这是预期的,因为masifutil包的路径没有被添加到sys.path中。为了将包文件夹添加到sys.path,我们将更新主程序;让我们称它为pkgmain4.py,并添加附加的sys.path追加语句,如下所示:

# pkgmain4.py with sys.path append code
import sys
sys.path.append('/Users/muasif/Google Drive/PythonForGeeks/source_code/chapter2/mypackages')
import masifutil
def my_main():
    """ This is a main function which generates two random\
     numbers and then apply calculator functions on them """
    x = masifutil.random_2d()
    y = masifutil.random_1d()
    sum = masifutil.add(x,y)
    diff = masifutil.subtract(x,y)
    sroot = masifutil.sqrt(x)
    log10x = masifutil.log(x)
    log2x = masifutil.ln(x)
    print("x = {}, y = {}".format(x, y))
    print("sum is {}".format(sum))
    print("diff is {}".format(diff))
    print("square root is {}".format(sroot))
    print("log base of 10 is {}".format(log10x))
    print("log base of 2 is {}".format(log2x))
""" This is executed only if the special variable '__name__' is set as main"""
if __name__ == "__main__":
    my_main()

在添加追加sys.path的附加行之后,我们执行了主脚本而没有错误,并且输出了预期的控制台输出。这是因为我们的masifutil包现在位于 Python 解释器可以加载它的路径上,当我们在我们主脚本中导入它时。

除了追加sys.path之外,我们还可以使用 site 模块中的site.addsitedir函数。使用这种方法的优势仅在于这个函数也会在包含的文件夹中查找.pth 文件,这对于添加额外的文件夹,如子包,很有帮助。下面是一个带有addsitedir函数的示例主脚本(pktpamin5.py)片段:

# pkgmain5.py
import site
site.addsitedir('/Users/muasif/Google Drive/PythonForGeeks/source_code/chapter2/mypackages')
import masifutil
#rest of the code is the same as in pkymain4.py

注意,我们使用这种方法追加或添加的目录仅在程序执行期间可用。要永久设置sys.path(在会话或系统级别),我们将讨论的以下方法更有帮助。

使用 PYTHONPATH 环境变量

这是一个方便的方法将我们的包文件夹添加到sys.path,Python 解释器将使用它来搜索包和模块,如果它们不在内置库中。根据我们使用的操作系统,我们可以如下定义这个变量。

在 Windows 中,可以使用以下任一选项定义环境变量:

  • PYTHONPATH = "C:\pythonpath1;C:\pythonpath2"。这对于一个活动会话来说很好。

  • 图形用户界面:转到我的电脑 | 属性 | 高级系统设置 | 环境变量。这是一个永久设置。

在 Linux 和 macOS 中,可以使用export PYTHONPATH= /some/path/ ``来设置。如果使用 Bash 或等效终端设置,环境变量将仅对终端会话有效。要永久设置,建议将环境变量添加到配置文件末尾,例如~/bash_profile`。

如果我们不设置PYTHONPATH就执行pkgmain3.py程序,它将返回一个错误:ModuleNotFoundError: No module named 'masifutil'。这同样是可以预料的,因为masifutil包的路径没有被添加到PYTHONPATH中。

在下一步中,我们将添加包含masifutil的文件夹路径到PYTHONPATH变量中,并重新运行pkgmain3程序。这次,它没有错误并且输出了预期的控制台输出。

使用 Python 站点包下的.pth 文件

这是一种将软件包添加到 sys.path 的便捷方式。这是通过在 Python 站点包下定义一个 .pth 文件来实现的。该文件可以包含我们想要添加到 sys.path 的所有文件夹。

为了说明目的,我们在 venv/lib/Python3.7/site-packages 下创建了一个 my.pth 文件。正如我们在 图 2.7 中所看到的,我们添加了一个包含我们的 masifutil 软件包的文件夹。通过这个简单的 .pth 文件,我们的主要脚本 pkymain3.py 程序运行良好,没有任何错误,并产生预期的控制台输出:

图 2.7 – 包含 my.pth 文件的虚拟环境视图

图 2.7 – 包含 my.pth 文件的虚拟环境视图

我们讨论的访问自定义软件包的方法对于在相同系统上的任何程序中重用软件包和模块是有效的。在下一节中,我们将探讨如何与其他开发者和社区共享软件包。

分享软件包

为了在社区间分发 Python 软件包和项目,有许多工具可供选择。我们只关注根据 PyPA 提供的指南推荐使用的工具。

在本节中,我们将介绍安装和分发打包技术。我们将使用或至少在本节中作为参考的一些工具如下:

  • distutils:这是 Python 的一部分,具有基本功能。对于复杂和定制的软件包分发,它不容易扩展。

  • setuputils:这是一个第三方工具,是 distutils 的扩展,并建议用于构建软件包。

  • wheel:这是 Python 打包格式,与前辈相比,它使安装更快、更简单。

  • pip install <module name>

  • Python 包索引 (PyPI):这是一个 Python 编程语言的软件仓库。PyPI 用于查找和安装由 Python 社区开发和共享的软件。

  • Twine:这是一个用于将 Python 软件包发布到 PyPI 的实用工具。

在接下来的小节中,我们将根据 PyPA 提供的指南更新 masifutil 软件包,以包含额外的组件。这将随后通过 pip 在系统范围内安装更新的 masifutil 软件包。最后,我们将发布更新的 masifutil 软件包到 测试 PyPI 并从测试 PyPI 安装它。

根据 PyPA 指南构建软件包

PyPA 推荐使用示例项目来构建可重用的软件包,该项目可在 github.com/pypa/sampleproject 找到。以下是从 GitHub 位置获取的示例项目片段:

图 2.8 – PyPA 在 GitHub 上的示例项目视图

图 2.8 – 包含 my.pth 文件的虚拟环境视图

图 2.8 – PyPA 在 GitHub 上的示例项目视图

在我们使用它们更新 masifutil 软件包之前,我们将介绍一些关键文件和文件夹,这些文件和文件夹对于理解它们很重要:

  • setup.py:这是最重要的文件,它必须存在于项目或包的根目录中。它是一个用于构建和安装包的脚本。此文件包含一个全局的setup()函数。设置文件还提供了用于运行各种命令的命令行界面。

  • setup.cfg:这是一个ini文件,可以被setup.py用来定义默认值。

  • setup()参数:可以传递给设置函数的关键参数如下:

    a) 名称

    b) 版本

    c) 描述

    d) 网址

    e) 作者

    f) 许可证

  • README.rst/README.md:此文件(无论是 reStructuredText 还是 Markdown 格式)可以包含有关包或项目的信息。

  • license.txtlicense.txt文件应包含每个包的分发条款和条件详情。许可证文件很重要,尤其是在那些在没有适当许可证的情况下分发包是非法的国家。

  • MANIFEST.in:此文件可以用来指定要包含在包中的附加文件列表。此文件列表不包括源代码文件(这些文件会自动包含)。

  • <package>:这是包含其中所有模块和包的顶级包。虽然不是强制性的,但这是一个推荐的方法。

  • data:这是一个添加数据文件的地点。

  • tests:这是一个占位符,用于添加模块的单元测试。

作为下一步,我们将根据 PyPA 指南更新我们之前的masifutil包。以下是更新后的masifutilv2包的新文件夹和文件结构:

图 2.9 – 更新后的 masifutilv2 文件结构视图

图 2.9 – 更新后的 masifutilv2 文件结构视图

我们已经添加了datatests目录,但目前它们实际上是空的。我们将在后面的章节中评估单元测试来完成这个主题。

大多数附加文件的内容已在示例项目中涵盖,因此在此不讨论,除了setup.py文件。

我们根据我们的包项目更新了setup.py的基本参数。其余参数的详细信息可在 PyPA 提供的示例setup.py文件中找到。以下是我们的setup.py文件的一个片段:

from setuptools import setup
setup(
   name='masifutilv2',
   version='0.1.0',
   author='Muhammad Asif',
   author_email='ma@example.com',
   packages=['masifutil', 'masifutil/advcalc'],
   python_requires='>=3.5, <4',
   url='http://pypi.python.org/pypi/PackageName/',
   license='LICENSE.txt',
   description='A sample package for illustration purposes',
   long_description=open('README.md').read(),
   install_requires=[
   ],
)

使用这个setup.py文件,我们准备在本地以及远程共享我们的masifutilv2包,这将在下一节中讨论。

使用 pip 从本地源代码安装

一旦我们用新文件更新了包,我们就可以使用 pip 工具安装它。最简单的方法是执行以下命令,并指定masifutilv2文件夹的路径:

> pip install <path to masifutilv2>

以下是在不安装 wheel 包的情况下运行命令的控制台输出:

Processing ./masifutilv2
Using legacy 'setup.py install' for masifutilv2, since package 'wheel' is not installed.
Installing collected packages: masifutilv2
    Running setup.py install for masifutilv2 ... done
Successfully installed masifutilv2-0.1.0 

pip 工具成功安装了包,但由于wheel包尚未安装,所以使用了 egg 格式。以下是安装后的虚拟环境视图:

图 2.10 – 使用 pip 安装 masifutilv2 后的虚拟环境视图

图 2.10 – 使用 pip 安装 masifutilv2 后的虚拟环境视图

在虚拟环境中安装包后,我们使用 pkgmain3.py 程序进行了测试,它按预期工作。

小贴士

要卸载包,我们可以使用 pip uninstall masifutilv2

作为下一步,我们将安装 wheel 包,然后再次重新安装相同的包。以下是安装命令:

> pip install <path to masifutilv2>

控制台输出将类似于以下内容:

Processing ./masifutilv2
Building wheels for collected packages: masifutilv2
  Building wheel for masifutilv2 (setup.py) ... done
  Created wheel for masifutilv2: filename=masi futilv2-0.1.0-py3-none-any.whl size=3497 sha256=038712975b7d7eb1f3fefa799da9e294b34 e79caea24abb444dd81f4cc44b36e
  Stored in folder: /private/var/folders/xp/g88fvmgs0k90w0rc_qq4xkzxpsx11v/T/pip-ephem-wheel-cache-l2eyp_wq/wheels/de/14/12/71b4d696301fd1052adf287191fdd054cc17ef6c9b59066277
Successfully built masifutilv2
Installing collected packages: masifutilv2
Successfully installed masifutilv2-0.1.0

这次使用 wheel 成功安装了包,我们可以看到它如下出现在我们的虚拟环境中:

图 2.11 – 使用 wheel 和 pip 安装 masifutilv2 后的虚拟环境视图

图 2.11 – 使用 wheel 和 pip 安装 masifutilv2 后的虚拟环境视图

在本节中,我们已使用 pip 工具从本地源代码安装了一个包。在下一节中,我们将把包发布到集中式仓库(测试 PyPI)。

发布包到测试 PyPI

作为下一步,我们将把我们的样本包添加到 PyPI 仓库。在执行任何发布我们包的命令之前,我们需要在测试 PyPI 上创建一个账户。请注意,测试 PyPI 是一个专门用于测试的包索引的独立实例。除了测试 PyPI 的账户外,我们还需要向账户添加一个 API 令牌。我们将根据测试 PyPI 网站上的说明(test.pypi.org/)为您留下创建账户和添加 API 令牌的详细信息。

要将包推送到测试 PyPI,我们需要 Twine 工具。我们假设 Twine 是使用 pip 工具安装的。要上传 masifutilv2 包,我们将执行以下步骤:

  1. 使用以下命令创建一个分发。此 sdist 工具将在 dist 文件夹下创建一个 TAR ZIP 文件:

    > python setup.py sdist
    
  2. 将分发文件上传到测试 PyPI。当提示输入用户名和密码时,请使用 __token__ 作为用户名,API 令牌作为密码:

    > twine upload --repository testpypi dist/masifutilv2-0.1.0.tar.gz 
    

    此命令将包的 TAR ZIP 文件推送到测试 PyPI 仓库,控制台输出将类似于以下内容:

    Uploading distributions to https://test.pypi.org/legacy/
    Enter your username: __token__
    Enter your password: 
    Uploading masifutilv2-0.1.0.tar.gz
    100%|█████████████████████| 
    5.15k/5.15k [00:02<00:00, 2.21kB/s]
    

在成功上传后,我们可以在 test.pypi.org/project/masifutilv2/0.1.0/ 查看上传的文件。

从 PyPI 安装包

从测试 PyPI 安装包与从常规仓库安装相同,只是我们需要通过使用 index-url 参数提供仓库 URL。命令和控制台输出将类似于以下内容:

> pip install --index-url https://test.pypi.org/simple/ --no-deps masifutilv2

此命令将显示类似于以下内容的控制台输出:

Looking in indexes: https://test.pypi.org/simple/
Collecting masifutilv2
  Downloading https://test-files.pythonhosted.org/  packages/b7/e9/7afe390b4ec1e5842e8e62a6084505cbc6b9   f6adf0e37ac695cd23156844/masifutilv2-0.1.0.tar.gz (2.3 kB)
Building wheels for collected packages: masifutilv2
  Building wheel for masifutilv2 (setup.py) ... done
  Created wheel for masifutilv2: filename=masifutilv2-  0.1.0-py3-none-any.whl size=3497   sha256=a3db8f04b118e16ae291bad9642483874   f5c9f447dbee57c0961b5f8fbf99501
  Stored in folder: /Users/muasif/Library/Caches/pip/  wheels/1c/47/29/95b9edfe28f02a605757c1   f1735660a6f79807ece430f5b836
Successfully built masifutilv2
Installing collected packages: masifutilv2
Successfully installed masifutilv2-0.1.0

正如我们在控制台输出中看到的那样,pip 正在 Test PyPI 中搜索模块。一旦它找到了名为 masifutilv2 的包,它就开始下载并在虚拟环境中安装它。

简而言之,我们观察到,一旦我们使用推荐的格式和风格创建了一个包,那么发布和访问包就只是使用 Python 工具和遵循标准步骤的问题。

摘要

在本章中,我们介绍了 Python 中的模块和包的概念。我们讨论了如何构建可重用的模块以及它们如何被其他模块和程序导入。我们还介绍了模块在被其他程序包含(通过导入过程)时的加载和初始化。在本章的后半部分,我们讨论了构建简单和高级包。我们还提供了大量的代码示例来访问包,以及安装和发布包以提高重用性。

在阅读完本章后,你已经学会了如何构建模块和包,以及如何共享和发布包(和模块)。如果你在一个组织内作为团队的一员工作,或者你正在为更大的社区构建 Python 库,这些技能都是非常重要的。

在下一章中,我们将讨论使用 Python 面向对象编程进行模块化的高级层次。这包括封装、继承、多态和抽象,这些是在现实世界中构建和管理复杂项目的关键工具。

问题

  1. 模块和包之间有什么区别?

  2. Python 中的绝对导入和相对导入是什么?

  3. PyPA 是什么?

  4. 测试 PyPI 是什么,为什么我们需要它?

  5. init 文件是构建包的必要条件吗?

进一步阅读

答案

  1. 模块旨在将函数、变量和类组织到单独的 Python 代码文件中。Python 包就像一个文件夹,用于组织多个模块或子包。

  2. 绝对导入需要从顶级开始使用包的绝对路径,而相对导入是基于包的相对路径,该路径根据程序当前的位置来确定,其中要使用 import 语句。

  3. Python 包管理权威机构PyPA)是一个维护 Python 打包中使用的核心软件项目的工作组。

  4. 测试 PyPI 是用于测试目的的 Python 编程语言的软件仓库。

  5. 从 Python 3.3 版本开始,init 文件是可选的。

第三章:第三章:高级面向对象 Python 编程

Python 可以用作类似于 C 的声明性模块化编程语言,也可以用于使用 Java 等编程语言进行命令式编程或完整的面向对象编程(OOP)。声明性编程是一种范式,其中我们关注我们想要实现的内容,而命令式编程是我们描述实现我们想要的内容的确切步骤。Python 适合这两种编程范式。OOP 是一种命令式编程形式,其中我们将现实世界对象的属性和行为捆绑到程序中。此外,OOP 还解决了不同类型现实世界对象之间的关系。

在本章中,我们将探讨如何使用 Python 实现面向对象编程(OOP)的高级概念。我们假设你已经熟悉类、对象和实例等一般概念,并且对对象之间的继承有基本了解。

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

  • 介绍类和对象

  • 理解 OOP 原则

  • 使用组合作为替代设计方法

  • 在 Python 中介绍鸭子类型

  • 学习何时在 Python 中不使用 OOP

技术要求

这些是本章的技术要求:

介绍类和对象

类是一个定义如何定义某物的蓝图。它实际上不包含任何数据——它是一个模板,用于根据模板或蓝图中的规范创建实例。

类的对象是一个从类构建的实例,这也是为什么它也被称为类的实例。在本章和本书的其余部分,我们将同义地使用对象实例。在 OOP 中,对象有时可以用物理对象如桌子、椅子或书籍来表示。在大多数情况下,软件程序中的对象代表抽象实体,这些实体可能不是物理的,如账户、名称、地址和支付。

为了让我们对类和对象的基本概念有更清晰的认识,我们将通过代码示例来定义这些术语。

区分类属性和实例属性

类属性是在类定义的一部分中定义的,并且它们的值意味着在从该类创建的所有实例中都是相同的。可以通过类名或实例名来访问类属性,尽管建议使用类名来访问这些属性(用于读取或更新)。对象的状态或数据由实例属性提供。

在 Python 中定义一个类很简单,只需使用class关键字。如第一章中讨论的,最佳 Python 开发生命周期,类的名称应该是驼峰式。以下代码片段创建了一个Car类:

#carexample1.py
class Car:
    pass

这个类没有任何属性和方法。它是一个空类,你可能会认为这个类没有用,直到我们向它添加更多组件。但并非如此!在 Python 中,你可以在运行时动态地添加属性,而无需在类中定义它们。以下是一个有效的代码示例,展示了我们如何在运行时向类实例添加属性:

#carexample1.py
class Car:
    pass
if __name__ == "__main__":
    car = Car ()
    car.color = "blue"
    car.miles = 1000
    print (car.color)
    print (car.miles)

在这个扩展示例中,我们创建了一个Car类的实例(car),然后向这个实例添加了两个属性:colormiles。请注意,使用这种方法添加的属性是实例属性。

接下来,我们将使用构造方法(__init__)添加类属性和实例属性,该方法在对象创建时加载。以下是一个包含两个实例属性(colormiles)和init方法的代码片段:

#carexample2.py
class Car:
    c_mileage_units = "Mi"
    def __init__(self, color, miles):
        self.i_color = color
        self.i_mileage = miles
if __name__ == "__main__":
    car1 = Car ("blue", 1000)
    print (car.i_color)
    print (car.i_mileage)
    print (car.c_mileage_units)
    print (Car.c_mileage_units)

在这个程序中,我们做了以下操作:

  1. 我们创建了一个具有c_mileage_units类属性和两个实例变量i_colori_mileageCar类。

  2. 我们创建了一个Car类的实例(car)。

  3. 我们使用car实例变量打印出了实例属性。

  4. 我们使用car实例变量以及Car类名打印出了类属性。两种情况下的控制台输出是相同的。

    重要提示

    self是对正在创建的实例的引用。在 Python 中,self的使用很常见,用于在实例方法中访问实例属性和方法,包括init方法。self不是一个关键字,使用self这个词不是强制的。它可以是指thisblah等任何名称,但必须作为实例方法的第一个参数,但使用self作为参数名称的约定非常强烈。

我们可以使用实例变量或类名来更新类属性,但结果可能不同。当我们使用类名更新类属性时,它将更新该类的所有实例。但如果我们使用实例变量更新类属性,它将只更新那个特定的实例。以下代码片段展示了这一点,它使用了Car类:

#carexample3.py
#class definition of Class Car is same as in carexample2.py
if __name__ == "__main__":
    car1 = Car ("blue", 1000)
    car2 = Car("red", 2000)
    print("using car1: " + car1.c_mileage_units)
    print("using car2: " + car2.c_mileage_units)
    print("using Class: " + Car.c_mileage_units)
    car1.c_mileage_units = "km"
    print("using car1: " + car1.c_mileage_units)
    print("using car2: " + car2.c_mileage_units)
    print("using Class: " + Car.c_mileage_units)
    Car.c_mileage_units = "NP"
    print("using car1: " + car1.c_mileage_units)
    print("using car2: " + car2.c_mileage_units)
    print("using Class: " + Car.c_mileage_units)

这个程序的输出可以通过以下方式进行分析:

  1. 第一组print语句将输出类属性的默认值,即Mi

  2. 在执行了car1.c_mileage_units = "km"语句之后,类属性值对于car2实例和类级别的属性将是相同的(Mi)。

  3. 在执行了Car.c_mileage_units = "NP"语句之后,car2和类级别的属性值将变为NP,但对于car1来说,由于我们明确设置了它,所以它将保持不变(km)。

    重要提示

    属性名以ci开头,分别表示它们是类变量和实例变量,而不是常规的局部或全局变量。非公共实例属性的名字必须以单个或双下划线开头,以使它们成为受保护的或私有的。这一点将在本章后面讨论。

在类中使用构造函数和析构函数

与任何其他面向对象编程语言一样,Python 也有构造函数和析构函数,但命名约定不同。在类中拥有构造函数的目的是在创建类的实例时初始化或分配类或实例级别的属性(主要是实例属性)。在 Python 中,__init__方法被称为构造函数,并且总是在创建新实例时执行。Python 支持三种类型的构造函数,如下所示:

  • 如果在类中忘记声明__init__方法,那么该类将使用一个默认的空构造函数。构造函数除了初始化类的实例之外,不做任何事情。

  • Name: 类:

    class Name:
        #non-parameterized constructor
        Name class
    
  • Name类将更新为带参数的构造函数,如下所示:

    class Name:   
        #parameterized constructor
        def __init__(self, first, last):
            self.i_first = first    
            self.i_last = last
    

析构函数与构造函数相反——它们在实例被删除或销毁时执行。在 Python 中,析构函数几乎不使用,因为 Python 有一个垃圾回收器,它会处理那些不再被任何其他实例或程序引用的实例的删除。如果我们需要在析构函数方法中添加逻辑,我们可以通过使用特殊的__del__方法来实现。当实例的所有引用都被删除时,它会自动调用。下面是如何在 Python 中定义析构函数的语法:

def __del__(self):
print("Object is deleted.")

区分类方法和实例方法

在 Python 中,我们可以在类中定义三种类型的方法,下面将进行描述:

  • self(实例本身)并可以读取和更新实例的状态。__init__方法,即构造函数方法,是实例方法的例子。

  • @classmethod装饰器。这些方法不需要类实例来执行。对于这个方法,类引用(cls是惯例)将自动作为第一个参数发送。

  • @staticmethod装饰器。它们没有访问clsself对象的能力。静态方法类似于我们定义在模块中的实用函数,它们根据参数的值提供输出——例如,如果我们需要评估某些输入数据或解析数据以进行处理,我们可以编写静态方法来实现这些目标。静态方法的工作方式与我们在模块中定义的常规函数类似,但它们在类的命名空间上下文中可用。

为了说明这些方法如何在 Python 中定义并使用,我们创建了一个简单的程序,下面将展示:

#methodsexample1.py
class Car:
    c_mileage_units = "Mi"
    def __init__(self, color, miles):
        self.i_color = color
        self.i_mileage = miles
    def print_color (self):
        print (f"Color of the car is {self.i_color}")
    @classmethod
    def print_units(cls):
        print (f"mileage unit are {cls.c_mileage_unit}")
        print(f"class name is {cls.__name__}")
    @staticmethod
    def print_hello():
        print ("Hello from a static method")
if __name__ == "__main__":
    car = Car ("blue", 1000)
    car.print_color()
    car.print_units()
    car.print_hello()
    Car.print_color(car);
    Car.print_units();
    Car.print_hello()

在这个程序中,我们做了以下几件事情:

  1. 我们创建了一个Car类,其中包含类属性(c_mileage_units)、类方法(print_units)、静态方法(print_hello)、实例属性(i_colori_mileage)、实例方法(print_color)和构造方法(__init__)

  2. 我们使用构造函数创建了Car类的实例,将其命名为car

  3. 使用实例变量(在这个例子中是car),我们调用了实例方法、类方法和静态方法。

  4. 使用类名(在这个例子中是Car),我们再次触发了实例方法、类方法和静态方法。请注意,我们可以使用类名来触发实例方法,但我们需要将实例变量作为第一个参数传递(这也解释了为什么每个实例方法都需要self参数)。

下面的程序控制台输出如下,仅供参考:

Color of the car is blue
mileage unit are Mi
class name is Car
Hello from a static method
Color of the car is blue
mileage unit are Mi
class name is Car
Hello from a static method

特殊方法

当我们在 Python 中定义一个类并尝试使用print语句打印其实例时,我们将得到一个包含类名和对象实例引用的字符串,即对象的内存地址。实例或对象没有默认的to string功能实现。下面展示了这种行为的一个代码片段:

#carexampl4.py
class Car:
    def __init__(self, color, miles):
        self.i_color = color
        self.i_mileage = miles
if __name__ == "__main__":
    car = Car ("blue", 1000)
    print (car)

我们将得到类似于以下内容的控制台输出,这不是print语句预期的结果:

<__main__.Car object at 0x100caae80>

要从print语句中获得有意义的内容,我们需要实现一个特殊的__str__方法,该方法将返回包含实例信息的字符串,并且可以根据需要自定义。下面是一个代码片段,展示了包含__str__方法的carexample4.py文件:

#carexample4.py
class Car:
    c_mileage_units = "Mi"
    def __init__(self, color, miles):
        self.i_color = color
        self.i_mileage = miles
    def __str__(self):
        return f"car with color {self.i_color} and \
         mileage {self.i_mileage}"
if __name__ == "__main__":
    car = Car ("blue", 1000)
    print (car)

下面展示了print语句的控制台输出:

car with color blue and mileage 1000

通过适当的__str__实现,我们可以使用print语句而不需要实现特殊的函数,如to_string()。这是 Python 控制字符串转换的 Pythonic 方式。出于类似原因,另一个流行的方法是__repr__,它被 Python 解释器用于检查对象。__repr__方法主要用于调试目的。

这些方法(以及一些其他方法)被称为特殊方法或双下划线方法,因为它们总是以双下划线开始和结束。普通方法不应使用此约定。在某些文献中,这些方法也被称为魔法方法,但这不是官方术语。类实现中有几十种特殊方法可供使用。官方 Python 3 文档中提供了特殊方法的完整列表,网址为 https://docs.python.org/3/reference/datamodel.html#specialnames。

在本节中,我们通过代码示例回顾了类和对象。在下一节中,我们将研究 Python 中可用的不同面向对象原则。

理解面向对象原则

面向对象编程(OOP)是将属性和行为捆绑到单个实体中的方法,我们称之为对象。为了使这种捆绑更高效和模块化,Python 中有几个原则可用,如下所述:

  • 数据封装

  • 继承

  • 多态

  • 抽象

在接下来的小节中,我们将详细研究这些原则。

数据封装

封装是面向对象编程中的基本概念,有时也被称为抽象。但在现实中,封装不仅仅是抽象。在面向对象编程中,将数据及其相关动作捆绑到单个单元中称为封装。封装实际上不仅仅是捆绑数据和相关的动作。我们在这里可以列举封装的三个主要目标,如下所示:

  • 将数据和相关的动作封装在一个单元中。

  • 隐藏对象的内部结构和实现细节。

  • 限制对对象某些组件(属性或方法)的访问。

封装简化了对象的使用,无需了解其内部实现的细节,并且还有助于控制对象状态更新的更新。

在接下来的小节中,我们将详细讨论这些目标。

封装数据和动作

为了在一个初始化中包含数据和动作,我们在类中定义属性和方法。Python 中的类可以有以下类型的元素:

  • 构造函数和析构函数

  • 类方法和属性

  • 实例方法和属性

  • 嵌套

我们已经在上一节中讨论了这些类元素,除了嵌套或内部类。我们已经提供了 Python 代码示例来展示构造函数和析构函数的实现。我们已经在上一节中通过实例属性封装了数据在我们的实例或对象中。我们还讨论了类方法、静态方法和类属性,并提供了代码示例。

为了完成这个主题,我们将使用嵌套类的 Python 代码片段进行讨论。让我们以我们的Car类及其内部的Engine内部类为例。每辆车都需要一个引擎,因此将其作为嵌套或内部类是有意义的:

#carwithinnerexample1.py
class Car:
    """outer class"""
    c_mileage_units = "Mi"
    def __init__(self, color, miles, eng_size):
        self.i_color = color
        self.i_mileage = miles
        self.i_engine = self.Engine(eng_size)
    def __str__(self):
        return f"car with color {self.i_color}, mileage \
        {self.i_mileage} and engine of {self.i_engine}"
    class Engine:
        """inner class"""
        def __init__(self, size):
            self.i_size = size
        def __str__(self):
            return self.i_size
if __name__ == "__main__":
    car = Car ("blue", 1000, "2.5L")
    print(car)
    print(car.i_engine.i_size)

在这个例子中,我们在常规的Car类内部定义了一个Engine内部类。Engine类只有一个属性—i_size,构造方法(__init__)和__str__方法。与之前的示例相比,我们对Car类进行了以下更新:

  • __init__方法包括一个新的引擎尺寸属性,并添加了一行来创建与Car实例关联的Engine实例的新实例。

  • Car类的__str__方法包括i_size内部类属性。

主程序在Car实例上使用print语句,并且还有一行用于打印Engine类的i_size属性值。该程序的控制台输出将类似于以下内容:

car with color blue, mileage 1000 and engine of 2.5L
2.5L

主程序的控制台输出显示,我们可以在类实现内部访问内部类,并且我们可以从外部访问内部类的属性。

在下一小节中,我们将讨论如何隐藏一些属性和方法,以便它们在类外部不可访问或不可见。

隐藏信息

在我们之前的代码示例中,我们已经看到我们可以无限制地访问所有类级别以及实例级别的属性。这种做法导致我们采用了扁平化设计,类将简单地成为变量和方法的包装器。更好的面向对象设计方法是将一些实例属性隐藏起来,只让必要的属性对外部世界可见。为了讨论在 Python 中如何实现这一点,我们引入了两个术语:私有保护

私有变量和方法

可以通过在变量名前使用双下划线作为前缀来定义一个私有变量或属性。在 Python 中,没有像其他编程语言中那样的private关键字。类和实例变量都可以标记为私有。

可以通过在方法名前使用双下划线来定义一个私有方法。私有方法只能在类内部调用,且在类外部不可用。

每当我们定义一个属性或方法为私有时,Python 解释器不允许在类定义外部访问这样的属性或方法。这种限制也适用于子类;因此,只有类内部的代码可以访问这些属性和方法。

受保护的变量和方法

i_color属性从公共属性更改为保护属性,我们只需将其名称更改为_i_color。Python 解释器不会在类或子类内部强制执行这种保护元素的用法。这更多的是为了遵守命名约定,并按照保护变量和方法定义使用或访问属性或方法。

通过使用私有和保护变量和方法,我们可以隐藏对象实现的一些细节。这有助于我们在大型类内部拥有紧密和干净的源代码,而不将一切暴露给外部世界。隐藏属性的另一个原因是为了控制它们可以如何被访问或更新。这是下一小节的主题。为了总结本节,我们将讨论一个带有私有和保护变量以及私有方法的Car类的更新版本,如下所示:

#carexample5.py
class Car:
    c_mileage_units = "Mi"
    __max_speed = 200
    def __init__(self, color, miles, model):
        self.i_color = color
        self.i_mileage = miles
        self.__no_doors = 4
        self._model = model
    def __str__(self):
        return f"car with color {self.i_color}, mileage           {self.i_mileage}, model {self._model} and doors             {self.__doors()}"
    def __doors(self):
        return self.__no_doors
if __name__ == "__main__":
    car = Car ("blue", 1000, "Camry")
    print (car)

在这个更新的Car类中,我们根据之前的示例更新或添加了以下内容:

  • 一个具有默认值的私有类变量__max_speed

  • __init__构造方法内部有一个默认值的私有实例变量__no_doors

  • 仅用于说明目的的受保护实例变量_model

  • 一个用于获取门数的私有实例方法__doors()

  • __str__ 方法已更新,通过使用 __doors() 私有方法来获取门

这个程序的控制台输出按预期工作,但如果尝试从主程序访问任何私有方法或私有变量,则不可用,Python 解释器将抛出错误。这是按照设计,因为这些私有变量和私有方法的目的仅限于在类内部使用。

重要提示

Python 并没有真正使变量和方法成为私有的,但它假装使它们成为私有的。Python 实际上通过将类名与变量名混合来打乱变量名,这样它们就不容易在包含它们的类外部可见。

对于 Car 类的示例,我们可以访问私有变量和私有方法。Python 通过使用一个以单下划线开头,后跟类名,然后是私有属性名的不同属性名,在类定义之外提供对这些属性和方法访问。同样,我们也可以访问私有方法。

以下代码行是有效的,但不推荐,并且违反了私有和受保护的定义:

print (Car._Car__max_speed)    
print (car._Car__doors())
print (car._model)         

如我们所见,_Car 被附加到实际的私有变量名之前。这样做是为了最小化与内部类中变量的冲突。

保护数据

在我们之前的代码示例中,我们看到了我们可以无限制地访问实例属性。我们还实现了实例方法,对这些方法的使用没有限制。我们模拟定义它们为私有或受保护的,这样可以隐藏数据和行为对外部世界。

但在现实世界的问题中,我们需要以可控和易于维护的方式提供对变量的访问。这在许多面向对象的语言中通过 访问修饰符(如获取器和设置器)来实现,这些将在下面定义:

  • 获取器:这些是用于从类或其实例访问私有属性的方法

  • 设置器:这些是用于设置类或其实例的私有属性的方法。

获取器和设置器方法也可以用来实现访问或设置属性的额外逻辑,并且在一个地方维护这样的额外逻辑是方便的。实现获取器和设置器方法有两种方式:一种 传统方式 和一种 装饰性方式

使用传统获取器和设置器

传统上,我们使用 getset 前缀编写实例方法,后跟下划线和变量名。我们可以将我们的 Car 类转换为使用实例属性的获取器和设置器方法,如下所示:

#carexample6.py
class Car:
    __mileage_units = "Mi"
    def __init__(self, col, mil):
        self.__color = col
        self.__mileage = mil
    def __str__(self):
        return f"car with color {self.get_color()} and \
         mileage {self.get_mileage()}"
    def get_color(self):
        return self.__color
    def get_mileage(self):
        return self.__mileage
    def set_mileage (self, new_mil):
            self.__mileage = new_mil
if __name__ == "__main__":
    car = Car ("blue", 1000)
    print (car)
    print (car.get_color())
    print(car.get_mileage())
    car.set_mileage(2000)
    print (car.get_color())
    print(car.get_mileage())

在这个更新的 Car 类中,我们添加了以下内容:

  • colormileage 实例属性被添加为私有变量。

  • colormileage 实例属性的获取器方法。

  • 仅针对mileage属性设置 setter 方法,因为color属性通常在对象创建时设置后不会改变。

  • 在主程序中,我们使用 getter 方法获取新创建的类的实例数据。接下来,我们使用 setter 方法更新里程数,然后再次获取colormileage属性的数据。

在这个示例中,每个语句的控制台输出都是微不足道的,符合预期。正如提到的,我们没有为每个属性定义 setter,而只为那些有意义的属性和设计要求定义了 setter。在面向对象编程(OOP)中,使用 getter 和 setter 是一种最佳实践,但在 Python 中并不十分流行。Python 开发者的文化(也称为 Pythonic 方式)仍然是直接访问属性。

使用属性装饰器

使用装饰器来定义 getter 和 setter 是一种现代方法,有助于实现 Python 编程方式。

如果你喜欢使用装饰器,那么 Python 中有一个@property装饰器,可以使代码更简洁、更清晰。传统的带有 getter 和 setter 的Car类通过装饰器进行了更新,以下是一个代码片段,展示了这一点:

carexample7.py
class Car:
    __mileage_units = "Mi"
    def __init__(self, col, mil):
        self.__color = col
        self.__mileage = mil
    def __str__(self):
        return f"car with color {self.color} and mileage \
         {self.mileage}"
    @property
    def color(self):
        return self.__color
    @property
    def mileage(self):
        return self.__mileage
    @mileage.setter
    def mileage (self, new_mil):
            self.__mileage = new_mil
if __name__ == "__main__":
    car = Car ("blue", 1000)
    print (car)
    print (car.color)
    print(car.mileage)
    car.mileage = 2000
    print (car.color)
    print(car.mileage)

在这个更新的类定义中,我们更新或添加了以下内容:

  • 实例属性作为私有变量

  • 使用属性名作为方法名和@property来为colormileage设置 getter 方法

  • 使用@mileage.setter装饰器为mileage设置 setter 方法,使方法名与属性名相同

在主脚本中,我们通过使用实例名称后跟一个点和属性名称(Pythonic 方式)来访问颜色和里程数属性。这使得代码语法简洁易读。装饰器的使用也使得方法名称更简单。

总结来说,我们讨论了 Python 中封装的所有方面,包括使用类来捆绑数据和动作,隐藏类外部世界中的不必要信息,以及如何使用 Python 的 getter、setter 和属性特性来保护类中的数据。在下一节中,我们将讨论 Python 中继承的实现方式。

使用继承扩展类

面向对象编程(OOP)中的继承概念与现实生活中继承的概念相似,即子女在拥有自己特征的基础上,从父母那里继承了一些特征。

同样,一个类可以继承另一个类的元素。这些元素包括属性和方法。我们从其中继承另一个类的类通常被称为父类、超类基类。从另一个类继承的类被称为派生类子类子类。以下截图显示了父类和子类之间简单的关系:

图 3.1 – 父类和子类关系

图 3.1 – 父类和子类关系

在 Python 中,当一个类从另一个类继承时,它通常会继承构成父类的所有元素,但可以通过使用命名约定(如双下划线)和访问修饰符来控制这一点。

继承可以分为两种类型:简单多重。我们将在下一节中讨论这些内容。

简单继承

在简单或基本继承中,一个类是从单个父类派生出来的。这是面向对象编程中常用的一种继承形式,更接近人类的家谱。使用简单继承的父类和子类的语法如下所示:

class BaseClass:
    <attributes and methods of the base class >
class ChildClass (BaseClass):
    <attributes and methods of the child class >

对于这种简单继承,我们将修改我们的Car类示例,使其从Vehicle父类派生。我们还将添加一个Truck子类来阐述继承的概念。以下是修改后的代码:

#inheritance1.py
class Vehicle:
    def __init__(self, color):
        self.i_color = color
    def print_vehicle_info(self):
        print(f"This is vehicle and I know my color is \
         {self.i_color}")
class Car (Vehicle):
    def __init__(self, color, seats):
        self.i_color = color
        self.i_seats = seats
    def print_me(self):
        print( f"Car with color {self.i_color} and no of \
         seats {self.i_seats}")
class Truck (Vehicle):
    def __init__(self, color, capacity):
        self.i_color = color
        self.i_capacity = capacity
    def print_me(self):
        print( f"Truck with color {self.i_color} and \
         loading capacity {self.i_capacity} tons")
if __name__ == "__main__":
    car = Car ("blue", 5)
    car.print_vehicle_info()
    car.print_me()
    truck = Truck("white", 1000)
    truck.print_vehicle_info()
    truck.print_me()

在这个例子中,我们创建了一个具有一个i_color属性和一个print_vehicle_info方法的Vehicle父类。这两个元素都是继承的候选者。接下来,我们创建了两个子类,CarTruck。每个子类都有一个额外的属性(i_seatsi_capacity)和一个额外的属性(print_me)。在每个子类的print_me方法中,我们访问父类实例属性以及子类实例属性。

这种设计是有意为之,为了阐述从父类继承一些元素并在子类中添加一些元素的想法。在这个例子中,使用两个子类来演示继承在可重用性方面的作用。

在我们的主程序中,我们创建了CarTruck实例,并尝试访问父类方法以及实例方法。该程序的控制台输出符合预期,如下所示:

This is vehicle and I know my color is blue
Car with color blue and no of seats 5
This is vehicle and I know my color is white
Truck with color white and loading capacity 1000 tons 

多重继承

在多重继承中,一个子类可以从多个父类派生。多重继承的概念适用于高级面向对象设计,其中对象与多个对象有关联,但当我们从多个类继承时,我们必须小心,尤其是如果这些类是从一个共同的超类继承的。这可能导致诸如菱形问题等问题。菱形问题是在我们通过从两个类YZ继承来创建一个X类的情况下出现的,而YZ类又是从共同的类A继承的。X类将对其从类YZ继承的A类的公共代码产生歧义。由于多重继承可能带来的问题,我们不鼓励使用多重继承。

为了说明这个概念,我们将修改我们的VehicleCar类,并添加一个Engine类作为其中一个父类。以下是一个包含类多重继承的完整代码片段:

 #inheritance2.py
class Vehicle:
    def __init__(self, color):
        self.i_color = color
    def print_vehicle_info(self):
        print( f"This is vehicle and I know my color is \
         {self.i_color}")
class Engine:
    def __init__(self, size):
        self.i_size = size
    def print_engine_info(self):
        print(f"This is Engine and I know my size is \
         {self.i_size}")
class Car (Vehicle, Engine):
    def __init__(self, color, size, seat):
        self.i_color = color
        self.i_size = size
        self.i_seat = seat
    def print_car_info(self):
        print(f"This car of color {self.i_color} with \
         seats {self.i_seat} with engine of size \
         {self.i_size}")
if __name__ == "__main__":
    car = Car ("blue", "2.5L", 5 )
    car.print_vehicle_info()
    car.print_engine_info()
    car.print_car_info()

在这个多重继承的例子中,我们创建了两个父类作为父类:VehicleEngineVehicle父类与前面的例子相同。Engine类有一个属性(i_size)和一个方法(print_engine_info)。Car类从VehicleEngine继承而来,并添加了一个额外的属性(i_seats)和一个额外的方法(print_car_info)。在实例方法中,我们可以访问父类的实例属性。

在主程序中,我们创建了Car类的一个实例。使用这个实例,我们可以访问父类以及子类的实例方法。以下是主程序的控制台输出,如图所示,符合预期:

This is vehicle and I know my color is blue
Car with color blue and no of seats 5
This is vehicle and I know my color is white
Truck with color white and loading capacity 1000 tons

在本节中,我们介绍了继承及其类型,即简单继承和多重继承。接下来,我们将研究 Python 中的多态概念。

多态

在其字面意义上,具有多种形式的过程称为多态。在面向对象编程(OOP)中,多态是指实例能够以多种方式表现的能力,以及使用具有相同名称和相同参数的相同方法,根据所属的类以不同的方式表现。

多态可以通过两种方式实现:方法重载方法重写。我们将在下一小节中分别讨论。

方法重载

方法重载是通过拥有多个具有相同名称但具有不同类型或参数数量来达到多态的一种方式。在 Python 中实现方法重载没有干净利落的方法。Python 中两个方法不能有相同的名称。在 Python 中,一切都是对象,包括类和方法。当我们为类编写方法时,实际上它们是从命名空间的角度来看的类的属性,因此不能有相同的名称。如果我们编写两个具有相同名称的方法,将不会出现语法错误,第二个方法将简单地替换第一个方法。

在类内部,可以通过设置参数的默认值来重载方法。这不是实现方法重载的完美方式,但它是可行的。以下是在 Python 类内部实现方法重载的示例:

#methodoverloading1.py
class Car:
    def __init__(self, color, seats):
        self.i_color = color
        self.i_seat = seats
    def print_me(self, i='basic'):
        if(i =='basic'):
            print(f"This car is of color {self.i_color}")
        else:
            print(f"This car is of color {self.i_color} \
             with seats {self.i_seat}")

if __name__ == "__main__":
    car = Car("blue", 5 )
    car.print_me()
    car.print_me('blah')
    car.print_me('detail')

在这个例子中,我们添加了一个具有默认值的参数的print_me方法。如果没有传递参数,将使用默认值。当没有传递参数给print_me方法时,控制台输出将只提供Car实例的颜色。当传递参数给此方法(无论值如何)时,我们将有此方法的不同行为,即提供Car实例的颜色和座位数。以下是此程序的参考控制台输出:

This car is of color blue
This car is of color blue with seats 5
This car is of color blue with seats 5

重要提示

有可用的第三方库(例如,overload),可以用来以更干净的方式实现方法重载。

方法重写

在子类和父类中拥有相同的方法名称被称为方法重写。父类和子类中方法的实现预期应该是不同的。当我们对一个子类的实例调用重写方法时,Python 解释器会在子类定义中查找该方法,即被重写的方法。解释器会执行子类级别的该方法。如果解释器在子类实例级别找不到方法,它会在父类中查找。如果我们必须使用子类实例来特别执行在子类中被重写的父类方法,我们可以使用super()方法来访问父类级别的该方法。这是 Python 中更受欢迎的多态概念之一,因为它与继承紧密相关,并且是实现继承的强大方式之一。

为了说明如何实现方法重写,我们将通过将print_vehicle_info方法名称重命名为print_me来更新inhertance1.py代码片段。正如我们所知,print_me方法已经在两个子类中存在,并且有不同的实现。以下是带有更改高亮的更新代码:

#methodoverriding1.py
class Vehicle:
    def __init__(self, color):
        self.i_color = color
    def print_me(self):
        print(f"This is vehicle and I know my color is \
         {self.i_color}")
class Car (Vehicle):
    def __init__(self, color, seats):
        self.i_color = color
        self.i_seats = seats
    def print_me(self):
        print( f"Car with color {self.i_color} and no of \
         seats {self.i_seats}")
class Truck (Vehicle):
    def __init__(self, color, capacity):
        self.i_color = color
        self.i_capacity = capacity
    def print_me(self):
        print( f"Truck with color {self.i_color} and \
         loading capacity {self.i_capacity} tons")
if __name__ == "__main__":
    vehicle = Vehicle("red")
    vehicle.print_me()
    car = Car ("blue", 5)
    car.print_me()
    truck = Truck("white", 1000)
    truck.print_me()

在这个例子中,我们在子类中重写了print_me方法。当我们创建三个不同实例的VehicleCarTruck类,并执行相同的方法时,我们得到不同的行为。以下是作为参考的控制台输出:

This is vehicle and I know my color is red
Car with color blue and no of seats 5
Truck with color white and loading capacity 1000 tons

方法重写在现实世界问题中有许多实际应用——例如,我们可以继承内置的list类,并可以重写其方法以添加我们的功能。引入自定义的排序方法是一个对list对象进行方法重写的例子。我们将在下一章中介绍方法重写的几个示例。

抽象

抽象是面向对象编程(OOP)的另一个强大特性,主要与隐藏实现细节和仅展示对象的本质或高级特性相关。一个现实世界的例子是我们通过抽象得到的汽车,作为驾驶员,我们拥有作为驾驶员可用的主要功能,而不需要知道这些功能是如何工作的真实细节以及哪些其他对象参与提供这些功能。

抽象是一个与封装和继承相关的概念,这也是为什么我们将这个主题留到最后的理由,以便首先理解封装和继承。将这个主题作为一个独立主题的另一个原因是强调在 Python 中使用抽象类。

Python 中的抽象类

抽象类就像其他类的蓝图。抽象类允许你创建一组抽象方法(空方法),这些方法需要子类来实现。简单来说,包含一个或多个抽象方法的类被称为抽象。另一方面,抽象方法是指只有声明而没有实现的方法。

抽象类中可以有已经实现的方法,这些方法可以通过继承被子类(如原样)利用。抽象类的概念对于实现常见的接口,如应用程序编程接口API),以及定义一个可以在子类中重用的公共代码库非常有用。

小贴士

抽象类不能被实例化。

抽象类可以使用 Python 的一个内置模块abc包来实现。abc包还包括Abstractmethod模块,它使用装饰器来声明抽象方法。下面是一个简单的 Python 示例,展示了使用ABC模块和abstractmethod装饰器的用法:

#abstraction1.py
from abc import ABC, abstractmethod
class Vehicle(ABC):
    def hello(self):
        print(f"Hello from abstract class")
    @abstractmethod
    def print_me(self):
       pass
class Car (Vehicle):
    def __init__(self, color, seats):
        self.i_color = color
        self.i_seats = seats

    """It is must to implemented this method"""
    def print_me(self):
        print( f"Car with color {self.i_color} and no of \
         seats {self.i_seats}")
if __name__ == "__main__":
   # vehicle = Vehicle()    #not possible
   # vehicle.hello()
    car = Car ("blue", 5)
    car.print_me()
    car.hello()

在这个例子中,我们做了以下操作:

  • 我们通过从ABC类继承并声明其中一个方法(print_me)为抽象方法,使Vehicle类成为抽象类。我们使用@abstractmethod装饰器来声明抽象方法。

  • 接下来,我们通过在它中实现print_me方法并保持其余代码与上一个示例相同,更新了我们著名的Car类。

  • 在程序的主要部分,我们尝试创建Vehicle类的一个实例(如图所示中的注释代码)。我们创建了一个Car类的实例并执行了print_mehello方法。

当我们尝试创建Vehicle类的一个实例时,它会给我们一个如下错误:

Can't instantiate abstract class Vehicle with abstract methods print_me

此外,如果我们尝试不在Car子类中实现print_me方法,我们会得到一个错误。对于Car类的一个实例,我们从print_mehello方法得到预期的控制台输出。

使用组合作为替代的设计方法

组合是面向对象编程中另一个流行的概念,它再次与封装有些相关。简单来说,组合意味着在一个对象内部包含一个或多个对象以形成一个现实世界的对象。包含其他类对象的类被称为组合类,而其对象包含在组合类中的类被称为组件类。在下面的屏幕截图中,我们展示了一个具有三个组件类对象ABC的组合类示例:

图 3.2 – 组合类与其组件类之间的关系

图 3.2 – 组合类与其组件类之间的关系

组合被认为是继承的替代方法。这两种设计方法都是为了在对象之间建立关系。在继承的情况下,对象之间是紧密耦合的,因为父类中的任何更改都可能导致子类中的代码出错。另一方面,在组合的情况下,对象之间是松散耦合的,这有助于在一个类中进行更改而不会破坏另一个类中的代码。由于灵活性,组合方法相当受欢迎,但这并不意味着它是每个问题的正确选择。那么,我们如何确定在哪个问题中使用哪个呢?这里有一个经验法则。当我们有对象之间的“是一个”关系时,继承是正确的选择——例如,汽车“是一个”车辆,猫“是一个”动物。在继承的情况下,子类是父类的扩展,具有额外的功能以及重用父类功能的能力。如果对象之间的关系是一个对象“有”另一个对象,那么最好使用组合——例如,汽车“有”一个电池。

我们将使用之前关于Car类和Engine类的示例。在多重继承的示例代码中,我们将Car类实现为Engine类的子类,这并不是继承的一个很好的用例。现在是时候通过在Car类中实现Engine对象来使用组合了。我们还可以有一个Seat类,并将其包含在Car类中。

我们将在以下示例中进一步阐述这个概念,其中我们将通过在它内部包含EngineSeat类来构建Car类:

#composition1.py
class Seat:
    def __init__(self, type):
        self.i_type = type
    def __str__(self):
        return f"Seat type: {self.i_type}"
class Engine:
    def __init__(self, size):
        self.i_size = size
    def __str__(self):
        return f"Engine: {self.i_size}"
class Car:
    def __init__(self, color, eng_size, seat_type):
        self.i_color = color
        self.engine = Engine(eng_size)
        self.seat = Seat(seat_type)
    def print_me(self):
        print(f"This car of color {self.i_color} with \
         {self.engine} and {self.seat}")
if __name__ == "__main__":
    car = Car ("blue", "2.5L", "leather" )
    car.print_me()
    print(car.engine)
    print(car.seat)
    print(car.i_color)
    print(car.engine.i_size)
    print(car.seat.i_type)

我们可以如下分析这个示例代码:

  1. 我们定义了EngineSeat类,每个类中有一个属性:Engine类的i_sizeSeat类的i_type

  2. 之后,我们通过添加i_color属性、一个Engine实例和一个Seat实例来定义了一个Car类。EngineSeat实例是在创建Car实例时创建的。

  3. 在这个主程序中,我们创建了一个Car实例并执行了以下操作:

    a) car.print_me: 这将访问Car实例上的print_me方法。

    b) print(car.engine): 这将执行Engine类的__str__方法。

    c) print(car.seat): 这将执行Seat类的__str__方法。

    d) print(car.i_color): 这将访问Car实例的i_color属性。

    e) print(car.engine.i_size): 这将访问Car实例内部的Engine实例的i_size属性。

    f) print(car.seat.i_type): 这将访问Car实例内部的Seat实例的i_type属性

该程序的控制台输出如下所示:

This car of color blue with Engine: 2.5L and Seat type: leather
Engine: 2.5L
Seat type: leather
blue
2.5L
leather

接下来,我们将讨论鸭子类型,它是多态的替代方案。

在 Python 中引入鸭子类型

鸭子类型,有时也称为动态类型,主要在支持动态类型的编程语言中采用,如 Python 和 JavaScript。这个名字“鸭子类型”是基于以下引用借用的:

“如果它看起来像鸭子,游泳像鸭子,呱呱叫像鸭子,那么它可能就是一只鸭子。”

这意味着如果一个鸟儿表现得像一只鸭子,它很可能就是一只鸭子。提到这个引用的目的是,我们可以通过对象的行为来识别对象,这是 Python 中鸭子类型的核心原则。

在鸭子类型中,对象的类类型不如它定义的方法(行为)重要。使用鸭子类型,不会检查对象的类型,但会执行期望的方法。

为了说明这个概念,我们用一个包含三个类CarCycleHorse的简单例子,并尝试在每个类中实现一个start方法。在Horse类中,我们不是将方法命名为start,而是将其命名为push。以下是包含所有三个类和末尾主程序的代码片段:

#ducttype1.py
class Car:
    def start(self):
        print ("start engine by ignition /battery")
class Cycle:
    def start(self):
        print ("start by pushing paddles")
class Horse:
    def push(self):
        print ("start by pulling/releasing the reins")
if __name__ == "__main__":
    for obj in Car(), Cycle(), Horse():
        obj.start()

在主程序中,我们尝试动态地迭代这些类的实例并调用start方法。正如预期的那样,对于Horse对象,obj.start()这一行失败了,因为该类没有这样的方法。正如我们在这个例子中所看到的,我们可以在一个语句中放入不同的类或实例类型,并执行它们的方法。

如果我们将Horse类中的名为push的方法改为start,主程序将无错误地执行。鸭子类型有许多用例,它可以简化解决方案。在许多对象中使用len方法和使用迭代器是一些例子。我们将在下一章详细探讨迭代器。

到目前为止,我们已经回顾了不同的面向对象概念和原则及其好处。在下一节中,我们还将简要讨论何时使用面向对象编程并不非常有益。

学习何时在 Python 中不使用面向对象编程

Python 具有使用面向对象语言(如 Java)或使用声明性编程(如 C)来开发程序的灵活性。面向对象编程总是对开发者有吸引力,因为它提供了封装、抽象、继承和多态等强大工具,但这些工具可能并不适合每个场景和用例。当用于构建大型且复杂的应用程序时,特别是涉及用户界面UIs)和用户交互的应用程序时,这些工具更有益。

如果你的程序更像是一个需要执行特定任务且不需要保持对象状态的脚本,那么使用面向对象编程就过度了。数据科学应用和密集型数据处理就是那些使用面向对象编程不那么重要,而定义如何按特定顺序执行任务以实现目标更为重要的例子。一个现实世界的例子是为在节点集群上执行数据密集型工作编写客户端程序,例如 Apache Spark 用于并行处理。我们将在后面的章节中介绍这些类型的程序。以下是一些使用面向对象编程不是必要的场景:

  • 读取文件、应用逻辑并将结果写回新文件是那种更适合使用模块中的函数而不是面向对象编程来实现的程序类型。

  • 使用 Python 配置设备非常流行,这也是另一个可以使用常规函数完成的候选方案。

  • 解析和转换数据从一个格式到另一个格式也是可以通过使用声明式编程而不是面向对象编程来编程的用例。

  • 将旧代码库移植到使用面向对象编程的新代码库不是一个好主意。我们需要记住,旧代码可能不是使用面向对象设计模式构建的,我们最终可能会得到难以维护和扩展的封装在类和对象中的非面向对象函数。

简而言之,在决定是否使用面向对象编程之前,首先分析问题陈述和需求是很重要的。这也取决于你将与你程序一起使用的第三方库。如果你需要从第三方库扩展类,那么在这种情况下,你将不得不使用面向对象编程。

摘要

在本章中,我们学习了 Python 中的类和对象的概念,并讨论了如何构建类以及如何使用它们来创建对象和实例。随后,我们深入探讨了面向对象编程的四个支柱:封装、继承、多态和抽象。我们还通过简单明了的代码示例来帮助读者掌握面向对象编程的概念。这四个支柱是使用 Python 进行面向对象编程的基础。

在后面的章节中,我们还介绍了鸭子类型,这对于阐明其不依赖于类的重要性至关重要,在回顾完何时使用面向对象编程并不显著有益后结束本章。

通过学习本章,你不仅更新了你对面向对象编程主要概念的知识,还学会了如何使用 Python 语法应用这些概念。我们将在下一章回顾一些用于高级编程的 Python 库。

问题

  1. 什么是类和对象?

  2. 什么是双下划线方法(dunder methods)?

  3. Python 是否支持从多个类继承?

  4. 我们能否创建一个抽象类的实例?

  5. 在鸭子类型中,类的类型很重要:对还是错?

进一步阅读

  • 《Python 模块化编程》,作者 Erik Westra

  • 《Python 3 面向对象编程》,作者 Dusty Phillips

  • 《学习面向对象编程》,作者 Gaston C. Hillar

  • 《Python 从入门到实践》第三版,由 凯·霍斯特曼兰斯·内塞

答案

  1. 类是一个蓝图或模板,用于告诉 Python 解释器如何定义某个事物。对象是根据类中定义的内容从该类构建的一个实例。

  2. 双下划线方法(Dunders)是始终以双下划线开始和结束的特殊方法。每个类都可以实现几十个特殊方法。

  3. 是的——Python 支持从多个类中继承一个类。

  4. 不可以——我们不能创建一个抽象类的实例。

  5. 错误。比类更重要的是方法。

第二部分:高级编程概念

我们在本节继续我们的旅程,通过学习 Python 语言的先进概念。这包括为你复习一些概念,并介绍高级主题,如迭代器、生成器、错误和异常处理。这将帮助你进入 Python 编程的下一个层次。除了编写 Python 程序外,我们还探讨如何使用 unittest 和 pytest 等测试框架编写和自动化单元测试和集成测试。在本节的最后部分,我们讨论了一些用于数据转换和构建 Python 装饰器的先进函数概念,以及如何使用包括 pandas DataFrames 在内的数据结构进行数据分析应用。

本节包含以下章节:

  • 第四章Python 高级编程库

  • 第五章使用 Python 进行测试和自动化

  • 第六章Python 的高级技巧和窍门

第四章:第四章:Python 高级编程库

在前面的章节中,我们讨论了在 Python 中构建模块化和可重用程序的不同方法。在本章中,我们将探讨 Python 编程语言的几个高级概念,如迭代器、生成器、日志记录和错误处理。这些概念对于编写高效和可重用的代码非常重要。对于本章,我们假设您熟悉 Python 语言语法,并且知道如何编写控制和循环结构。

在本章中,我们将学习 Python 中循环的工作原理,如何处理文件以及打开和访问文件的最佳实践,以及如何处理可能预期或意外的错误情况。我们还将研究 Python 中的日志记录支持以及配置日志系统的不同方法。本章还将帮助您学习如何使用 Python 的高级库来构建复杂的项目。

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

  • 介绍 Python 数据容器

  • 使用迭代器和生成器进行数据处理

  • Python 中的文件处理

  • 处理错误和异常

  • 使用 Python 的 logging 模块

到本章结束时,您将学会如何构建迭代器和生成器,如何处理程序中的错误和异常,以及如何以高效的方式为您的 Python 项目实现日志记录。

技术要求

本章的技术要求是您需要在您的计算机上安装 Python 3.7 或更高版本。本章的示例代码可以在github.com/PacktPublishing/Python-for-Geeks/tree/master/Chapter04找到。

让我们从回顾 Python 中可用的数据容器开始,这将有助于本章后续主题的学习。

介绍 Python 数据容器

Python 支持多种数据类型,包括数值类型和集合。定义数值数据类型,如整数和浮点数,是基于给变量赋值。我们赋给变量的值决定了数值数据类型的类型。请注意,可以使用特定的构造函数(例如,int()float())来创建特定数据类型的变量。容器数据类型也可以通过在适当格式中赋值或使用每个集合数据类型的特定构造函数来定义。在本节中,我们将研究五种不同的容器数据类型:字符串列表元组字典集合

字符串

字符串不是直接的数据容器类型。但讨论字符串数据类型很重要,因为它在 Python 编程中被广泛使用,并且字符串数据类型是用一个不可变序列(Unicode 代码点序列)实现的。它使用序列(一种集合类型)的事实使其成为本节讨论的候选者。

在 Python 中,字符串对象是不可变对象。由于不可变性,字符串对象为并发程序提供了一个安全的解决方案,在这些程序中,多个函数可能访问相同的字符串对象并返回相同的结果。这种安全性在可变对象中是不可能的。作为不可变对象,字符串对象常被用作字典数据类型的键或集合数据类型的数据元素。不可变性的缺点是,即使只是对现有的字符串实例进行微小的更改,也需要创建一个新的实例。

可变对象与不可变对象

可变对象在创建后可以被更改,但不可变对象则不行。

字符串字面量可以使用匹配的单引号(例如,'blah')、双引号(例如,"blah blah")或三重单引号或双引号(例如,"""none"""'''none''')来包围。还值得一提的是,Python 3 和 Python 2 中字符串对象的处理方式不同。在 Python 3 中,字符串对象只能以 Unicode 数据点的形式存储文本序列,但在 Python 2 中,它们可以存储文本以及字节数据。在 Python 3 中,字节数据由 bytes 数据类型处理。

在 Python 3 中,将文本与字节分离使得处理更加清晰和高效,但代价是数据可移植性。字符串中的 Unicode 文本不能直接保存到磁盘或发送到网络上的远程位置,除非将其转换为二进制格式。这种转换需要将字符串数据编码为字节序列,这可以通过以下方式之一实现:

  • UTF-8 是默认编码)以及如何处理错误。

  • 通过将字符串实例传递给 bytes 构造函数,并附带编码方案和错误处理方案,可以将字符串数据类型转换为 Bytes 数据类型。

任何字符串对象的方法细节和可用属性可以在官方 Python 文档中找到,具体取决于 Python 版本。

列表

列表是 Python 中基本集合类型之一,它使用单个变量来存储多个对象。列表是动态的且可变的,这意味着列表中的对象可以被更改,列表可以增长或缩小。

Python 中的列表对象不是使用任何链表概念实现的,而是使用可变长数组。该数组包含它所存储的对象的引用。这个数组的指针及其长度存储在列表头结构中,该结构在对象被添加或从列表中删除时保持最新。这种数组的操作被设计得像列表一样,但实际上它不是一个真正的列表。这就是为什么 Python 列表的一些操作没有被优化。例如,将新对象插入列表和从列表中删除对象将具有O(n)的复杂度。

为了解决这种情况,Python 在collections内置模块中提供了一个deque数据类型。deque数据类型提供了栈和队列的功能,并且在需要类似链表的行为时是一个很好的替代选项。

列表可以创建为空或带有初始值,使用方括号。接下来,我们将展示一个代码片段,演示如何仅使用方括号或使用列表对象构造函数创建空或非空列表对象:

e1 = []                  #an empty list
e2 = list()              #an empty list via constructor
g1 = ['a', 'b']          #a list with 2 elements
g2 = list(['a', 'b'])    #a list with 2 elements using a \
                          constructor
g3 = list(g1)            #a list created from a list

列表对象可用的操作细节,如addinsertappenddelete,可以在官方 Python 文档中查阅。我们将在下一节介绍元组。

元组

元组是一个不可变列表,这意味着一旦创建后就不能修改。元组通常用于少量条目,并且当集合中条目的位置和顺序很重要时。为了保持条目的顺序,元组被设计为不可变的,这也是元组与列表区别开来的地方。元组上的操作通常比常规列表数据类型更快。在需要集合中的值以特定顺序保持不变的情况下,由于它们的优越性能,使用元组是首选选项。

元组通常使用值初始化,因为它们是不可变的。可以使用括号创建一个简单的元组。以下代码片段展示了创建元组实例的几种方法:

w = ()                     #an empty tuple
x = (2, 3)                 #tuple with two elements
y = ("Hello World")        #not a tuple, Comma is required \
                            for single entry tuple
z = ("Hello World",)       #A comma will make it a tuple

在此代码片段中,我们创建了一个空元组(w),一个包含数字的元组(x),以及一个包含文本Hello World的元组(z)。变量y不是一个元组,因为对于单元素元组(单个对象的元组),我们需要一个尾随逗号来表示它是一个元组。

在介绍列表和元组之后,我们将简要介绍字典。

字典

字典是 Python 中最常用且功能多样的数据类型之一。字典是一种用于以键:值格式存储数据值的集合。字典是可变且无序的数据类型。在其他编程语言中,它们被称为关联数组哈希表

可以使用花括号和一系列 key:value 对来创建字典。键与其值由冒号 ':' 分隔,而 key:value 对由逗号 ',' 分隔。以下是一个字典定义的代码片段:

mydict = {
  "brand": "BMW",
  "model": "330i",
  "color": "Blue"
}

字典中不允许有重复的键。键必须是一个不可变对象类型,如字符串、元组或数字。字典中的值可以是任何数据类型,甚至包括列表、集合、自定义对象,甚至另一个字典本身。

当处理字典时,有三个对象或列表是重要的:

  • keys() 方法:

    dict_object.keys()
    
  • values() 方法:

    dict_object.values()
    
  • items() 方法:

    dict_object.items()
    

接下来,我们将讨论集合,它们也是 Python 中的关键数据结构。

集合

集合是一组独特的对象集合。集合是一个可变且无序的集合。集合中不允许有对象的重复。Python 使用散列表数据结构来实现集合的唯一性,这与确保字典中键的唯一性的方法相同。Python 中集合的行为与数学中的集合非常相似。这种数据类型在对象顺序不重要但唯一性很重要的情况下找到其应用。这有助于测试某个集合是否包含某个特定对象。

小贴士

如果需要一个不可变数据类型的集合行为,Python 有一个名为 frozenset 的集合的变体实现。

可以使用花括号或使用集合构造函数 (set()) 来创建一个新的集合对象。以下代码片段展示了创建集合的一些示例:

s1 = set()            # empty set
s2 = {}               # an empty set using curly 
s3 = set(['a', 'b'])  # a set created from a list with                       # const.
s3 = {1,2}            # a set created using curly bracket
s4 = {1, 2, 1}        # a set will be created with only 1 and 2                     # objects. Duplicate object will be ignored

使用索引方法无法访问集合对象。我们需要像列表一样从集合中弹出一个对象,或者我们可以迭代集合以逐个获取对象。像数学集合一样,Python 中的集合也支持如 并集交集差集 等操作。

在本节中,我们回顾了 Python 3 中字符串和集合数据类型的关键概念,这些概念对于理解即将到来的主题——迭代器和生成器至关重要。

使用迭代器和生成器进行数据处理

迭代是数据处理和数据转换中使用的核心工具之一。在处理大型数据集时,当整个数据集无法或效率不高地放入内存时,迭代特别有用。迭代器提供了一种逐个将数据带入内存的方法。

可以通过定义一个单独的类并实现特殊方法 __iter____next__ 来创建迭代器。但还有一种使用 yield 操作创建迭代器的新方法,称为生成器。在接下来的小节中,我们将研究迭代器和生成器。

迭代器

迭代器是用于遍历其他对象的实体。一个可以由迭代器遍历的对象被称为iterable对象类。虽然不推荐这样做,但从技术上讲是可行的,我们将会通过一个例子来讨论为什么这种做法不是一个好的设计方法。在下一个代码片段中,我们提供了几个使用 Python 中的for循环进行迭代操作的例子:

#iterator1.py
#example 1: iterating on a list
for x in [1,2,3]:
    print(x)
#example 2: iterating on a string
for x in "Python for Geeks":
    print(x, end="")
print('')
#example 3: iterating on a dictionary
week_days = {1:'Mon', 2:'Tue', 
             3:'Wed', 4:'Thu', 
             5:'Fri', 6:'Sat', 7:'Sun'}
for k in week_days:
   print(k, week_days[k])
#example 4: iterating on a file
for row in open('abc.txt'):
    print(row, end="")

在这些代码示例中,我们使用了不同的for循环来遍历列表、字符串、字典和文件。所有这些数据类型都是可迭代的,因此我们将使用简单的语法,通过for循环遍历这些集合或序列中的项目。接下来,我们将研究使对象可迭代的要素,这通常被称为迭代器协议

重要提示

在 Python 中,每个集合默认都是可迭代的

在 Python 中,迭代器对象必须实现两个特殊方法:__iter____next__。要迭代一个对象,该对象必须至少实现__iter__方法。一旦对象实现了__iter__方法,我们就可以称该对象为可迭代的。这些方法将在下面进行描述:

  • __iter__:这个方法返回迭代器对象。这个方法在循环开始时被调用,以获取迭代器对象。

  • __next__:这个方法在循环的每次迭代中被调用,并返回可迭代对象中的下一个项目。

为了解释如何构建一个可迭代的自定义对象,我们将实现Week类,该类将所有工作日的数字和名称存储在字典中。这个类默认不是可迭代的。为了使其可迭代,我们将添加__iter__方法。为了使例子简单,我们还将在这个类中添加__next__方法。以下是包含Week类和主程序的代码片段,该程序通过迭代获取工作日的名称:

#iterator2.py
class Week:
    def __init__(self):
        self.days = {1:'Monday', 2: "Tuesday",
                     3:"Wednesday", 4: "Thursday",
                     5:"Friday", 6:"Saturday", 7:"Sunday"}
        self._index = 1
    def __iter__(self):
        self._index = 1
        return self
    def __next__(self):
        if self._index < 1 | self._index > 7 :
            raise StopIteration
        else:
            ret_value =  self.days[self._index]
            self._index +=1
        return ret_value
if(__name__ == "__main__"):
    wk = Week()
    for day in wk:
        print(day)

我们分享这个代码示例只是为了演示如何在同一个对象类中实现__iter____next__方法。这种实现迭代器的方式在互联网上很常见,但并不是一个推荐的方法,并且被认为是一个不好的设计。原因是当我们使用它时,在for循环中,我们会得到一个作为迭代器的主要对象,因为我们在这个类中实现了__iter____next__。这可能会导致不可预测的结果。我们可以通过执行以下代码片段来证明这一点,针对同一个类Week

#iterator3.py
class Week:
#class definition is the same as shown in the previous \
 code example
if(__name__ == "__main__"):
    wk = Week()
    iter1 = iter(wk)
    iter2 = iter(wk)
   print(iter1.__next__())
   print(iter2.__next__())
   print(next(iter1))
   print(next(iter2))

在这个新的主程序中,我们使用两个不同的迭代器遍历同一个对象。这个主程序的结果并不像预期的那样。这是因为两个迭代器共享一个常见的基础属性_index。以下是作为参考的控制台输出:

Monday
Tuesday
Wednesday
Thursday

注意,在这个新的主程序中,我们故意没有使用for循环。我们使用iter函数为同一个Week类的对象创建了两个迭代器对象。iter函数是一个 Python 标准函数,它调用__iter__方法。为了获取可迭代对象中的下一个项目,我们直接使用了__next__方法和next函数。next函数也是一个通用函数,就像iter函数一样。这种将可迭代对象用作迭代器的方法也不被认为是线程安全的。

最佳做法始终是使用一个单独的迭代器类,并且始终通过__iter__方法创建迭代器的新实例。每个迭代器实例都必须管理自己的内部状态。下面是Week类相同代码示例的修订版,其中包含一个单独的迭代器类:

#iterator4.py
class Week:
    def __init__(self):
        self.days = {1: 'Monday', 2: "Tuesday",
                     3: "Wednesday", 4: "Thursday",
                     5: "Friday", 6: "Saturday", 7: "Sunday"}

    def __iter__(self):
        return WeekIterator(self.days)
class WeekIterator:
    def __init__(self, dayss):
        self.days_ref = dayss
        self._index = 1
    def __next__(self):
        if self._index < 1 | self._index > 8:
            raise StopIteration
        else:
            ret_value =  self.days_ref[self._index]
            self._index +=1
        return ret_valu
if(__name__ == "__main__"):
    wk = Week()
    iter1 = iter(wk)
    iter2 = iter(wk)
    print(iter1.__next__())
    print(iter2.__next__())
    print(next(iter1))
    print(next(iter2))

在这个修订的代码示例中,我们有一个包含__next__方法的单独迭代器类,它有自己的_index属性来管理迭代器状态。迭代器实例将引用容器对象(字典)。修订示例的控制台输出给出了预期的结果:每个迭代器都在单独迭代同一个Week类的实例。控制台输出如下作为参考:

Monday
Monday
Tuesday
Tuesday

简而言之,要创建一个迭代器,我们需要实现__iter____next__方法,管理内部状态,并在没有值可用时引发StopIteration异常。接下来,我们将研究生成器,这将简化我们返回迭代器的方式。

生成器

生成器是一种简单的方法,用于返回一个迭代器实例,它可以用于迭代,这通过仅实现一个生成器函数来实现。生成器函数类似于一个普通函数,但其中包含的是yield语句而不是return语句。在生成器函数中仍然允许使用return语句,但它不会用于返回可迭代对象中的下一个项目。

根据定义,如果一个函数至少包含一个yield语句,那么它将是一个生成器函数。使用yield语句时的主要区别是它会暂停函数并保存其内部状态,当函数下次被调用时,它将从上次yield的行开始。这种设计模式使得迭代器功能简单且高效。

在内部,__iter____next__等方法会自动实现,StopIteration异常也会自动引发。局部属性及其值在连续调用之间被保留,开发者不需要实现额外的逻辑。当 Python 解释器识别到生成器函数(包含yield语句的函数)时,它会提供所有这些功能。

要理解生成器是如何工作的,我们将从一个简单的生成器示例开始,该示例用于生成字母表前三个字母的序列:

#generators1.py
def my_gen():
    yield 'A'
    yield 'B'
    yield 'C'
if(__name__ == "__main__"):
    iter1 = my_gen()
    print(iter1.__next__())
    print(next(iter1))
    print(iter1.__next__())

在此代码示例中,我们使用三个yield语句实现了一个简单的生成器函数,而没有使用return语句。在程序的主要部分,我们做了以下操作:

  1. 我们调用了生成器函数,它返回给我们一个迭代器实例。在这个阶段,my_gen()生成器函数内部的任何一行都没有执行。

  2. 使用迭代器实例,我们调用了__next__方法,这启动了my_gen()函数的执行,在执行第一个yield语句后暂停,并返回A

  3. 接下来,我们在迭代器实例上调用next()函数。结果是我们在使用__next__方法时得到的结果相同。但这次,my_gen()函数从上次由于yield语句而暂停的下一行开始执行。下一行是另一个yield语句,这导致在返回字母B后再次暂停。

  4. 下一个__next__方法将导致执行下一个yield语句,这将返回字母C

接下来,我们将重新审视Week类及其迭代器实现,并将使用生成器而不是迭代器类。下面的示例代码将展示:

#generator2.py
class Week:
    def __init__(self):
        self.days = {1:'Monday', 2: "Tuesday", 
                     3:"Wednesday", 4: "Thursday", 
                     5:"Friday", 6:"Saturday", 7:"Sunday"}
    def week_gen(self):
        for x in self.days:
            yield self.days[x]
if(__name__ == "__main__"):
    wk = Week()
    iter1 = wk.week_gen()
    iter2 = iter(wk.week_gen())
    print(iter1.__next__())
    print(iter2.__next__())
    print(next(iter1))
    print(next(iter2))

iterator4.py相比,使用生成器实现的Week类要简单得多,也更干净,我们可以达到相同的结果。这就是生成器的力量,这也是为什么它们在 Python 中非常受欢迎。在结束这个主题之前,重要的是要强调生成器的几个其他关键特性:

  • 生成器表达式:生成器表达式可以用来动态创建简单的生成器(也称为匿名函数),而不需要编写特殊的方法。语法与列表推导式类似,只是我们使用圆括号而不是方括号。下面的代码示例(是我们为列表推导式引入的示例的扩展)展示了如何使用生成器表达式创建生成器,其用法,以及与列表推导式的比较:

    #generator3.py
    L = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]
    f1 = [x+1 for x in L]
    g1 = (x+1 for x in L)
    print(g1.__next__())
    print(g1.__next__())
    
  • 无限流:生成器也可以用来实现无限数据流。将无限流引入内存总是一个挑战,但生成器可以轻松解决这个问题,因为它们一次只返回一个数据项。

  • for循环,但我们将尝试使用两个生成器来解决它:prime_gen生成器用于生成素数,x2_gen生成器用于取prime_gen生成器提供的素数的平方。我们将这两个生成器管道化输入到sum函数中,以获得所需的结果。以下是此问题解决方案的代码片段:

    #generator4.py
    def prime_gen(num):
        for cand in range(2, num+1):
            for i in range (2, cand):
                if (cand % i) == 0:
                    break
            else:
                yield cand
    def x2_gen(list2):
        for num in list2:
            yield num*num
    print(sum(x2_gen(prime_gen(5))))
    

生成器基于按需操作,这使得它们不仅内存效率高,而且提供了一种在需要时生成值的方法。这有助于避免不必要的生成数据,这些数据可能根本不会被使用。生成器非常适合用于大量数据处理、将数据从一个函数传递到另一个函数,以及模拟并发。

在下一节中,我们将探讨如何在 Python 中处理文件。

在 Python 中处理文件

从文件中读取数据或将数据写入文件是任何编程语言支持的基本操作之一。Python 提供了广泛的支持来处理文件操作,这些操作大多可在其标准库中找到。在本节中,我们将讨论核心文件操作,如打开文件、关闭文件、从文件中读取、向文件中写入、使用上下文管理器进行文件管理,以及使用 Python 标准库通过一个句柄打开多个文件。我们将从下一小节开始讨论文件操作。

文件操作

文件操作通常从打开文件开始,然后读取或更新该文件的内容。核心文件操作如下:

打开和关闭文件

要对文件执行任何读取或更新操作,我们需要一个指向文件的指针或引用。可以通过使用内置的open函数来打开文件来获取文件引用。此函数返回对file对象的引用,也称为使用绝对或相对路径命名的文件。一个可选参数是访问模式,用于指示文件应以何种模式打开。访问模式可以是readwriteappend或其他。访问模式选项的完整列表如下:

  • r:此选项用于以只读模式打开文件。如果没有提供访问模式选项,这是一个默认选项:

    f = open ('abc.txt')
    
  • a:此选项用于打开文件以在文件末尾追加新行:

    f = open ('abc.txt', 'a')
    
  • w:此选项用于以写入模式打开文件。如果文件不存在,它将创建一个新文件。如果文件存在,此选项将覆盖它,并且该文件中的任何现有内容都将被销毁:

    f = open ('abc.txt', 'w')
    
  • x:此选项用于以独占写入模式打开文件。如果文件已存在,它将引发错误:

    f = open ('abc.txt', 'x')
    
  • t:此选项用于以文本模式打开文件。这是默认选项。

  • b:此选项用于以二进制模式打开文件。

  • +:此选项用于以读写模式打开文件:

    f = open ('abc.txt', 'r+'
    

模式选项可以组合使用以获取多个选项。除了文件名和访问模式选项之外,我们还可以传递编码类型,尤其是对于文本文件。以下是一个使用utf-8打开文件的示例:

f = open("abc.txt", mode='r', encoding='utf-8')

当我们完成对文件的操作后,关闭文件以释放资源供其他进程使用文件是必须的。可以通过在文件实例或文件句柄上使用close方法来关闭文件。以下是一个展示如何使用close方法的代码片段:

file = open("abc.txt", 'r+w')
#operations on file
file.close()

一旦文件关闭,操作系统将释放与文件实例和锁(如果有)相关的资源,这是任何编程语言中的最佳实践。

读写文件

可以通过以访问模式r打开文件并使用读取方法之一来读取文件。接下来,我们总结可用于读取操作的不同方法:

  • read(n): 此方法从文件中读取n个字符。

  • readline(): 此方法返回文件中的一行。

  • readlines(): 此方法返回文件中所有行的列表。

同样,一旦以适当的访问模式打开文件,我们就可以向文件追加或写入内容。与追加文件相关的相关方法如下:

  • write (x): 此方法将字符串或字节序列写入文件,并返回添加到文件中的字符数。

  • writelines (lines): 此方法将行列表写入文件。

在下一个代码示例中,我们将创建一个新文件,向其中添加一些文本行,然后使用之前讨论的读取操作读取文本数据:

#writereadfile.py: write to a file and then read from it
f1 = open("myfile.txt",'w')
f1.write("This is a sample file\n")
lines =["This is a test data\n", "in two lines\n"]
f1.writelines(lines)
f1.close()
f2 = open("myfile.txt",'r')
print(f2.read(4))
print(f2.readline())
print(f2.readline())
f2.seek(0)
for line in f2.readlines():
    print(line)
f2.close()

在此代码示例中,我们首先向文件写入三行。在读取操作中,首先读取四个字符,然后使用readline方法读取两行。最后,我们使用seek方法将指针移回文件顶部,并使用readlines方法访问文件中的所有行。

在下一节中,我们将看到使用上下文管理器如何使文件处理变得方便。

使用上下文管理器

在任何编程语言中,正确和公平地使用资源都是至关重要的。文件句柄和数据库连接是许多例子中的常见做法,在处理对象后没有及时释放资源。如果资源根本未释放,最终会导致称为内存泄漏的情况,并可能影响系统性能,最终可能导致系统崩溃。

为了解决这种内存泄漏和及时释放资源的问题,Python 提出了上下文管理器的概念。上下文管理器旨在精确地保留和释放资源。当使用with关键字与上下文管理器一起使用时,with关键字后面的语句应返回一个对象,该对象必须实现上下文管理协议。该协议要求返回的对象实现两个特殊方法。这些特殊方法如下:

  • .__enter__(): 此方法与with关键字一起调用,用于保留with关键字后面的语句所需的资源。

  • .__exit__(): 此方法在执行with块之后被调用,用于释放在.__enter__()方法中保留的资源。

例如,当使用上下文管理器with语句(块)打开文件时,不需要关闭文件。open语句将返回文件句柄对象,该对象已经实现了上下文管理协议,文件将在with块执行完成后自动关闭。以下是一个使用上下文管理器编写和读取文件的代码示例的修订版:

#contextmgr1.py
with open("myfile.txt",'w') as f1:
    f1.write("This is a sample file\n")
    lines = ["This is a test data\n", "in two lines\n"]
    f1.writelines(lines)
with open("myfile.txt",'r') as f2:
    for line in f2.readlines():
        print(line)

使用上下文管理器的代码简单易读。使用上下文管理器是打开和操作文件的一种推荐方法。

操作多个文件

Python 支持同时打开和操作多个文件。我们可以以不同的模式打开这些文件并对它们进行操作。文件的数量没有限制。我们可以使用以下示例代码以读取模式打开两个文件,并按任意顺序访问它们:

1.txt
This is a sample file 1
This is a test data 1
2.txt
This is a sample file 2
This is a test data 2
#multifilesread1.py
with open("1.txt") as file1, open("2.txt") as file2:
    print(file2.readline())
    print(file1.readline())

我们还可以使用这种多文件操作选项从一个文件读取并写入到另一个文件。以下是将内容从一个文件传输到另一个文件的示例代码:

#multifilesread2.py
with open("1.txt",'r') as file1, open("3.txt",'w') as file2:
   for line in file1.readlines():
     file2.write(line)

Python 还有一个更优雅的解决方案来使用fileinput模块操作多个文件。此模块的输入函数可以接受多个文件的列表,然后将所有这些文件视为单个输入。以下是一个使用fileinput模块和两个输入文件1.txt2.txt的示例代码:

#multifilesread1.py
import fileinput
with fileinput.input(files = ("1.txt",'2.txt')) as f:
    for line in f:
        print(f.filename())
        print(line)

使用这种方法,我们得到一个可以依次操作多个文件的文件句柄。接下来,我们将讨论 Python 中的错误和异常处理。

处理错误和异常

在 Python 中,可能存在许多类型的错误。最常见的一种与程序的语法相关,通常被称为语法错误。在许多情况下,错误会在程序执行过程中被报告。这类错误被称为运行时错误。我们程序中可以处理的运行时错误被称为异常。本节将重点介绍如何处理运行时错误或异常。在介绍错误处理之前,我们将简要介绍以下最常见的运行时错误:

  • IndexError: 当程序尝试访问一个无效索引(内存中的位置)处的项时,将发生此错误。

  • ModuleNotFoundError: 当在系统路径中找不到指定的模块时,将抛出此错误。

  • ZeroDivisionError: 当程序尝试将一个数除以零时,将抛出此错误。

  • KeyError: 当程序尝试使用无效的键从一个字典中获取值时,将发生此错误。

  • StopIteration: 当__next__方法在容器中找不到更多项时,将抛出此错误。

  • TypeError: 当程序尝试对一个不适当的类型的对象应用操作时,将发生此错误。

Python 的官方文档中提供了完整的错误列表。在接下来的小节中,我们将讨论如何使用 Python 中的适当构造来处理错误,有时也称为异常。

在 Python 中处理异常

当运行时错误发生时,程序可能会突然终止,并可能损坏系统资源,如损坏文件和数据库表。这就是为什么错误或异常处理是编写任何语言中健壮程序的关键组成部分之一。其思路是预测运行时错误可能会发生,如果发生此类错误,我们的程序将如何响应该特定错误。

与许多其他语言一样,Python 使用tryexcept关键字。这两个关键字后面跟着要执行的单独的代码块。try代码块是一组常规语句,我们预计其中可能发生错误。只有当try代码块中发生错误时,except代码块才会执行。下面是使用tryexcept代码块编写 Python 代码的语法:

try:
    #a series of statements
except:
    #statements to be executed if there is an error in \
     try block

如果我们预测到特定的错误类型或多个错误类型,我们可以定义一个带有错误名称的except代码块,并且可以根据需要添加任意多的except代码块。这样的命名except代码块仅在try代码块中引发命名异常时才会执行。使用except代码块语句,我们还可以添加一个as语句来将异常对象存储为在try代码块中引发的变量。在下一个代码示例中的try代码块有许多可能的运行时错误,这就是为什么它有多个except代码块:

#exception1.py
try:
    print (x)
    x = 5
    y = 0
    z = x /y
    print('x'+ y)
except NameError as e:
    print(e)
except ZeroDivisionError:
    print("Division by 0 is not allowed")
except Exception as e:
    print("An error occured")
    print(e)

为了更好地说明except代码块的使用,我们添加了多个except代码块,下面将进行解释:

  • try代码块尝试访问一个未定义的变量。在我们的代码示例中,当解释器尝试执行print(x)语句时,此代码块将被执行。此外,我们给异常对象命名为e,并使用print语句获取与该错误类型相关的官方错误详情。

  • z = x/y 和 y = 0. 为了执行此代码块,我们首先需要修复NameError代码块。

  • except代码块,表示如果与前面的两个except代码块不匹配,则此代码块将被执行。最后的语句print('x'+ y)也将引发类型为TypeError的错误,并将由此代码块处理。由于我们在此代码块中没有收到任何特定的异常类型,我们可以使用Exception关键字将异常对象存储在变量中。

注意,一旦在 try 块中的任何语句中发生错误,其余的语句将被忽略,并且控制流将转到其中一个 except 块。在我们的代码示例中,我们需要首先修复 NameError 错误,才能看到下一层的异常,依此类推。我们在示例中添加了三种不同类型的错误来演示如何为同一个 try 块定义多个 except 块。except 块的顺序很重要,因为必须首先定义具有错误名称的更具体的 except 块,并且没有指定错误名称的 except 块必须始终放在最后。

下图显示了所有的异常处理块:

图 4.1 – Python 中不同的异常处理块

图 4.1 – Python 中不同的异常处理块

如前图所示,除了 tryexcept 块之外,Python 还支持 elsefinally 块以增强错误处理功能。如果没有在 try 块中引发错误,则执行 else 块。此块中的代码将按正常方式执行,并且如果此块内发生任何错误,则不会抛出异常。如果需要,可以在 else 块内添加嵌套的 tryexcept 块。请注意,此块是可选的。

finally 块无论在 try 块中是否有错误都会被执行。finally 块内的代码执行时不会进行任何异常处理机制。此块主要用于通过关闭连接或打开的文件来释放资源。尽管这是一个可选块,但强烈建议实现此块。

接下来,我们将通过代码示例来查看这些块的使用。在这个例子中,我们将在 try 块中打开一个新文件进行写入。如果在打开文件时发生错误,将会抛出异常,并且我们将使用 except 块中的 print 语句将错误详情发送到控制台。如果没有发生错误,我们将执行 else 块中的代码,该代码将向文件写入一些文本。在两种情况下(错误或无错误),我们将在 finally 块中关闭文件。完整的示例代码如下:

#exception2.py
try:
    f = open("abc.txt", "w")
except Exception as e:
    print("Error:" + e)
else:
    f.write("Hello World")
    f.write("End")
finally:
    f.close()

我们已经广泛地介绍了如何在 Python 中处理异常。接下来,我们将讨论如何从 Python 代码中引发异常。

引发异常

当运行时发生错误时,Python 解释器会引发异常或错误。如果出现可能导致我们得到不良输出或如果我们继续执行程序则可能导致程序崩溃的条件,我们也可以自己引发错误或异常。引发错误或异常将提供程序优雅退出的方式。

可以使用 raise 关键字将异常(对象)抛给调用者。异常可以是以下类型之一:

  • 内置异常

  • 自定义异常

  • 一个通用的 Exception 对象

在下一个代码示例中,我们将调用一个简单的函数来计算平方根,并将其实现为如果输入参数不是一个有效的正数则抛出异常:

#exception3.py
import math
def sqrt(num):
    if not isinstance(num, (int, float)) :
        raise TypeError("only numbers are allowed")
    if num < 0:
        raise Exception ("Negative number not supported")
    return math.sqrt(num)
if __name__ == "__main__":
    try:
        print(sqrt(9))
        print(sqrt('a'))
        print (sqrt(-9))
    except Exception as e:
        print(e)

在这个代码示例中,当传递给 sqrt 函数的数字不是数字时,我们通过创建 TypeError 类的新实例来抛出一个内置异常。当传递的数字小于 0 时,我们也抛出一个通用异常。在这两种情况下,我们都向其构造函数传递了我们的自定义文本。在下一节中,我们将研究如何定义我们自己的自定义异常并将其抛给调用者。

定义自定义异常

在 Python 中,我们可以通过创建一个新的类来定义自己的自定义异常,这个类必须从内置的 Exception 类或其子类派生。为了说明这个概念,我们将通过定义两个自定义异常类来修改之前的示例,以替换内置的 TypeErrorException 错误类型。新的自定义异常类将派生自 TypeErrorException 类。以下是带有自定义异常的示例代码供参考:

#exception4.py
import math
class NumTypeError(TypeError):
    pass
class NegativeNumError(Exception):
    def __init__(self):
        super().__init__("Negative number not supported")
def sqrt(num):
    if not isinstance(num, (int, float)) :
        raise NumTypeError("only numbers are allowed")
    if num < 0:
        raise NegativeNumError
    return math.sqrt(num)
if __name__ == "__main__":
    try:
        print(sqrt(9))
        print(sqrt('a'))
        print (sqrt(-9))
    except NumTypeError as e:
        print(e)
    except NegativeNumError as e:
        print(e)

在这个代码示例中,NumTypeError 类是从 TypeError 类派生出来的,我们在这个类中没有添加任何内容。NegativeNumError 类是从 Exception 类继承的,我们重写了它的构造函数,并在构造函数中为这个异常添加了一个自定义消息。当我们在这 sqrt() 函数中抛出这些自定义异常时,我们不会用 NegativeNumError 异常类传递任何文本。当我们使用主程序时,我们会通过 print (e) 语句得到消息,因为我们已经将其设置为类定义的一部分。

在本节中,我们介绍了如何使用 tryexcept 块处理内置错误类型,如何定义自定义异常,以及如何声明性地抛出异常。在下一节中,我们将介绍 Python 中的日志记录。

使用 Python 日志模块

日志记录是任何合理规模的应用程序的基本要求。日志记录不仅有助于调试和故障排除,还能深入了解应用程序内部问题的细节。日志记录的一些优点如下:

  • 调试代码,特别是诊断应用程序失败或崩溃的原因和时间

  • 诊断异常的应用程序行为

  • 为监管或法律合规性提供审计数据

  • 识别用户行为和恶意尝试访问未经授权的资源

在讨论任何日志记录的实际例子之前,我们首先将讨论 Python 日志系统的关键组件。

介绍核心日志组件

以下组件对于在 Python 中设置应用程序的日志记录是基本的:

  • 日志记录器

  • 日志级别

  • 日志格式化器

  • 日志处理器

Python 日志系统的高级架构可以总结如下:

图 4.2 – Python 中的日志组件

图 4.2 – Python 中的日志组件

这些组件将在以下子节中详细讨论。

日志记录器

日志记录器是 Python 日志系统的入口点。它是应用程序程序员的接口。Python 中的 Logger 类提供了多种方法来以不同的优先级记录消息。我们将在本节后面通过代码示例研究 Logger 类的方法。

应用程序与使用日志配置(如日志级别)设置的 Logger 实例交互。在接收到日志事件后,Logger 实例选择一个或多个合适的日志处理器并将事件委托给处理器。每个处理器通常设计用于特定的输出目标。处理器在应用过滤和格式化后,将消息发送到目标输出。

日志级别

对于日志系统中的所有事件和消息,优先级并不相同。例如,关于错误的日志比警告消息更紧急。日志级别是设置不同日志事件不同优先级的一种方式。Python 中定义了六个级别。每个级别都与一个表示严重性的整数值相关联。这些级别是 NOTSETDEBUGINFOWARNINGERRORCRITICAL。以下是对它们的总结:

图 4.3 – Python 中的日志级别

图 4.3 – Python 中的日志级别

日志格式化

日志格式化组件有助于改进消息的格式,这对于保持一致性和便于人类及机器阅读非常重要。日志格式化组件还会向消息添加额外的上下文信息,例如时间、模块名称、行号、线程和进程,这对于调试目的非常有用。以下是一个示例格式化表达式:

"%(asctime)s — %(name)s — %(levelname)s — %(funcName)s:%(lineno)d — %(message)s"

当使用这样的格式化表达式时,级别为 INFO 的日志消息 hello Geeks 将显示得类似于以下控制台输出:

2021-06-10 19:20:10,864 - a.b.c - INFO - <module name>:10 - hello Geeks

日志处理器

日志处理器的角色是将日志数据写入适当的目的地,这可以是控制台、文件,甚至是电子邮件。Python 中提供了许多内置的日志处理器类型。以下介绍一些流行的处理器:

  • StreamHandler 用于在控制台上显示日志

  • FileHandler 用于将日志写入文件

  • SMTPHandler 用于将日志发送到电子邮件

  • SocketHandler 用于将日志发送到网络套接字

  • SyslogHandler 用于将日志发送到本地或远程 Unix 系统日志服务器

  • HTTPHandler 用于通过 GETPOST 方法将日志发送到 Web 服务器

日志处理器使用日志格式化器向日志添加更多上下文信息,并使用日志级别来过滤日志数据。

与日志模块一起工作

在本节中,我们将通过代码示例讨论如何使用 logging 模块。我们将从基本的日志选项开始,并逐步将其提升到高级水平。

使用默认的日志记录器

在不创建任何日志类实例的情况下,Python 中已经有一个默认的日志记录器可用。默认日志记录器,也称为logging模块,使用其方法来调度日志事件。下面的代码片段展示了使用根日志记录器来捕获日志事件:

#logging1.py
import logging
logging.debug("This is a debug message")
logging.warning("This is a warning message")
logging.info("This is an info message")

debugwarninginfo方法用于根据它们的严重性将日志事件调度到日志记录器。此日志记录器的默认日志级别设置为WARNING,默认输出设置为stderr,这意味着所有消息都只会在控制台或终端上显示。此设置将阻止DEBUGINFO消息在控制台输出中显示,如下所示:

WARNING:root:This is a warning message

可以通过在import语句之后添加以下行来更改根日志记录器的级别:

logging.basicConfig(level=logging.DEBUG)

在将日志级别更改为DEBUG后,控制台输出将现在显示所有日志消息:

DEBUG:root:This is a debug message
WARNING:root:This is a warning message
INFO:root:This is an info message

虽然我们在本小节中讨论了默认或根日志记录器,但不建议除了基本日志记录目的之外使用它。作为一个最佳实践,我们应该创建一个新的具有名称的日志记录器,我们将在下一个代码示例中讨论。

使用命名日志记录器

我们可以创建一个具有自己名称的单独日志记录器,可能还有自己的日志级别、处理程序和格式化程序。下面的代码片段是创建具有自定义名称的日志记录器并使用与根日志记录器不同的日志级别的示例:

#logging2.py
import logging
logger1 = logging.getLogger("my_logger")
logging.basicConfig()
logger1.setLevel(logging.INFO)
logger1.warning("This is a warning message")
logger1.info("This is a info message")
logger1.debug("This is a debug message")
logging.info("This is an info message")

当我们使用getLogger方法通过字符串名称或使用模块名称(通过使用__name__全局变量)创建日志记录器实例时,对于每个名称只管理一个实例。这意味着如果我们尝试在任何应用程序的部分使用具有相同名称的getLogger方法,Python 解释器将检查是否已经为该名称创建了一个实例。如果已经创建了一个实例,它将返回相同的实例。

在创建日志记录器实例后,我们需要调用根日志记录器(basicConfig())来为我们自己的日志记录器提供一个处理程序和格式化程序。如果没有任何处理程序配置,我们将得到一个内部处理程序作为最后的手段,这将只输出未格式化的消息,并且日志级别将默认为WARNING,而不管我们为日志记录器设置的日志级别是什么。下面的代码片段的控制台输出如下,符合预期:

WARNING:my_logger:This is a warning message
INFO:my_logger:This is a info message

还需要注意以下几点:

  • 我们将日志记录器的日志级别设置为INFO,这样我们就可以记录warninginfo消息,但不能记录调试消息。

  • 当我们使用根日志记录器(通过使用logging实例)时,我们无法发送出info消息。这是因为根日志记录器仍在使用默认的日志级别,即WARNING

使用具有内置处理程序和自定义格式化程序的日志记录器

我们可以使用内置的处理器创建一个日志记录器对象,但带有自定义的格式化器。在这种情况下,处理器对象可以使用自定义的格式化器对象,并且在我们开始使用日志记录器进行任何日志事件之前,可以将处理器对象添加到日志记录器对象中作为其处理器。以下是一个代码片段,说明如何程序化地创建处理器和格式化器,然后将处理器添加到日志记录器中:

#logging3.py
import logging
logger = logging.getLogger('my_logger')
my_handler = logging.StreamHandler()
my_formatter = logging.Formatter('%(asctime)s - '\
                  '%(name)s - %(levelname)s - %(message)s')
my_handler.setFormatter(my_formatter)
logger.addHandler(my_handler)
logger.setLevel(logging.INFO)
logger.warning("This is a warning message")
logger.info("This is an info message")
logger.debug("This is a debug message")

我们也可以通过使用basicConfig方法并传递适当的参数来创建具有相同设置的日志记录器。下一个代码片段是logging3.py的修订版,其中包含了basicConfig设置:

#logging3A.py
import logging
logger = logging.getLogger('my_logger')
logging.basicConfig(handlers=[logging.StreamHandler()],
                    format="%(asctime)s - %(name)s - "
                           "%(levelname)s - %(message)s",
                    level=logging.INFO)
logger.warning("This is a warning message")
logger.info("This is an info message")
logger.debug("This is a debug message")

到目前为止,我们已经涵盖了使用内置类和对象设置日志记录器的情况。接下来,我们将设置一个具有自定义处理器和格式化器的日志记录器。

使用具有文件处理器的日志记录器

日志处理器将日志消息发送到它们的最终目的地。默认情况下,每个日志记录器都配置为将日志消息发送到与运行程序关联的控制台或终端。但可以通过配置一个具有不同目的地的新的处理器来更改这一点。可以通过使用我们在前一小节中讨论的两种方法之一来创建一个文件处理器。在本节中,我们将使用第三种方法,通过将文件名作为属性传递给basicConfig方法来自动创建一个文件处理器。这将在下一个代码片段中展示:

 #logging4.py
import logging
logging.basicConfig(filename='logs/logging4.log' 
                    ,level=logging.DEBUG)
logger = logging.getLogger('my_logger')
logger.setLevel(logging.INFO)
logger.warning("This is a warning message")
logger.info("This is a info message")
logger.debug("This is a debug message")

这将生成日志消息,这些消息将按照basicConfig方法指定的文件和日志级别(设置为INFO)生成。

使用程序化方式使用具有多个处理器的日志记录器

创建具有多个处理器的日志记录器相当简单,可以通过使用basicConfig方法或手动将处理器附加到日志记录器来实现。为了说明目的,我们将修订我们的代码示例logging3.py以执行以下操作:

  1. 我们将创建两个处理器(一个用于控制台输出,一个用于文件输出),它们是streamHandlerfileHandler类的实例。

  2. 我们将创建两个单独的格式化器,每个处理器一个。我们将不包括控制台处理器的格式化器中的时间信息。

  3. 我们将为两个处理器设置不同的日志级别。重要的是要理解,处理器级别的日志级别不能覆盖根级别的处理器。

下面是完整的代码示例:

#logging5.py
import logging
logger = logging.getLogger('my_logger')
logger.setLevel(logging.DEBUG)
console_handler = logging.StreamHandler()
file_handler = logging.FileHandler("logs/logging5.log")
#setting logging levels at the handler level
console_handler.setLevel(logging.DEBUG)
file_handler.setLevel(logging.INFO)
#creating separate formatter for two handlers
console_formatter = logging.Formatter(
                  '%(name)s - %(levelname)s - %(message)s')
file_formatter = logging.Formatter('%(asctime)s - '
                  '%(name)s - %(levelname)s - %(message)s')
#adding formatters to the handler
console_handler.setFormatter(console_formatter)
file_handler.setFormatter(file_formatter)
#adding handlers to the logger
logger.addHandler(console_handler)
logger.addHandler(file_handler)
logger.error("This is an error message")
logger.warning("This is a warning message")
logger.info("This is an info message")
logger.debug("This is a debug message")

尽管我们为两个处理器设置了不同的日志级别,分别是INFODEBUG,但它们只有在日志记录器的日志级别较低(默认为WARNING)时才会生效。这就是为什么我们必须在程序开始时将日志级别设置为DEBUG。处理器的日志级别可以是DEBUG或任何更高的级别。这是一个在设计应用程序的日志策略时需要考虑的非常重要的点。

在本节中共享的代码示例中,我们基本上是程序化地配置了日志记录器。在下一节中,我们将讨论如何通过配置文件配置日志记录器。

使用配置文件配置具有多个处理器的日志记录器

程序化设置日志记录器很有吸引力,但在生产环境中并不实用。在生产环境中,我们必须以与开发设置不同的方式设置日志记录器配置,有时我们还需要提高日志级别来调试仅在实时环境中遇到的故障。这就是为什么我们提供了通过文件提供日志记录器配置的选项,该文件可以根据目标环境轻松更改。日志记录器的配置文件可以使用.conf文件编写。为了说明目的,我们将使用 YAML 文件演示日志记录器配置,这与我们在上一节中程序化实现的结果完全相同。完整的 YAML 文件和 Python 代码如下:

以下是一个 YAML 配置文件:

version: 1
formatters:
  console_formatter:
    format: '%(name)s - %(levelname)s - %(message)s'
  file_formatter:
      format: '%(asctime)s - %(name)s - %(levelname)s -         %(message)s'
handlers:
  console_handler:
    class: logging.StreamHandler
    level: DEBUG
    formatter: console_formatter
    stream: ext://sys.stdout
  file_handler:
    class: logging.FileHandler
    level: INFO
    formatter: file_formatter
    filename: logs/logging6.log
loggers:
  my_logger:
    level: DEBUG
    handlers: [console_handler, file_handler]
    propagate: no
root:
  level: ERROR
  handlers: [console_handler]

以下是一个使用 YAML 文件配置日志记录器的 Python 程序:

#logging6.py
import logging
import logging.config
import yaml
with open('logging6.conf.yaml', 'r') as f:
    config = yaml.safe_load(f.read())
    logging.config.dictConfig(config)
logger = logging.getLogger('my_logger')
logger.error("This is an error message")
logger.warning("This is a warning message")
logger.info("This is a info message")
logger.debug("This is a debug message")

要从文件中加载配置,我们使用了dictConfig方法而不是basicConfig方法。基于 YAML 的日志配置结果与我们使用 Python 语句实现的结果完全相同。对于功能齐全的日志记录器,还有其他一些额外的配置选项可用。

在本节中,我们介绍了为应用程序配置一个或多个日志记录器实例的不同场景。接下来,我们将讨论应该记录哪些事件以及不应该记录哪些事件。

记录什么以及不记录什么

总是会有关于我们应该记录什么信息以及不应该记录什么信息的争论。作为最佳实践,以下信息对于记录来说很重要:

  • 应用程序应该记录所有错误和异常,最合适的方式是在源模块中记录这些事件。

  • 可以将使用替代代码流处理的异常记录为警告。

  • 为了调试目的,记录函数的进入和退出是有用的信息。

  • 记录代码中的决策点也是有用的,因为这有助于故障排除。

  • 用户的活动和动作,特别是与应用程序中某些资源和方法访问相关的活动,对于安全和审计目的来说,记录这些信息很重要。

在记录消息时,上下文信息也很重要,包括时间、记录器名称、模块名称、函数名称、行号、记录级别等。这些信息对于识别根本原因分析至关重要。

关于这个话题的后续讨论是关于不应该捕获哪些信息进行记录。我们不应该记录任何敏感信息,例如用户 ID、电子邮件地址、密码以及任何私人敏感数据。我们还应该避免记录任何个人和商业记录数据,例如健康记录、政府颁发的文件细节和组织细节。

摘要

在本章中,我们讨论了需要使用高级 Python 模块和库的各种主题。我们首先刷新了关于 Python 中数据容器知识的记忆。接下来,我们学习了如何使用和构建可迭代对象的迭代器。我们还介绍了生成器,它比迭代器更高效、更容易构建和使用。我们讨论了如何打开和读取文件,以及如何写入文件,随后讨论了与文件一起使用上下文管理器。在下一个主题中,我们讨论了如何在 Python 中处理错误和异常,如何通过编程引发异常,以及如何定义自定义异常。异常处理是任何优秀的 Python 应用程序的基础。在最后一节中,我们介绍了如何使用不同的处理器和格式化选项配置 Python 中的日志框架。

在阅读完这一章后,你现在知道如何构建自己的迭代器,设计生成器函数以迭代任何可迭代对象,以及如何在 Python 中处理文件、错误和异常。你还学习了如何使用一个或多个处理器设置日志记录器,以使用不同的日志级别来管理应用程序的日志记录。本章中你学到的技能对于构建任何开源或商业应用程序都是关键。

在下一章中,我们将把重点转向如何构建和自动化单元测试和集成测试。

问题

  1. 列表和元组之间的区别是什么?

  2. 在使用上下文管理器时,哪个 Python 语句总是会使用?

  3. try-except块中使用else语句有什么用?

  4. 为什么生成器比迭代器更好用?

  5. 日志记录中使用多个处理器有什么用?

进一步阅读

  • Luciano Ramalho 著的《流畅的 Python

  • John Hunt 著的《Python 3 编程高级指南

  • Doug Hellmann 著的《Python 3 标准库实例教程

  • Python 3.7.10 文档》(docs.python.org/3.7/))

  • 想要了解更多关于配置日志记录器的选项,您可以参考官方 Python 文档,链接为docs.python.org/3/library/logging.config.html

答案

  1. 列表是一个可变对象,而元组是不可变的。这意味着我们可以在创建列表后更新它。对于元组来说,这并不成立。

  2. with语句与上下文管理器一起使用。

  3. 只有当try块中的代码执行没有错误时,才会执行else块。一旦在try块中成功执行核心功能而没有问题,就可以在else块中编写后续操作。

  4. 与迭代器相比,生成器在内存效率上更高,编程也更容易。生成器函数自动提供一个iterator实例和next函数的实现。

  5. 使用多个处理器很常见,因为通常一个处理器专注于一种目标类型。如果我们需要将日志事件发送到多个目的地,并且可能具有不同的优先级级别,我们将需要多个处理器。此外,如果我们需要将消息记录到多个文件中,并且具有不同的日志级别,我们可以创建不同的文件处理器来与多个文件协调。

第五章:第五章:使用 Python 进行测试和自动化

软件测试是根据用户要求或期望的规范验证应用程序或程序的过程,并评估软件的可扩展性和优化目标。以真实用户验证软件需要很长时间,并且不是人力资源的有效利用。此外,测试不仅只进行一次或两次,而是一个作为软件开发一部分的持续过程。为了应对这种情况,建议对所有类型的测试进行自动化。测试自动化是一组程序,用于使用不同场景作为输入来验证应用程序的行为。对于专业的软件开发环境,每次将源代码更新(也称为提交操作)到中央存储库时,都必须执行自动化测试。

在本章中,我们将研究自动化的不同方法,然后查看适用于 Python 应用程序的测试框架和库的不同类型。然后,我们将专注于单元测试,并探讨在 Python 中实现单元测试的不同方法。接下来,我们将研究测试驱动开发TDD)的有用性及其正确的实现方式。最后,我们将专注于自动化的持续集成CI),并探讨其稳健和高效实施所面临的挑战。本章将帮助您理解 Python 在各个级别自动化的概念。

本章将涵盖以下主题:

  • 理解各种测试级别

  • 使用 Python 测试框架

  • 执行 TDD

  • 介绍自动化 CI

在本章结束时,您不仅将了解不同类型的测试自动化,还将能够使用两个流行的测试框架之一编写单元测试。

技术要求

这是本章的技术要求:

  • 您需要在您的计算机上安装 Python 3.7 或更高版本。

  • 您需要在 Test PyPI 上注册一个账户,并在您的账户下创建一个应用程序编程接口API)令牌。

本章的示例代码可在github.com/PacktPublishing/Python-for-Geeks/tree/master/Chapter05找到。

理解各种测试级别

根据应用程序类型、其复杂程度以及正在开发该应用程序的团队的角色,在各个级别执行测试。不同的测试级别包括以下内容:

  • 单元测试

  • 集成测试

  • 系统测试

  • 接受测试

这些不同的测试级别按照以下顺序应用:

![图 5.1 – 软件开发中的不同测试级别img/B17189_05_01.jpg

图 5.1 – 软件开发中的不同测试级别

这些测试级别将在下一小节中描述。

单元测试

单元测试是一种关注最小可能单元级别的测试类型。一个单元对应于代码中的一个单元,可以是模块中的一个函数或类中的一个方法,也可以是应用程序中的一个模块。单元测试在隔离的情况下执行单个单元的代码,并验证代码是否按预期工作。单元测试是开发者用于在代码开发的早期阶段识别错误,并在开发过程的第一轮迭代中修复它们的技巧。在 Python 中,单元测试主要针对特定的类或模块,而不涉及依赖项。

单元测试是由应用程序开发者开发的,可以在任何时间进行。单元测试是一种pyunitunittest)、pytestdoctestnose以及其他几种测试方式。

集成测试

集成测试是关于以组的形式集体测试程序中的单个单元。这种测试背后的理念是将应用程序的不同功能或模块组合在一起进行测试,以验证组件之间的接口和数据交换。

集成测试通常由测试人员而不是开发者执行。这种测试在单元测试过程之后开始,测试的重点是识别当不同的模块或功能一起使用时出现的集成问题。在某些情况下,集成测试可能需要外部资源或数据,这些资源或数据在开发环境中可能无法提供。这种限制可以通过使用模拟测试来管理,模拟测试提供外部或内部依赖的替代模拟对象。模拟对象模拟真实依赖的行为。模拟测试的例子可以是发送电子邮件或使用信用卡进行支付。

集成测试是一种黑盒测试。用于集成测试的库和工具与单元测试中使用的几乎相同,区别在于测试的边界被进一步扩展,以包括单个测试中的多个单元。

系统测试

系统测试的边界进一步扩展到系统级别,这可能是一个完整的模块或应用程序。这种测试从端到端E2E)的角度验证应用程序的功能。

系统测试也是由测试人员开发的,但完成集成测试过程之后。我们可以这样说,集成测试是系统测试的先决条件;否则,在执行系统测试时,将会重复大量的工作。系统测试可以识别潜在的问题,但无法确定问题的具体位置。问题的确切根本原因通常由集成测试甚至通过添加更多的单元测试来确定。

系统测试也是一种黑盒测试,可以利用与集成测试相同的库。

接受测试

接受测试是在接受软件用于日常使用之前的最终用户测试。接受测试通常不是自动化测试的候选者,但在应用用户必须通过 API 与产品交互的情况下,使用自动化进行接受测试是值得的。这种测试也称为用户接受测试UAT)。这种类型的测试很容易与系统测试混淆,但它不同之处在于它确保了从真实用户的角度来看应用程序的可使用性。还有另外两种接受测试:工厂接受测试FAT)和运营接受测试OAT)。前者从硬件角度来看更为流行,后者由负责在生产环境中使用产品的运营团队执行。

此外,我们还听说过alphabeta测试。这些也是用户级别的测试方法,并不适用于测试自动化。Alpha 测试由开发人员和内部员工执行,以模拟实际用户的行为。Beta 测试由客户或实际用户执行,以便在宣布软件的通用可用性GA)之前提供早期反馈。

在软件开发中,我们也使用术语回归测试。这基本上是在我们更改源代码或任何内部或外部依赖项更改时执行测试。这种做法确保我们的产品在更改之前以相同的方式运行。由于回归测试会重复多次,因此自动化测试对于这种类型的测试是必须的。

在下一节中,我们将探讨如何使用 Python 中的测试框架构建测试用例。

与 Python 测试框架一起工作

Python 提供了用于测试自动化的标准库和第三方库。最流行的框架在此列出:

  • pytest

  • unittest

  • doctest

  • nose

这些框架可以用于单元测试,以及集成和系统测试。在本节中,我们将评估这些框架中的两个:unittest,它是 Python 标准库的一部分,以及pytest,它作为一个外部库可用。评估的重点将在于使用这两个框架构建测试用例(主要是单元测试),尽管也可以使用相同的库和设计模式构建集成和系统测试。

在我们开始编写任何测试用例之前,了解什么是测试用例非常重要。在本章和本书的上下文中,我们可以将测试用例定义为根据预期结果验证编程代码特定行为结果的一种方式。测试用例的开发可以分为以下四个阶段:

  1. 准备:这是一个为我们的测试用例准备环境的阶段。这不包括任何动作或验证步骤。在测试自动化社区中,这个阶段更常见地被称为准备测试工具

  2. 执行:这是触发我们想要测试的系统的动作阶段。这个动作阶段导致系统行为发生变化,而系统状态的变化是我们想要评估以进行验证目的的东西。请注意,在这个阶段我们不会验证任何东西。

  3. 断言:在这个阶段,我们评估执行阶段的结果,并将结果与预期结果进行验证。基于这种验证,测试自动化工具将测试用例标记为失败或通过。在大多数工具中,这种验证是通过内置的断言函数或语句实现的。

  4. 清理:在这个阶段,环境被清理以确保其他测试不受执行阶段引起的状态变化的影响。

测试用例的核心阶段是执行断言准备清理阶段是可选的,但强烈推荐。这两个阶段主要提供软件测试工具。测试工具是一种设备或装置或软件,它提供了一个环境,以一致的方式测试设备或机器或软件。术语测试工具在单元测试和集成测试的上下文中使用。

测试框架或库提供了辅助方法或语句,以便方便地实现这些阶段。在下一节中,我们将评估unittestpytest框架以下主题:

  • 如何构建执行和断言阶段的基线测试用例

  • 如何使用测试工具构建测试用例

  • 如何构建异常和错误验证的测试用例

  • 如何批量运行测试用例

  • 如何在执行中包含和排除测试用例

这些主题不仅涵盖了各种测试用例的开发,还包括执行它们的不同方式。我们将从unittest框架开始评估。

与 unittest 框架一起工作

在开始使用unittest框架或库讨论实际示例之前,介绍一些与单元测试和,特别是与unittest库相关的术语和传统方法名称是很重要的。这些术语在所有测试框架中或多或少都会使用,下面概述如下:

  • 测试用例:测试或测试用例或测试方法是一组基于执行应用代码单元后的当前条件与执行后的条件比较的代码指令。

  • 测试套件:测试套件是一组可能具有共同先决条件、初始化步骤,也许还有相同的清理步骤的测试用例。这促进了测试自动化代码的可重用性并减少了执行时间。

  • 测试运行器: 这是一个 Python 应用程序,它执行测试(单元测试),验证代码中定义的所有断言,并将结果作为成功或失败返回给我们。

  • 设置: 这是测试套件中的一个特殊方法,将在每个测试用例之前执行。

  • setupClass: 这是测试套件中的一个特殊方法,它将在测试套件中测试执行开始时仅执行一次。

  • teardown: 这是测试套件中的另一个特殊方法,在每次测试完成后执行,无论测试是否通过。

  • teardownClass: 这是测试套件中的另一个特殊方法,当套件中的所有测试完成后,将仅执行一次。

要使用unittest库编写测试用例,我们必须将测试用例实现为从TestCase基类继承的类的实例方法。TestCase类包含一些方法,可以方便地编写和执行测试用例。这些方法分为三类,将在下面讨论:

  • setUp, tearDown, setupClass, teardownClass, run, skipTest, skipTestIf, subTest, 和 debug。这些测试由测试运行器用于在测试用例之前或之后执行一段代码,运行一组测试用例,运行测试,跳过测试,或作为子测试运行任何代码块。在我们的测试用例实现类中,我们可以重写这些方法。这些方法的详细说明作为 Python 文档的一部分,可在docs.python.org/3/library/unittest.html找到。

  • 验证方法(断言方法):这些方法用于实现测试用例以检查成功或失败条件,并自动报告测试用例的成功或失败。这些方法的名称通常以assert前缀开头。断言方法的列表非常长。我们在此提供一些常用断言方法的示例:图 5.2 – TestCase 类断言方法的几个示例

图 5.2 – TestCase 类断言方法的几个示例

  • failureException: 此属性提供由测试方法引发的异常。此异常可以用作超类来定义具有附加信息的自定义失败异常。

    b) longMessage: 此属性确定如何处理使用assert方法传递的自定义消息。如果此属性的值设置为True,则消息将附加到标准失败消息。如果此属性设置为false,则自定义消息将替换标准消息。

    c) countTestCases(): 此方法返回附加到测试对象上的测试数量。

    d) shortDescription(): 此方法返回测试方法的描述,如果有任何描述添加,则使用文档字符串。

我们在本节中回顾了TestCase类的主要方法。在下一节中,我们将探讨如何使用unittest为示例模块或应用程序构建单元测试。

使用基类TestCase构建测试用例

unittest库是一个受 JUnit 框架(Java 社区中流行的测试框架)高度启发的标准 Python 测试框架。单元测试以单独的 Python 文件编写,并建议将这些文件作为主项目的一部分。正如我们在第二章使用模块化处理复杂项目中的构建包部分所讨论的,Python 打包权威机构PyPA)的指南建议在为项目或库构建包时为测试保留一个单独的文件夹。在本节的代码示例中,我们将遵循与这里所示类似的结构:

Project-name
|-- src
|   -- __init__.py
|   -- myadd/myadd.py 
|-- tests
|   -- __init__.py
|   -- tests_myadd/test_myadd1.py 
|   -- tests_myadd/test_myadd2.py
|-- README.md

在我们的第一个代码示例中,我们将为myadd.py模块中的add函数构建一个测试套件,如下所示:

# myadd.py with add two numbers
def add(x, y):
    """This function adds two numbers"""
    return x + y

重要的是要理解,对于同一块代码(在我们的例子中是add函数)可以有多个测试用例。对于add函数,我们通过改变输入参数的值实现了四个测试用例。下面是一个包含四个add函数测试用例的代码示例,如下所示:

#test_myadd1.py test suite for myadd function
import unittest
from myunittest.src.myadd.myadd import add
class MyAddTestSuite(unittest.TestCase):
def test_add1(self):
    """ test case to validate two positive numbers"""
    self.assertEqual(15, add(10 , 5), "should be 15")
def test_add2(self):
    """ test case to validate positive and negative \
     numbers"""
    self.assertEqual(5, add(10 , -5), "should be 5")
def test_add3(self):
    """ test case to validate positive and negative \
     numbers"""
    self.assertEqual(-5, add(-10 , 5), "should be -5")
def test_add4(self):
    """ test case to validate two negative numbers"""
    self.assertEqual(-15, add(-10 , -5), "should be -15")
if __name__ == '__main__':
    unittest.main()

接下来将讨论前面测试套件的所有关键点,如下所示:

  • 要使用unittest框架实现单元测试,我们需要导入一个同名的标准库,即unittest

  • 我们需要在测试套件中导入我们想要测试的模块或模块。在这种情况下,我们使用相对导入方法从myadd.py模块中导入了add函数(参见第二章使用模块化处理复杂项目中的导入模块部分,了解更多详情)。

  • 我们将实现一个继承自unittest.Testcase基类的测试套件类。测试用例在子类中实现,在这个例子中是MyAddTestSuite类。unittest.Testcase类的构造函数可以接受一个方法名作为输入,该输入可以用来运行测试用例。默认情况下,已经实现了一个runTest方法,该方法是测试运行器用来执行测试的。在大多数情况下,我们不需要提供自己的方法或重新实现runTest方法。

  • 要实现一个测试用例,我们需要编写一个以test前缀开头并跟一个下划线的函数。这有助于测试运行器查找要执行的测试用例。使用这种命名约定,我们向我们的测试套件中添加了四个方法。

  • 在每个测试用例方法中,我们使用了一个特殊的assertEqual方法,它来自基类。这个方法代表了测试用例的断言阶段,用于决定我们的测试将被宣布为通过或失败。这个方法的第一个参数是单元测试的预期结果,第二个参数是在执行测试代码后得到的值,第三个参数(可选)是在测试失败时报告中的消息。

  • 在测试套件结束时,我们添加了unittest.main方法来触发测试运行器运行runTest方法,这使得在不使用控制台命令的情况下执行测试变得容易。这个main方法(底层是一个TestProgram类)将首先发现所有要执行的测试,然后执行这些测试。

    重要提示

    可以使用如Python -m unittest <测试套件或模块>这样的命令来运行单元测试,但本章提供的代码示例将假设我们正在使用 PyCharm 集成开发环境IDE)来运行测试用例。

接下来,我们将使用测试固定装置构建下一级的测试用例。

使用测试固定装置构建测试用例

我们已经讨论了在执行测试用例前后由测试运行器自动运行的setUptearDown方法。这些方法(以及setUpClasstearDownClass方法)提供了测试固定装置,并且对于有效地实现单元测试非常有用。

首先,我们将修改add函数的实现。在新实现中,我们将这段代码单元作为MyAdd类的一部分。我们还通过在输入参数无效的情况下抛出TypeError异常来处理这种情况。下面是包含新add方法的完整代码片段:

# myadd2.py is a class with add two numbers method
class MyAdd:
    def add(self, x, y):
        """This function adds two numbers"""
        if (not isinstance(x, (int, float))) | \
                (not isinstance(y, (int, float))) :
            raise TypeError("only numbers are allowed")
        return x + y

在上一节中,我们仅使用行为阶段和断言阶段构建了测试用例。在本节中,我们将通过添加setUptearDown方法来修改之前的代码示例。下面是myAdd类的测试套件,如下所示:

#test_myadd2.py test suite for myadd2 class method
import unittest
from myunittest.src.myadd.myadd2 import MyAdd
class MyAddTestSuite(unittest.TestCase):
    def setUp(self):
        self.myadd = MyAdd()
    def tearDown(self):
        del (self.myadd)
    def test_add1(self):
       """ test case to validate two positive numbers"""
       self.assertEqual(15, self.myadd.add(10 , 5), \
        "should be 15")
    def test_add2(self):
        """ test case to validate positive and negative           numbers"""
        self.assertEqual(5, self.myadd.add(10 , -5), \
         "should be 5")
#test_add3 and test_add4 are skipped as they are very \
 same as test_add1 and test_add2

在这个测试套件中,我们添加或更改了以下内容:

  • 我们在setUp方法中创建了一个新的MyAdd类实例,并将其引用保存为实例属性。这意味着在执行任何测试用例之前,我们将创建一个新的MyAdd类实例。这可能不是这个测试套件的理想做法,因为更好的方法可能是使用setUpClass方法,为整个测试套件创建一个MyAdd类的单个实例,但我们这样实现是为了说明目的。

  • 我们还添加了一个tearDown方法。为了演示如何实现它,我们简单地调用了在setUp方法中创建的MyAdd实例的析构函数(使用del函数)。与setUp方法一样,tearDown方法是在每个测试用例之后执行的。如果我们打算使用setUpClass方法,有一个等效的tearDownClass方法。

在下一节中,我们将展示构建测试用例以处理TypeError异常的代码示例。

带有错误处理的测试用例构建

在之前的代码示例中,我们只比较了测试用例的结果与预期结果。我们没有考虑任何异常处理,例如如果将错误类型的参数传递给我们的add函数,我们的程序会有什么行为。单元测试也必须涵盖编程的这些方面。

在下一个代码示例中,我们将构建测试用例以处理来自代码单元的预期错误或异常。对于这个例子,我们将使用相同的add函数,如果参数不是数字,它将抛出TypeError异常。测试用例将通过向add函数传递非数字参数来构建。下一个代码片段显示了测试用例:

#test_myadd3.py test suite for myadd2 class method to validate errors
import unittest
from myunittest.src.myadd.myadd2 import MyAdd
class MyAddTestSuite(unittest.TestCase):
    def setUp(self):
        self.myadd = MyAdd()
    def test_typeerror1(self):
        """ test case to check if we can handle non \
         number input"""
        self.assertRaises(TypeError, self.myadd.add, \
         'a' , -5)
    def test_typeerror2(self):
        """ test case to check if we can handle non \
         number input"""
        self.assertRaises(TypeError, self.myadd.add, \
         'a' , 'b')

在前面的代码片段中,我们在test_add3.py模块中添加了两个额外的测试用例。这些测试用例使用assertRaises方法来验证是否抛出了特定类型的异常。在我们的测试用例中,我们使用单个字母(a)或两个字母(ab)作为两个测试用例的参数。在两种情况下,我们都期望抛出预期的异常(TypeError)。注意assertRaises方法的参数。此方法只期望将方法或函数名称作为第二个参数。方法或函数的参数必须作为assertRaises函数的参数单独传递。

到目前为止,我们已经在单个测试套件下执行了多个测试用例。在下一节中,我们将讨论如何同时运行多个测试套件,使用命令行以及程序化方式。

执行多个测试套件

随着我们为每个代码单元构建测试用例,测试用例的数量(单元测试用例)会迅速增长。使用测试套件的想法是将模块化引入测试用例的开发中。测试套件也使得在添加更多功能到应用程序时维护和扩展测试用例变得更加容易。接下来我们想到的是如何通过主脚本或工作流程来执行多个测试套件。像 Jenkins 这样的 CI 工具提供了这样的功能。像unittestnosepytest这样的测试框架也提供了类似的功能。

在本节中,我们将构建一个简单的计算器应用程序(一个MyCalc类),其中包含addsubtractmultiplydivide方法。稍后,我们将为这个类中的每个方法添加一个测试套件。这样,我们将为这个计算器应用程序添加四个测试套件。目录结构在实现测试套件和测试用例时非常重要。对于这个应用程序,我们将使用以下目录结构:

图 5.3 – mycalc 应用程序及其相关测试套件的目录结构

图 5.3 – mycalc 应用程序及其相关测试套件的目录结构

Python 代码编写在mycalc.py模块中,测试套件文件(test_mycalc*.py)将在下面展示。请注意,我们只展示了每个测试套件中的单个测试用例。实际上,每个测试套件中都会有多个测试用例。我们将从mycalc.py文件中的计算器函数开始,如下所示:

# mycalc.py with add, subtract, multiply and divide functions
class MyCalc:
    def add(self, x, y):
        """This function adds two numbers"""
        return x + y
    def subtract(self, x, y):
        """This function subtracts two numbers"""
        return x - y
    def multiply(self, x, y):
        """This function subtracts two numbers"""
        return x * y
    def divide(self, x, y):
        """This function subtracts two numbers"""
        return x / y

接下来,我们有一个测试套件用于测试test_mycalc_add.py文件中的add函数,如下面的代码片段所示:

# test_mycalc_add.py test suite for add class method
import unittest
from myunittest.src.mycalc.mycalc import MyCalc
class MyCalcAddTestSuite(unittest.TestCase):
    def setUp(self):
        self.calc = MyCalc()
    def test_add(self):
        """ test case to validate two positive numbers"""
        self.assertEqual(15, self.calc.add(10, 5), \
         "should be 15")

接下来,我们有一个测试套件用于测试test_mycalc_subtract.py文件中的subtract函数,如下面的代码片段所示:

#test_mycalc_subtract.py test suite for subtract class method
import unittest
from myunittest.src.mycalc.mycalc import MyCalc
class MyCalcSubtractTestSuite(unittest.TestCase):
    def setUp(self):
        self.calc = MyCalc()
    def test_subtract(self):
        """ test case to validate two positive numbers"""
        self.assertEqual(5, self.calc.subtract(10,5), \
         "should be 5")

接下来,我们有一个测试套件用于测试test_mycalc_multiply.py文件中的multiply函数,如下面的代码片段所示:

#test_mycalc_multiply.py test suite for multiply class method
import unittest
from myunittest.src.mycalc.mycalc import MyCalc
class MyCalcMultiplyTestSuite(unittest.TestCase):
    def setUp(self):
        self.calc = MyCalc()
    def test_multiply(self):
        """ test case to validate two positive numbers"""
        self.assertEqual(50, self.calc.multiply(10, 5), "should           be 50")

接下来,我们有一个测试套件用于测试test_mycalc_divide.py文件中的divide函数,如下面的代码片段所示:

#test_mycalc_divide.py test suite for divide class method
import unittest
from myunittest.src.mycalc.mycalc import MyCalc
class MyCalcDivideTestSuite(unittest.TestCase):
    def setUp(self):
        self.calc = MyCalc()
    def test_divide(self):
        """ test case to validate two positive numbers"""
        self.assertEqual(2, self.calc.divide(10 , 5), \
         "should be 2")

我们已经有了示例应用程序代码和所有四个测试套件的代码。下一个方面是如何一次性执行所有测试套件。一个简单的方法是使用discover关键字。在我们的示例案例中,我们将从项目的顶部运行以下命令来发现并执行tests_mycalc目录中所有四个测试套件中的所有测试用例:

python -m unittest discover myunittest/tests/tests_mycalc

此命令将以递归方式执行,这意味着它还可以发现子目录中的测试用例。其他(可选)参数可以用来选择要执行的测试用例集,具体描述如下:

  • -v:使输出详细。

  • -s:测试用例发现的起始目录。

  • -p:用于搜索测试文件的模式。默认为test*.py,但可以通过此参数更改。

  • -t:这是项目的顶层目录。如果没有指定,起始目录是顶层目录

尽管运行多个测试套件的命令行选项简单且强大,但我们有时需要控制从不同位置的不同测试套件中运行选定测试的方式。这就是通过 Python 代码加载和执行测试用例变得方便的地方。下面的代码片段是一个示例,展示了如何从类名中加载测试套件,在每个套件中查找测试用例,然后使用unittest测试运行器运行它们:

import unittest
from test_mycalc_add import MyCalcAddTestSuite
from test_mycalc_subtract import MyCalcSubtractTestSuite
from test_mycalc_multiply import MyCalcMultiplyTestSuite
from test_mycalc_divide import MyCalcDivideTestSuite
def run_mytests():
    test_classes = [MyCalcAddTestSuite, \
      MyCalcSubtractTestSuite,\
      MyCalcMultiplyTestSuite,MyCalcDivideTestSuite ]
    loader = unittest.TestLoader()
    test_suites = []
    for t_class in test_classes:
        suite = loader.loadTestsFromTestCase(t_class)
        test_suites.append(suite)
    final_suite = unittest.TestSuite(test_suites)
    runner = unittest.TextTestRunner()
    results = runner.run(final_suite)

if __name__ == '__main__':
    run_mytests()

在本节中,我们介绍了使用unittest库构建测试用例。在下一节中,我们将使用pytest库。

与 pytest 框架一起工作

使用unittest库编写的测试用例更容易阅读和管理,尤其是如果你来自使用 JUnit 或其他类似框架的背景。但对于大规模 Python 应用程序,pytest库因其易于实现和使用以及能够扩展以满足复杂测试需求而脱颖而出。在pytest库的情况下,没有要求从任何基类扩展单元测试类;实际上,我们可以编写测试用例而不实现任何类。

pytest是一个开源框架。与unittest框架一样,pytest测试框架可以自动发现测试,如果文件名有test前缀,并且这种发现格式是可以配置的。pytest框架提供的功能与unittest框架提供的单元测试功能相同。在本节中,我们将重点讨论pytest框架中不同或额外的功能。

无基类构建测试用例

为了演示如何使用pytest库编写单元测试用例,我们将通过实现没有类的add函数来修改我们的myadd2.py模块。这个新的add函数将添加两个数字,如果数字没有作为参数传递,则抛出异常。使用pytest框架的测试用例代码如下所示:

# myadd3.py is a class with add two numbers method
def add(self, x, y):
    """This function adds two numbers"""
    if (not isinstance(x, (int, float))) | \
            (not isinstance(y, (int, float))):
        raise TypeError("only numbers are allowed")
    return x + y

接下来将展示测试用例的模块,如下所示:

#test_myadd3.py test suite for myadd function
import pytest
from mypytest.src.myadd3 import add
def test_add1():
    """ test case to validate two positive numbers"""
    assert add(10, 5) == 15"
def test_add2():
    """ test case to validate two positive numbers"""
    assert add(10, -5) == 5, "should be 5"

我们只为test_myadd3.py模块展示了两个测试用例,因为其他测试用例将与前两个测试用例相似。这些额外的测试用例可以在本章的 GitHub 目录下的源代码中找到。以下是对测试用例实现中的一些关键差异进行了概述:

  • 没有要求在类下实现测试用例,我们可以实现测试用例作为类方法,而不需要从任何基类继承。这与unittest库相比是一个关键差异。

  • assert语句可以作为关键字用于验证任何条件,以声明测试是否通过。将assert关键字从条件语句中分离出来,使得测试用例中的断言非常灵活和可定制。

还需要提到的是,使用pytest框架,控制台输出和报告功能更加强大。例如,使用test_myadd3.py模块执行测试用例的控制台输出如下所示:

test_myadd3.py::test_add1 PASSED                         [25%]
test_myadd3.py::test_add2 PASSED                         [50%]
test_myadd3.py::test_add3 PASSED                         [75%]
test_myadd3.py::test_add4 PASSED                         [100%]
===================== 4 passed in 0.03s =======================

接下来,我们将探讨如何使用pytest库验证预期的错误。

使用错误处理构建测试用例

pytest框架中编写用于验证预期抛出异常或错误的测试用例与在unittest框架中编写此类测试用例不同。pytest框架利用上下文管理器进行异常验证。在我们的test_myadd3.py测试模块中,我们已经添加了两个用于异常验证的测试用例。下面是test_myadd3.py模块中包含这两个测试用例的代码片段:

def test_typeerror1():
    """ test case to check if we can handle non number \
     input"""
    with pytest.raises(TypeError):
        add('a', 5)
def test_typeerror2():
    """ test case to check if we can handle non number \
      input"""
    with pytest.raises(TypeError, match="only numbers are \
     allowed"):
        add('a', 'b')

为了验证异常,我们使用pytest库的raises函数来指示通过运行某个单元代码(我们第一个测试用例中的add('a', 5))预期的异常类型。在第二个测试用例中,我们使用了match参数来验证抛出异常时设置的消息。

接下来,我们将讨论如何使用pytest框架中的标记。

使用 pytest 标记构建测试用例

pytest框架配备了标记,允许我们为我们的测试用例附加元数据或定义不同的类别。这些元数据可用于许多目的,例如包括或排除某些测试用例。标记是通过使用@pytest.mark装饰器实现的。

pytest框架提供了一些内置标记,下面将描述其中最受欢迎的几个:

  • skip:当使用此标记时,测试运行器将无条件地跳过测试用例。

  • skipif:此标记用于根据传递给此标记的条件表达式跳过测试。

  • xfail:此标记用于忽略测试用例中预期的失败。它是在特定条件下使用的。

  • parametrize:此标记用于使用不同的值作为参数对测试用例进行多次调用。

为了演示前三个标记的使用,我们通过为测试用例函数添加标记来重写我们的test_add3.py模块。修改后的测试用例模块(test_add4.py)如下所示:

@pytest.mark.skip
def test_add1():
    """ test case to validate two positive numbers"""
    assert add(10, 5) == 15
@pytest.mark.skipif(sys.version_info > (3,6),\ 
reason=" skipped for release > than Python 3.6")
def test_add2():
    """ test case to validate two positive numbers"""
    assert add(10, -5) == 5, "should be 5"
@pytest.mark.xfail(sys.platform == "win32", \
reason="ignore exception for windows")
def test_add3():
    """ test case to validate two positive numbers"""
    assert add(-10, 5) == -5
    raise Exception()

我们无条件地使用了skip标记来忽略第一个测试用例。这将忽略该测试用例。对于第二个测试用例,我们使用了带有条件(Python 版本大于 3.6)的skipif标记。对于最后一个测试用例,我们故意抛出一个异常,并使用xfail标记来忽略如果系统平台是 Windows 的此类异常。此类标记有助于在预期某些条件(如本例中的操作系统)下忽略测试用例中的错误。

测试用例的执行控制台输出如下所示:

test_myadd4.py::test_add1 SKIPPED (unconditional skip)   [33%]
Skipped: unconditional skip
test_myadd4.py::test_add2 SKIPPED ( skipped for release > than Pytho...)                                                [66%]
Skipped:  skipped for release > than Python 3.6
test_myadd4.py::test_add3 XFAIL (ignore exception for mac)                                                    [100%]
@pytest.mark.xfail(sys.platform == "win32",
                       reason="ignore exception for mac")
============== 2 skipped, 1 xfailed in 0.06s =================

接下来,我们将讨论如何使用pytest库中的parametrize标记。

使用参数化构建测试用例

在所有之前的代码示例中,我们构建了没有传递任何参数给它们的测试用例函数或方法。但对于许多测试场景,我们需要通过改变输入数据来运行相同的测试用例。在经典方法中,我们运行多个测试用例,这些测试用例在输入数据方面不同。我们之前的test_myadd3.py示例展示了如何使用这种方法实现测试用例。对于此类测试,一个推荐的方法是使用pytest,它将根据表或字典中的排列数量执行我们的测试用例。DDT 的一个现实世界示例是使用各种具有有效和无效凭证的用户来验证应用程序登录功能的操作行为。

pytest框架中,可以使用pytest标记进行参数化来实现 DDT。通过使用parametrize标记,我们可以定义需要传递的输入参数以及需要使用的测试数据集。pytest框架将根据parametrize标记提供的测试数据条目数量自动多次执行测试用例函数。

为了说明如何使用parametrize标记进行数据驱动测试(DDT),我们将修改我们的myadd4.py模块以适应add函数的测试用例。在修改后的代码中,我们将只有一个测试用例函数,但将使用不同的测试数据作为输入参数,如下面的代码片段所示:

# test_myadd5.py test suite using parameterize marker
import sys
import pytest
from mypytest.src.myadd3 import add
@pytest.mark.parametrize("x,y,ans",
                         [(10,5,15),(10,-5,5),
                          (-10,5,-5),(-10,-5,-15)],
                         ids=["pos-pos","pos-neg",
                              "neg-pos", "neg-neg"])
def test_add(x, y, ans):
    """ test case to validate two positive numbers"""
    assert add(x, y) == ans

对于parametrize标记,我们使用了三个参数,具体描述如下:

  • 测试用例参数:我们提供了一个参数列表,这些参数将按照与测试用例函数定义相同的顺序传递给我们的测试函数。此外,我们将在下一个参数中提供的测试数据也将遵循相同的顺序。

  • 数据:要传递的测试数据将是一系列不同的输入参数集合。测试数据中的条目数量将决定测试用例将执行多少次。

  • ids:这是一个可选参数,主要用于为之前参数中提供的不同测试数据集附加一个友好的标签。这些标识符ID)标签将在输出报告中用于识别同一测试用例的不同执行。

下面的代码片段显示了此测试用例执行的控制台输出:

test_myadd5.py::test_add[pos-pos] PASSED                 [ 25%]
test_myadd5.py::test_add[pos-neg] PASSED                 [ 50%]
test_myadd5.py::test_add[neg-pos] PASSED                 [ 75%]
test_myadd5.py::test_add[neg-neg] PASSED                 [100%]
=============== 4 passed in 0.04s ================= 

这个控制台输出显示了测试用例执行的次数以及使用了哪些测试数据。使用pytest标记构建的测试用例简洁且易于实现。这节省了大量时间,并使我们能够在短时间内编写更多的测试用例(仅通过改变数据)。

接下来,我们将讨论pytest库的另一个重要特性:固定值(fixtures)。

使用 pytest 固定值构建测试用例

pytest 框架中,测试固定装置是通过 Python 装饰器 (@pytest.fixture) 实现的。与其它框架相比,pytest 框架中测试固定装置的实现非常强大,以下是一些关键原因:

  • pytest 框架中的固定装置提供了高度的扩展性。我们可以定义通用的设置或固定装置(方法),这些可以在函数、类、模块和包之间重用。

  • pytest 框架的固定装置实现是模块化的。我们可以为测试用例使用一个或多个固定装置。一个固定装置也可以使用一个或多个其他固定装置,就像我们使用函数调用其他函数一样。

  • 测试套件中的每个测试用例都将具有使用相同或不同固定装置集的灵活性。

  • 我们可以在 pytest 框架中创建固定装置,并为它们设置范围。默认范围是 function,这意味着固定装置将在每个函数(测试用例)执行之前执行。其他范围选项有 moduleclasspackagesession。以下简要定义:

    a) Function:固定装置在执行测试用例后将被销毁。

    b) Module:固定装置在执行模块中的最后一个测试用例后将被销毁。

    c) Class:固定装置在执行类中的最后一个测试用例后将被销毁。

    d) Package:固定装置在执行包中的最后一个测试用例后将被销毁。

    e) Session:固定装置在执行测试会话中的最后一个测试用例后将被销毁。

pytest 框架有一些有用的内置固定装置可以直接使用,例如 capfd 用于捕获输出到文件描述符,capsys 用于捕获输出到 stdoutstderrrequest 用于提供关于请求测试函数的信息,以及 testdir 用于提供测试执行的临时测试目录。

pytest 框架中的固定装置可以在测试用例结束时用于重置或拆卸。我们将在本节稍后讨论这一点。

在下一个代码示例中,我们将使用自定义固定装置为我们的 MyCalc 类构建测试用例。MyCalc 的示例代码已在 执行多个测试套件 部分中分享。固定装置和测试用例的实现如下所示:

# test_mycalc1.py test calc functions using test fixture
import sys
import pytest
from mypytest.src.myadd3 import add
from mypytest.src.mycalc import MyCalc
@pytest.fixture(scope="module")
def my_calc():
    return MyCalc()
@pytest.fixture
def test_data ():
    return {'x':10, 'y':5}
def test_add(my_calc, test_data):
    """ test case to add two numbers"""
    assert my_calc.add(test_data.get('x'),\
      test_data.get('y')) == 15
def test_subtract(my_calc, test_data):
    """ test case to subtract two numbers"""
    assert my_calc.subtract(test_data.get('x'), \
     test_data.get('y'))== 5

在这个测试套件示例中,以下是我们讨论的关键点:

  • 我们创建了两个固定装置:my_calctest_datamy_calc 固定装置的设置范围设置为 module,因为我们希望它只执行一次,以提供一个 MyCalc 类的实例。test_data 固定装置使用默认范围(function),这意味着它将在每个方法执行之前执行。

  • 对于测试用例(test_addtest_subtract),我们使用了固定装置作为输入参数。参数的名称必须与固定装置函数名称匹配。pytest 框架会自动查找用于测试用例的参数名称对应的固定装置。

我们讨论的代码示例是使用 fixture 作为设置函数。我们可能想要问的问题是:我们如何通过 pytest fixtures 实现拆卸功能? 实现拆卸功能有两种方法,接下来将进行讨论。

使用 yield 代替 return 语句

使用这种方法,我们编写一些代码主要是为了设置目的,使用 yield 语句而不是 return,然后在 yield 语句之后编写拆卸目的的代码。如果我们有一个包含许多 fixtures 的测试套件或模块,pytest 测试运行器将执行每个 fixture(按照评估的执行顺序),直到遇到 yield 语句。一旦测试用例执行完成,pytest 测试运行器将触发所有已 yield 的 fixtures 的执行,并执行 yield 语句之后的代码。基于 yield 的方法在代码易于遵循和维护方面很干净。因此,这是一个推荐的方法。

使用请求 fixture 添加最终化方法

使用这种方法,我们必须考虑三个步骤来编写拆卸方法,概述如下:

  • 我们必须在 fixtures 中使用一个 request 对象。request 对象可以使用具有相同名称的内置 fixture 提供。

  • 我们将定义一个 teardown 方法,单独或作为 fixture 实现的一部分。

  • 我们将使用 addfinalizer 方法将 teardown 方法作为可调用方法提供给请求对象。

为了通过代码示例说明这两种方法,我们将修改我们之前对 fixtures 的实现。在修改后的代码中,我们将使用 yield 方法实现 my_calc fixture,并使用 addfinalizer 方法实现 data_set fixture。以下是修改后的代码示例:

# test_mycalc2.py test calc functions using test fixture
<import statements>
@pytest.fixture(scope="module")
def my_calc():
    my_calc = MyCalc()
    yield my_calc
    del my_calc
@pytest.fixture
def data_set(request):
    dict = {'x':10, 'y':5}
    def delete_dict(obj):
        del obj
    request.addfinalizer(lambda: delete_dict(dict))
    return dict
<rest of the test cases>

注意,对于这些示例 fixtures,实际上没有必要进行拆卸功能,但我们添加它们是为了说明目的。

小贴士

使用 nosedoctest 进行测试自动化类似于使用 unittestpytest 框架。

在下一节中,我们将讨论软件开发的 TDD 方法。

执行 TDD

TDD 是软件工程中众所周知的一种实践。这是一种软件开发方法,其中在编写应用程序中所需功能的任何代码之前,首先编写测试用例。以下是 TDD 的三个简单规则:

  • 除非你编写一个失败的单元测试,否则不要编写任何功能代码。

  • 不要在同一个测试中编写任何超过使测试失败的代码。

  • 不要编写任何超过通过失败测试所需的函数代码。

这些 TDD 规则还驱使我们遵循一个著名的软件开发三阶段方法,称为 红、绿、重构。这些阶段在 TDD 中持续重复。这三个阶段在 图 5.4 中显示,并将在下面进行描述。

红色

在这个阶段,第一步是编写一个没有测试代码的测试。在这种情况下,测试显然会失败。我们不会尝试编写一个完整的测试用例,而只是编写足够的代码来使测试失败。

绿色

在这个阶段,第一步是编写代码,直到已经编写的测试通过。再次强调,我们只会编写足够的代码来通过测试。我们将运行所有测试以确保之前编写的测试也能通过。

重构

在这个阶段,我们应该考虑提高代码质量,这意味着使代码易于阅读和使用优化——例如,任何硬编码的值都必须移除。在重构周期后运行测试也是推荐的。重构阶段的成果是干净的代码。我们可以通过添加更多测试场景并添加代码来使新测试通过来重复这个周期,并且这个周期必须重复,直到开发出功能。

重要的是要理解 TDD 既不是一种测试方法,也不是一种设计方法。它是一种根据首先编写的测试用例来开发软件的方法。

以下图表显示了 TDD 的三个阶段:

![图 5.4 – TDD,也称为红、绿、重构图片

图 5.4 – TDD,也称为红、绿、重构

在下一节中,我们将介绍测试自动化在 CI 流程中的作用。

引入自动化 CI

CI 是一个结合了自动化测试和版本控制系统优势的过程,以实现完全自动化的集成环境。采用 CI 开发方法,我们频繁地将代码集成到共享仓库中。每次我们将代码添加到仓库中,都期望以下两个过程启动:

  • 自动构建过程开始验证新添加的代码在编译或语法方面没有破坏任何东西。

  • 自动化测试执行开始验证现有功能以及新功能是否符合定义的测试用例。

以下图表展示了 CI 流程的不同步骤和阶段。虽然我们在流程图中展示了构建阶段,但对于基于 Python 的项目来说,这不是一个必需的阶段,因为我们可以在没有编译代码的情况下执行集成测试:

![图 5.5 – CI 测试阶段图片

图 5.5 – CI 测试阶段

要构建一个持续集成(CI)系统,我们需要一个稳定的分布式版本控制系统,以及一个可以用来通过一系列测试套件测试整个应用程序工作流程的工具。市面上有几种商业和开源软件工具提供持续集成和持续交付(CD)功能。这些工具旨在易于与源控制系统和测试自动化框架集成。一些流行的 CI 工具包括 JenkinsBambooBuildbotGitLab CICircleCIBuddy。这些工具的详细信息可以在 进一步阅读 部分找到,供那些有兴趣了解更多的人参考。

这种自动化的 CI 的明显好处是能够快速检测错误,并在一开始就方便地修复它们。重要的是要理解,CI 不仅仅是关于错误修复,但它确实有助于轻松识别错误并及时修复。

摘要

在本章中,我们介绍了软件应用的不同测试级别。我们还评估了两个可用于 Python 测试自动化的测试框架(unittestpytest)。我们学习了如何使用这两个框架构建基本和高级测试用例。在章节的后面部分,我们介绍了 TDD 方法及其对软件开发明显的益处。最后,我们简要介绍了 CI,这是使用敏捷开发运维(devops)模型交付软件的关键步骤。

这章对任何想要开始为他们的 Python 应用程序编写单元测试的人来说都很有用。提供的代码示例为我们使用任何测试框架编写测试用例提供了一个良好的起点。

在下一章中,我们将探讨在 Python 中开发应用程序的不同技巧和提示。

问题

  1. 单元测试是白盒测试还是黑盒测试的一种形式?

  2. 我们应该在什么时候使用模拟对象?

  3. 使用 unittest 框架实现测试固定有哪些方法?

  4. TDD 与 CI 有何不同?

  5. 我们应该在什么时候使用 DDT?

进一步阅读

  • 《Python 测试学习》,作者:Daniel Arbuckle

  • 《Python 测试驱动开发》,作者:Harry J.W. Percival

  • 《专家 Python 编程》,作者:Michał Jaworski 和 Tarek Ziadé

  • unittest 框架的详细信息可以在 Python 文档的docs.python.org/3/library/unittest.html找到。

答案

  1. 白盒测试

  2. 模拟对象有助于模拟外部或内部依赖的行为。通过使用模拟对象,我们可以专注于编写测试来验证功能行为。

  3. setUptearDownsetUpClasstearDownClass

  4. TDD 是一种先编写测试用例再开发软件的方法。CI 是在我们构建新版本时执行所有测试的过程。TDD 和 CI 之间没有直接关系。

  5. 当我们需要对输入参数的多种排列进行功能测试时,会使用 DDT。例如,如果我们需要用不同组合的参数测试一个 API 端点,我们可以利用 DDT。

第六章:第六章:Python 的高级技巧和窍门

在本章中,我们将介绍一些高级技巧和窍门,这些技巧和窍门可以在编写 Python 代码时作为强大的编程技术使用。这包括 Python 函数的高级使用,如嵌套函数、lambda 函数以及使用函数构建装饰器。此外,我们将涵盖使用 filter、mapper 和 reducer 函数进行数据转换。接下来,我们将介绍一些与数据结构相关的技巧,例如嵌套字典和不同集合类型的理解。最后,我们将研究 pandas 库中 DataFrame 对象的先进功能。这些高级技巧和窍门不仅将展示 Python 在用更少的代码实现高级功能方面的强大能力,而且还将帮助您更快、更有效地编写代码。

本章将涵盖以下主题:

  • 学习使用函数的高级技巧

  • 使用数据结构理解高级概念

  • 介绍使用 pandas DataFrame 的高级技巧

到本章结束时,您将了解如何使用 Python 函数实现高级功能,如数据转换和构建装饰器。此外,您还将学习如何使用包括 pandas DataFrame 在内的数据结构,用于基于分析的应用程序。

技术要求

本章的技术要求如下:

  • 您需要在您的计算机上安装 Python 3.7 或更高版本。

  • 您需要在 TestPyPI 上注册一个账户,并在您的账户下创建一个 API 令牌。

本章的示例代码可以在github.com/PacktPublishing/Python-for-Geeks/tree/master/Chapter06找到。

我们将首先讨论 Python 中函数的高级概念。

学习使用函数的高级技巧

在 Python 和其他编程语言中使用函数对于可重用性和模块化至关重要。然而,随着现代编程语言的最新进展,函数的作用已经超越了可重用性,包括编写简单、简短和简洁的代码,而无需使用复杂的循环和条件语句。

我们将从counterzipitertools函数的使用开始,我们将在下一节中讨论这些函数。

介绍用于迭代任务的 counter、itertools 和 zip 函数

对于任何数据处理任务,开发者广泛使用迭代器。我们在第四章高级编程的 Python 库中详细介绍了迭代器。在本节中,我们将学习关于下一级实用函数的内容,这些函数可以帮助您方便地处理迭代器和可迭代对象。这些包括counter模块、zip函数和itertools模块。我们将在以下小节中讨论这些内容。

Counter

Counter类,我们将提供一个简单的代码示例,如下所示:

#counter.py
from collections import Counter
#applying counter on a string object
print(Counter("people"))
#applying counter on a list object
my_counter = Counter([1,2,1,2,3,4,1,3])
print(my_counter.most_common(1))
print(list(my_counter.elements()))
#applying counter on a dict object
print(Counter({'A': 2, 'B': 2, 'C': 2, 'C': 3}))

在前面的代码示例中,我们使用一个String对象、一个列表对象和一个字典对象创建了多个Counter实例。Counter类有most_commonelements等方法。我们使用most_common方法并设置值为1,这会给我们返回my-counter容器中出现次数最多的元素。此外,我们还使用了elements方法从Counter实例返回原始列表。该程序的控制台输出应如下所示:

Counter({'p': 2, 'e': 2, 'o': 1, 'l': 1})
[(1, 3)]
[1, 1, 1, 2, 2, 3, 3, 4]
Counter({'C': 4, 'A': 2, 'B': 2}) 

重要的是要注意,在字典对象的情况下,我们故意使用了重复的键,但在Counter实例中,我们只得到一个键值对,这是字典中的最后一个。此外,Counter实例中的元素根据每个元素的值进行排序。请注意,Counter类将字典对象转换为散列表对象。

zip

zip函数用于根据两个或多个单独的迭代器创建一个聚合迭代器。当需要并行迭代多个迭代器时,zip函数非常有用。例如,在实现涉及插值或模式识别的数学算法时,我们可以使用zip函数。这也有助于数字信号处理,其中我们将多个信号(数据源)组合成一个单一信号。以下是一个使用zip函数的简单代码示例:

#zip.py
num_list = [1, 2, 3, 4, 5]
lett_list = ['alpha', 'bravo', 'charlie']
zipped_iter = zip(num_list,lett_list)
print(next(zipped_iter))
print(next(zipped_iter))
print(list(zipped_iter))

在前面的代码示例中,我们通过使用zip函数将两个列表合并以进行迭代。请注意,从元素数量来看,一个列表比另一个列表大。该程序的输出应如下所示:

(1, 'alpha')
(2, 'bravo')
[(3, 'charlie'), (4, 'delta')]

如预期,我们使用next函数获取前两个元组,这是来自每个列表对应元素的组合。最后,我们使用list构造函数遍历zip迭代器中的其余元组。这给我们提供了一个剩余元组的列表。

itertools

Python 提供了一个名为itertools的模块,它提供了用于处理迭代器的有用函数。当处理大量数据时,迭代器的使用是必不可少的,这正是itertool模块提供的实用函数非常有帮助的地方。itertools模块提供了许多函数。在这里,我们将简要介绍几个关键函数:

  • count: 这个函数用于创建一个计数数字的迭代器。我们可以提供一个起始数字(默认为 0),并且可选地设置计数步长的尺寸。以下代码示例将返回一个提供计数数字的迭代器,例如 10、12 和 14:

    #itertools_count.py
    import itertools
    iter = itertools.count(10, 2)
    print(next(iter))
    print(next(iter))
    
  • cycle: 这个函数允许你无限循环遍历一个迭代器。以下代码片段说明了如何使用此函数遍历字母表中的字母列表:

    letters = {'A','B','C'}
    for letter in itertools.cycle(letters):
        print(letter)
    
  • Repeat:此函数为我们提供了一个迭代器,该迭代器会反复返回一个对象,除非设置了times参数。以下代码片段将重复Python字符串对象五次:

    for x in itertools.repeat('Python', times=5):
        print(x)
    
  • accumulate:此函数将返回一个迭代器,该迭代器提供基于传递给此accumulate函数作为参数的聚合函数的累积总和或其他累积结果。以下代码示例将有助于理解该函数的使用方法:

    #accumulate function without providing an aggregator function for any accumulated results. By default, the accumulate function will add two numbers (1 and 3) from the original list. This process is repeated for all numbers, and the results are stored inside an iterable (in our case, this is res). In the second part of this code example, we provided the mul (multiplication) function from the operator module, and this time, the accumulated results are based on the multiplication of two numbers.
    
  • chain:此函数将两个或多个可迭代对象组合起来,并返回一个组合的可迭代对象。请看以下示例代码,展示了两个可迭代对象(列表)以及chain函数的使用:

    list1 = ['A','B','C']
    list2 = ['W','X','Y','Z']
    chained_iter = itertools.chain(list1, list2)
    for x in chained_iter:
        print(x)
    

    注意,此函数将以串行方式组合可迭代对象。这意味着list1中的项目将首先可用,然后是list2中的项目。

  • compress:此函数可以用来根据另一个可迭代对象过滤一个可迭代对象中的元素。在以下示例代码片段中,我们根据selector可迭代对象从列表中选择了字母:

    letters = ['A','B','C']
    selector = [True, 0, 1]
    for x in selector iterable, we can use True*/*False or 1*/*0. The output of this program will be the letters A and C.
    
  • groupby:此函数识别可迭代对象中每个项目的键,并根据识别的键对项目进行分组。此函数需要一个额外的函数(称为key_func),用于在可迭代对象的每个元素中识别键。以下示例代码解释了该函数的使用方法以及如何实现key_func函数:

    #itertools_groupby.py
    import itertools
    mylist = [("A", 100), ("A", 200), ("B", 30), \
    ("B", 10)]
    def get_key(group):
        return group[0]
    for key, grp in itertools.groupby(mylist, get_key):
        print(key + "-->", list(grp))
    
  • tee:这是另一个有用的函数,可以用来从单个迭代器中复制迭代器。以下是一个示例代码,展示了如何从一个列表可迭代对象中复制两个迭代器:

    letters = ['A','B','C']
    iter1, iter2 = itertools.tee(letters)
    for x in iter1:
        print(x)
    for x in iter2:
        print(x)
    

接下来,我们将讨论另一个广泛用于数据转换的函数类别。

使用过滤器、映射器和归约器进行数据转换

mapfilterreduce是 Python 中可用的三个函数,用于简化代码并编写简洁的代码。这三个函数在单个操作中应用于可迭代对象,而不使用迭代语句。mapfilter函数作为内置函数提供,而reduce函数则需要导入functools模块。这些函数被数据科学家广泛用于数据处理。map函数和filter函数用于转换或过滤数据,而reduce函数用于数据分析,以从大量数据集中获取有意义的成果。

在以下小节中,我们将评估每个函数及其应用和代码示例。

地图

Python 中的map函数定义如下语法:

map(func, iter, ...)

func参数是要应用于iter对象每个项目的函数的名称。三个点表示可以传递多个可迭代对象。然而,重要的是要理解函数(func)的参数数量必须与可迭代对象的数量相匹配。map函数的输出是一个map对象,它是一个生成器对象。可以通过将map对象传递给list构造函数将其转换成列表。

重要提示

在 Python 2 中,map函数返回一个列表。这种行为在 Python 3 中已经改变。

在讨论map函数的使用之前,首先,我们将实现一个简单的转换函数,该函数将数字列表转换为它们的平方值。接下来的代码示例提供了:

#map1.py to get square of each item in a list
mylist = [1, 2, 3, 4, 5]
new_list = []
for item in mylist:
    square = item*item
    new_list.append(square)
print(new_list)

在这里,代码示例使用for循环结构遍历列表,计算列表中每个条目的平方,并将其添加到新的列表中。这种编写代码的风格很常见,但绝对不是 Python 风格的编写代码方式。这个程序的输出如下:

[1, 4, 9, 16, 25]

使用map函数,可以将此代码简化并缩短,如下所示:

# map2.py to get square of each item in a list
def square(num):
    return num * num
mylist = [1, 2, 3, 4, 5]
new_list = list(map(square, mylist))
print(new_list)

通过使用map函数,我们提供了函数的名称(在这个例子中,它是square)和列表的引用(在这个例子中,它是mylist)。map函数返回的map对象通过使用list构造函数转换为列表对象。这个代码示例的控制台输出与之前的代码示例相同。

在下面的代码示例中,我们将为map函数提供两个列表作为输入:

# map3.py to get product of each item in two lists
def product(num1, num2):
    return num1 * num2
mylist1 = [1, 2, 3, 4, 5]
mylist2 = [6, 7, 8, 9]
new_list = list(map(product, mylist1, mylist2))
print(new_list)

这次,已实现的map函数的目标是使用product函数。product函数从两个列表中取每个项目,并在返回之前将每个列表中对应的项相乘。

这个代码示例的控制台输出如下:

[6, 14, 24, 36] 

对这个控制台输出的分析告诉我们,map函数只使用了每个列表的前四个项目。当map函数在任何一个可迭代对象(在我们的例子中,这些是两个列表)中运行完项目后,它会自动停止。这意味着即使我们提供了不同大小的可迭代对象,map函数也不会引发任何异常,但会为使用提供的函数在可迭代对象之间映射的项目数量工作。在我们的代码示例中,mylist2列表中的项目数量较少,是四个。这就是为什么输出列表中只有四个项目(在我们的例子中,这是new_list)。接下来,我们将通过一些代码示例讨论filter函数。

filter

filter函数也作用于可迭代对象,但只作用于一个可迭代对象。正如其名称所暗示的,它为可迭代对象提供了过滤功能。过滤条件通过函数定义提供。filter函数的语法如下:

 filter (func, iter)

func 函数提供过滤条件,并且必须返回 TrueFalse。由于 filter 函数只能允许一个可迭代对象,因此 func 函数只能有一个参数。以下代码示例使用 filter 函数来选择值为偶数的项。为了实现选择标准,实现了 is_even 函数来评估传递给它的数字是否为偶数。示例代码如下:

# filter1.py to get even numbers from a list
def is_even(num):
    return (num % 2 == 0)
mylist = [1, 2, 3, 4, 5,6,7,8,9]
new_list = list(filter(is_even, mylist))
print(new_list)

上述代码示例的控制台输出如下:

[2, 4, 6, 8]

接下来,我们将讨论 reduce 函数。

reduce

reduce 函数用于对序列中的每个元素应用累积处理函数,该函数作为参数传递给它。这个累积处理函数不是用于转换或过滤目的。正如其名称所暗示的,累积处理函数用于根据序列中的所有元素在最后得到一个单一的结果。使用 reduce 函数的语法如下:

reduce (func, iter[,initial])

func 函数是一个用于对可迭代对象的每个元素应用累积处理的函数。此外,initial 是一个可选值,可以传递给 func 函数作为累积处理初始值的用途。重要的是要理解,对于 reduce 函数的情况,func 函数将始终有两个参数:第一个参数将是初始值(如果提供的话)或序列的第一个元素,第二个参数将是序列的下一个元素。

在下面的代码示例中,我们将使用一个简单的包含前五个数字的列表。我们将实现一个自定义方法来添加两个数字,然后使用 reduce 方法来计算列表中所有元素的总和。代码示例如下:

# reduce1.py to get sum of numbers from a list
from functools import reduce
def seq_sum(num1, num2):
    return num1+num2
mylist = [1, 2, 3, 4, 5]
result = reduce(seq_sum, mylist)
print(result) 

该程序的输出是 15,这是列表中所有元素的数值总和(在我们的例子中,这被称为 mylist)。如果我们向 reduce 函数提供初始值,结果将根据初始值附加。例如,以下语句的相同程序的输出将是 25

result = reduce(seq_sum, mylist, 10)

如前所述,reduce 函数的结果或返回值是一个单一值,它遵循 func 函数。在这个例子中,它将是一个整数。

在本节中,我们讨论了 Python 中可用的 mapfilterreduce 函数。这些函数被数据科学家广泛用于数据转换和数据精炼。使用 mapfilter 等函数的一个问题是,它们返回 mapfilter 类型的对象,我们必须显式地将结果转换为 list 数据类型以进行进一步处理。列表推导式和生成器没有这样的限制,但提供类似的功能,并且相对更容易使用。这就是为什么它们比 mapfilterreduce 函数更受欢迎。我们将在 使用数据结构理解高级概念 部分讨论列表推导式和生成器。接下来,我们将研究 Lambda 函数的使用。

学习如何构建 Lambda 函数

Lambda 函数是基于单行表达式的匿名函数。正如使用 def 关键字来定义常规函数一样,使用 lambda 关键字来定义匿名函数。Lambda 函数限制在单行内。这意味着它们不能使用多个语句,也不能使用返回语句。单行表达式的评估后,返回值会自动返回。

Lambda 函数可以在常规函数可以使用的任何地方使用。Lambda 函数最简单、最方便的使用方式是与 mapreducefilter 函数一起使用。当您希望使代码更简洁时,Lambda 函数非常有用。

为了说明 Lambda 函数,我们将重用我们之前讨论过的 map 和 filter 代码示例。在这些代码示例中,我们将用 Lambda 函数替换 func,如下面的代码片段所示:

# lambda1.py to get square of each item in a list
mylist = [1, 2, 3, 4, 5]
new_list = list(map(lambda x: x*x, mylist))
print(new_list)
# lambda2.py to get even numbers from a list
mylist = [1, 2, 3, 4, 5, 6, 7, 8, 9]
new_list = list(filter(lambda x: x % 2 == 0, mylist))
print(new_list)
# lambda3.py to get product of corresponding item in the\
 two lists
mylist1 = [1, 2, 3, 4, 5]
mylist2 = [6, 7, 8, 9]
new_list = list(map(lambda x,y: x*y, mylist1, mylist2))
print(new_list)

虽然代码已经变得更加简洁,但我们应谨慎使用 Lambda 函数。这些函数是不可重用的,并且不易维护。在将 Lambda 函数引入我们的程序之前,我们需要重新思考这一点。任何更改或附加功能都不容易添加。一个经验法则是,在编写单独函数会带来开销时,仅使用 Lambda 函数进行简单的表达式。

在另一个函数中嵌入函数

当我们在现有函数内添加一个函数时,它被称为 def 关键字,并使用适当的缩进。内部函数不能被外部程序执行或调用。然而,如果外部函数返回内部函数的引用,调用者可以使用它来执行内部函数。我们将在以下小节中查看许多用例的内部函数引用示例。

内部函数有许多优点和应用。我们将在下面描述其中的一些。

封装

内部函数的一个常见用途是能够隐藏其功能对外部世界的可见性。内部函数仅在外部函数的作用域内可用,并且对全局作用域不可见。以下代码示例显示了一个隐藏内部函数的外部函数:

#inner1.py
def outer_hello():
    print ("Hello from outer function")
    def inner_hello():
        print("Hello from inner function")
    inner_hello()
outer_hello()

从外部函数的外部,我们只能调用外部函数。内部函数只能从外部函数体内部调用。

辅助函数

在某些情况下,我们可能会发现自己处于一个函数内部代码可重用的情况。我们可以将此类可重用代码转换为单独的函数;否则,如果代码仅可在函数的作用域内重用,那么它就是一个构建内部函数的例子。这种内部函数也称为辅助函数。以下代码片段说明了这个概念:

def outer_fn(x, y):
    def get_prefix(s):
        return s[:2] 
    x2 = get_prefix(x)
    y2 = get_prefix(y)
    #process x2 and y2 further

在前面的示例代码中,我们在一个外部函数内部定义了一个内部函数,称为get_prefix(一个辅助函数),用于过滤参数值的第一个两个字母。由于我们必须对所有参数重复此过滤过程,因此我们添加了一个辅助函数,以便在这个函数的作用域内重用,因为它专门针对这个函数。

闭包和工厂函数

这是一种内部函数大放异彩的使用案例。闭包是一个内部函数及其封装环境。闭包是一个动态创建的函数,可以被另一个函数返回。闭包的真正魔力在于返回的函数可以完全访问其创建时的变量和命名空间。即使封装函数(在这个上下文中,是外部函数)已经执行完毕,这也是正确的。

闭包概念可以通过代码示例来展示。以下代码示例展示了一个实现闭包工厂以创建计算基数值幂的函数的使用案例,并且基数值由闭包保留:

# inner2.py
def power_calc_factory(base):
    def power_calc(exponent):
        return base**exponent
    return power_calc
power_calc_2 = power_gen_factory(2)
power_calc_3 = power_gen_factory(3)
print(power_calc_2(2))
print(power_calc_2(3))
print(power_calc_3(2))
print(power_calc_3(4))

在前面的代码示例中,外部函数(即power_calc_factory)充当闭包工厂函数,因为它在每次被调用时都会创建一个新的闭包,然后将其返回给调用者。此外,power_calc是一个内部函数,它从一个闭包命名空间中获取一个变量(即base),然后获取第二个变量(即exponent),该变量作为参数传递给它。请注意,最重要的语句是return power_calc。这个语句返回一个包含其封装环境的内部函数对象。

当我们第一次调用power_calc_factory函数并传递base参数时,会创建一个包含其命名空间、包括传递给它的参数的闭包,并将闭包返回给调用者。当我们再次调用相同的函数时,我们得到一个新的闭包,其中包含内部函数对象。在这个代码示例中,我们创建了 2 个闭包:一个base值为 2,另一个base值为 3。当我们通过传递不同的exponent变量值调用内部函数时,内部函数(在这种情况下,power_calc函数)也将能够访问已经传递给外部函数的base值。

这些代码示例说明了使用外部和内部函数动态创建函数的使用。传统上,内部函数用于在函数内部隐藏或封装功能。但当他们与作为创建动态函数的工厂的外部函数一起使用时,这成为内部函数最强大的应用。内部函数也用于实现装饰器。我们将在下一节中更详细地讨论这个问题。

使用装饰器修改函数行为

Python 中装饰器的概念基于装饰器设计模式,这是一种结构型设计模式。这种模式允许你在不改变对象实现的情况下向对象添加新行为。这种新行为被添加到特殊的包装对象中。

在 Python 中,装饰器是特殊的高阶函数,允许开发者在不修改现有函数(或方法)内部的情况下向其添加新功能。通常,这些装饰器被添加在函数定义之前。装饰器用于实现应用程序的许多功能,但在数据验证、日志记录、缓存、调试、加密和事务管理中特别受欢迎。

要创建一个装饰器,我们必须定义一个可调用实体(即函数、方法或类),它接受一个函数作为参数。可调用实体将返回另一个具有装饰器定义行为的函数对象。被装饰的函数(在本节的其余部分我们将称之为装饰函数)作为参数传递给实现装饰器的函数(在本节的其余部分我们将称之为装饰器函数)。装饰器函数除了执行传递给它的函数外,还会执行作为装饰器函数一部分添加的额外行为。

以下代码示例展示了装饰器的一个简单示例,其中我们定义了一个装饰器,在函数执行前后添加时间戳:

# decorator1.py
from datetime import datetime
def add_timestamps(myfunc):
    def _add_timestamps():
        print(datetime.now())
        myfunc()
        print(datetime.now())
    return _add_timestamps
@add_timestamps
def hello_world():
    print("hello world")
hello_world()

在这个代码示例中,我们定义了一个add_timestamps装饰器函数,它接受任何函数作为参数。在内部函数_add_timestamps中,我们在函数执行前后获取当前时间,然后将这些时间作为参数传递。装饰器函数通过闭包返回内部函数对象。正如我们在上一节中讨论的那样,装饰器所做的不仅仅是巧妙地使用内部函数。使用@符号装饰函数等同于以下代码行:

hello = add_timestamps(hello_world)
hello()

在这种情况下,我们通过传递函数名作为参数显式地调用装饰器函数。换句话说,装饰过的函数等于内部函数,它是定义在装饰器函数内部的。这正是 Python 在看到函数定义前有@符号的装饰器时,解释和调用装饰器函数的方式。

然而,当我们需要获取关于函数调用的额外细节时,会出现问题,这对于调试非常重要。当我们使用内置的help函数与hello_world函数一起使用时,我们只收到内部函数的帮助信息。如果我们使用文档字符串,也会发生同样的事情,它将适用于内部函数但不会适用于装饰过的函数。此外,对于装饰过的函数,序列化代码将是一个挑战。Python 为所有这些问题提供了一个简单的解决方案;这个解决方案就是使用functools库中的wraps装饰器。我们将修改之前的代码示例以包含wraps装饰器。完整的代码示例如下:

# decorator2.py
from datetime import datetime
from functools import wraps
def add_timestamps(myfunc):
    @wraps(myfunc)
    def _add_timestamps():
        print(datetime.now())
        myfunc()
        print(datetime.now())
    return _add_timestamps
@add_timestamps
def hello_world():
    print("hello world")
hello_world()
help(hello_world)
print(hello_world)

使用wraps装饰器将提供关于嵌套函数执行的额外细节,如果我们运行提供的示例代码,我们可以在控制台输出中查看这些细节。

到目前为止,我们已经通过一个简单的装饰器示例来解释这个概念。在本节的剩余部分,我们将学习如何将函数的参数传递给装饰器,如何从装饰器中返回值,以及如何链式使用多个装饰器。首先,我们将学习如何使用装饰器传递属性并返回一个值。

使用具有返回值和参数的装饰函数

当我们的装饰过函数接受参数时,装饰这样的函数需要一些额外的技巧。一个技巧是在内部包装函数中使用*args**kwargs。这将使内部函数能够接受任意数量的位置参数和关键字参数。以下是一个带有参数和返回值的装饰函数的简单示例:

# decorator3.py
from functools import wraps
def power(func):
    @wraps(func)
    def inner_calc(*args, **kwargs):
        print("Decorating power func")
        n = func(*args, **kwargs)
        return n
    return inner_calc
@power
def power_base2(n):
    return 2**n
print(power_base2(3))

在前面的示例中,inner_calc 的内部函数接受 *args**kwargs 的通用参数。要从内部函数(在我们的代码示例中为 inner_calc)返回一个值,我们可以保留从函数返回的值(在我们的代码示例中,这是 funcpower_base2(n)),该函数在我们的内部函数内部执行,并从 inner_calc 的内部函数返回最终的返回值。

建立具有其自身参数的装饰器

在前面的示例中,我们使用了所谓的 decorator3.py 示例。在修订版中,我们计算作为装饰器参数传递的基数值的幂。您可以通过以下方式查看使用嵌套装饰器函数的完整代码示例:

# decorator4.py
from functools import wraps
def power_calc(base):
    def inner_decorator(func):
        @wraps(func)
        def inner_calc(*args, **kwargs):
            exponent = func(*args, **kwargs)
            return base**exponent
        return inner_calc
    return inner_decorator
@power_calc(base=3)
def power_n(n):
    return n
print(power_n(2))
print(power_n(4))

以下是这个代码示例的工作原理:

  • power_calc 装饰器函数接受一个参数 base 并返回 inner_decorator 函数,这是一个标准的装饰器实现。

  • inner_decorator 函数接受一个函数作为参数,并返回用于实际计算的 inner_calc 函数。

  • inner_calc 函数调用装饰函数以获取 exponent 属性(在这种情况下)然后使用 base 属性,该属性作为参数传递给外部的装饰器函数。正如预期的那样,围绕内部函数的闭包使得 base 属性的值对 inner_calc 函数可用。

接下来,我们将讨论如何使用一个以上的装饰器与一个函数或方法一起使用。

使用多个装饰器

我们多次了解到,使用一个以上的装饰器与一个函数一起使用是可能的。这可以通过链式装饰器实现。链式装饰器可以是相同的也可以是不同的。这可以通过在函数定义之前将装饰器一个接一个地放置来实现。当使用一个以上的装饰器与一个函数一起使用时,装饰函数只执行一次。为了说明其实现和实际应用,我们选择了一个示例,在该示例中,我们使用时间戳将消息记录到目标系统。时间戳是通过一个单独的装饰器添加的,目标系统也是基于另一个装饰器选择的。以下代码示例显示了三个装饰器的定义,即 add_time_stampfileconsole

# decorator5.py (part 1)
from datetime import datetime
from functools import wraps
def add_timestamp(func):
    @wraps(func)
    def inner_func(*args, **kwargs):
        res = "{}:{}\n".format(datetime.now(),func(*args,\
          **kwargs))
        return res
    return inner_func
def file(func):
    @wraps(func)
    def inner_func(*args, **kwargs):
        res = func(*args, **kwargs)
        with open("log.txt", 'a') as file:
           file.write(res)
        return res
    return inner_func
def console(func):
    @wraps(func)
    def inner_func(*args, **kwargs):
        res = func(*args, **kwargs)
        print(res)
        return res
    return inner_func

在前面的代码示例中,我们实现了三个装饰器函数。它们如下:

  • file: 这个装饰器使用一个预定义的文本文件,并将装饰函数提供的消息添加到文件中。

  • console: 这个装饰器将装饰函数提供的消息输出到控制台。

  • add_timestamp: 这个装饰器在装饰函数提供的消息之前添加一个时间戳。这个装饰器函数的执行必须在文件或控制台装饰器之前,这意味着这个装饰器必须放在装饰器链的末尾。

在以下代码片段中,我们可以在主程序中的不同函数中使用这些装饰器:

#decorator5.py (part 2)
@file
@add_timestamp
def log(msg):
    return msg
@file
@console
@add_timestamp
def log1(msg):
    return msg
@console
@add_timestamp
def log2(msg):
    return msg
log("This is a test message for file only")
log1("This is a test message for both file and console")
log2("This message is for console only")

在前面的代码示例中,我们使用前面定义的三个装饰器函数的不同组合来展示来自同一日志函数的不同行为。在第一种组合中,我们只在添加时间戳后向文件输出消息。在第二种组合中,我们同时向文件和控制台输出消息。在最后一种组合中,我们只向控制台输出消息。这显示了装饰器提供的灵活性,而无需更改函数。值得一提的是,装饰器在简化代码和以简洁的方式添加行为方面非常有用,但它们在执行期间会产生额外的开销。装饰器的使用应限制在那些收益足以弥补任何开销成本的场景中。

这结束了我们对高级函数概念和技巧的讨论。在下一节中,我们将转向与数据结构相关的一些高级概念。

使用数据结构理解高级概念

Python 提供了对数据结构的全面支持,包括存储数据、访问数据以进行处理和检索的关键工具。在 第四章 高级编程的 Python 库 中,我们讨论了 Python 中可用的数据结构对象。在本节中,我们将介绍一些高级概念,例如字典中的字典以及如何使用数据结构进行推导。我们将从在字典中嵌入字典开始。

在字典中嵌入字典

在一个字典中嵌套另一个字典或嵌套字典是将一个字典放入另一个字典的过程。嵌套字典在许多现实世界的例子中非常有用,尤其是在你处理和转换一种格式到另一种格式的数据时。

图 6.1 展示了一个嵌套字典。根字典在键1和键3处有两个字典。键1对应的字典内部还有其他字典。键3对应的字典是一个常规字典,其条目是键值对:

图 6.1:字典中嵌套字典的示例

img/B17189_06_01.jpg

图 6.1:字典中嵌套字典的示例

图 6.1 中显示的根字典可以写成以下形式:

root_dict = {'1': {'A': {dictA}, 'B':{dictB}},
             '2': [list2],
             '3': {'X': val1,'Y':val2,'Z': val3}
            }

这里,我们创建了一个根字典,其中包含字典对象和列表对象的混合。

创建或定义嵌套字典

可以通过在花括号内放置以逗号分隔的字典来定义或创建嵌套字典。为了演示如何创建嵌套字典,我们将创建一个学生字典。每个学生条目将包含另一个字典,其中包含nameage作为其元素,这些元素映射到他们的学生编号:

# dictionary1.py
dict1 = {100:{'name':'John', 'age':24},
         101:{'name':'Mike', 'age':22},
         102:{'name':'Jim', 'age':21} }
print(dict1)
print(dict1.get(100))

接下来,我们将学习如何动态创建字典以及如何添加或更新嵌套字典元素。

向嵌套字典中添加内容

要动态创建字典或在现有嵌套字典中添加元素,我们可以使用多种方法。在下面的代码示例中,我们将使用三种不同的方法来构建嵌套字典。它们与我们在dictionary1.py模块中定义的方法相同:

  • 在第一种情况下,我们将通过直接分配键值对项并将它分配给根字典中的一个键来构建一个内部字典(即student101)。在可能的情况下,这是首选方法,因为代码既易于阅读也易于管理。

  • 在第二种情况下,我们创建了一个空的内部字典(即student102),并通过赋值语句将值分配给键。当值可以通过其他数据结构获得时,这也是一个首选方法。

  • 在第三种情况下,我们直接为根字典的第三个键初始化一个空目录。初始化过程完成后,我们使用双重索引(即两个键)分配值:第一个键用于根字典,第二个键用于内部字典。这种方法使代码简洁,但如果代码的可读性对维护很重要,则不是首选方法。

这三个不同情况的完整代码示例如下:

# dictionary2.py
#defining inner dictionary 1
student100 = {'name': 'John', 'age': 24}
#defining inner dictionary 2
student101 = {}
student101['name'] = 'Mike'
student101['age'] = '22'
#assigning inner dictionaries 1 and 2 to a root dictionary
dict1 = {}
dict1[100] = student100
dict1[101] = student101
#creating inner dictionary directly inside a root \
dictionary
dict1[102] = {}
dict1[102]['name'] = 'Jim'
dict1[102]['age'] = '21'
print(dict1)
print(dict1.get(102))

接下来,我们将讨论如何访问嵌套字典中的不同元素。

访问嵌套字典中的元素

如我们之前讨论的,要在字典内部添加值和字典,我们可以使用双重索引。或者,我们可以使用字典对象的get方法。同样的方法适用于从内部字典访问不同元素。以下是一个示例代码,说明如何使用get方法和双重索引从内部字典中访问不同元素:

# dictionary3.py
dict1 = {100:{'name':'John', 'age':24},
         101:{'name':'Mike', 'age':22},
         102:{'name':'Jim', 'age':21} }
print(dict1.get(100))
print(dict1.get(100).get('name'))
print(dict1[101])
print(dict1[101]['age'])

接下来,我们将检查如何从内部字典中删除内部字典或键值对项。

从嵌套字典中删除

要删除字典或字典中的元素,我们可以使用通用的del函数,或者我们可以使用dictionary对象的pop方法。在下面的示例代码中,我们将展示del函数和pop方法的使用:

# dictionary4.py
dict1 = {100:{'name':'John', 'age':24},
         101:{'name':'Mike', 'age':22},
         102:{'name':'Jim', 'age':21} }
del (dict1[101]['age'])
print(dict1)
dict1[102].pop('age')
print(dict1)

在下一节中,我们将讨论理解如何帮助处理来自不同数据结构类型的数据。

使用推导

理解是一种快速构建新序列(如列表、集合和字典)的方法,这些新序列是从现有序列中生成的。Python 支持四种不同类型的理解,如下所示:

  • 列表推导

  • 字典推导

  • 集合推导

  • 生成器推导

我们将在以下小节中简要概述每种理解类型,并附上代码示例。

列表推导

列表推导涉及使用循环和(如果需要)条件语句创建动态列表。

几个使用列表推导的示例将帮助我们更好地理解这个概念。在第一个示例(即 list1.py)中,我们将通过给原始列表的每个元素加 1 来创建一个新的列表。以下是代码片段:

#list1.py
list1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]
list2 = [x+1 for x in list1]
print(list2)

在这种情况下,新列表将通过使用 x+1 表达式来创建,其中 x 是原始列表中的一个元素。这等同于以下传统代码:

list2 = []
for x in list1:
    list2.append(x+1)

使用列表推导,我们可以只用一行代码就实现这三行代码。

在第二个示例(即 list2.py)中,我们将从原始的 1 到 10 的数字列表中创建一个新的列表,但只包含偶数。我们可以通过简单地在之前的代码示例中添加一个条件来实现这一点,如下所示:

#list2.py
list1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
list2 = [x for x in list1 if x % 2 == 0]
print(list2)

如你所见,条件被添加到推导表达式的末尾。接下来,我们将讨论如何使用推导来构建字典。

字典推导

也可以通过使用字典推导来创建字典。字典推导与列表推导类似,是一种从另一个字典中条件性地选择或转换条目的方法。以下代码片段展示了如何从一个现有字典中创建一个新字典,该字典包含小于或等于 200 的元素,并且通过将每个选定的值除以 2 来实现。请注意,值在推导表达式中也被转换回整数:

#dictcomp1.py
dict1 = {'a': 100, 'b': 200, 'c': 300}
dict2 = {x:int(y/2) for (x, y) in dict1.items() if y <=200}
print(dict2)

如果使用传统的编程方式来实现,这段字典推导代码等同于以下代码片段:

Dict2 = {}
for x,y in dict1.items():
    if y <= 200:
        dict2[x] = int(y/2)

注意,推导可以显著减少代码量。接下来,我们将讨论集合推导。

集合推导

与列表推导类似,也可以使用集合推导来创建集合。使用集合推导创建集合的代码语法与列表推导类似。区别在于我们将使用花括号而不是方括号。在以下代码片段中,你可以查看使用集合推导从一个列表中创建集合的示例:

#setcomp1.py
list1 = [1, 2, 6, 4, 5, 6, 7, 8, 9, 10, 8]
set1 = {x for x in list1 if x % 2 == 0}
print(set1)

这段集合推导代码等同于以下使用传统编程方式的代码片段:

Set1 = set()
for x in list1:
    if x % 2 == 0:
        set1.add(x)

如预期的那样,集合中会丢弃重复的条目。

这就结束了我们对 Python 中不同数据结构可用的推导类型的讨论。接下来,我们将讨论数据结构可用的过滤选项。

介绍使用 pandas DataFrame 的高级技巧

pandas 是一个开源的 Python 库,它提供了高性能数据操作工具,使数据分析变得快速且简单。pandas 库的典型用途包括重塑、排序、切片、聚合和合并数据。

pandas 库建立在NumPy库之上,NumPy 是另一个用于处理数组的 Python 库。NumPy 库比传统的 Python 列表要快得多,因为数据在内存中存储在一个连续的位置,而传统列表则不是这样。

pandas 库处理三个关键数据结构,如下所示:

  • Series: 这是一个单维数组样式的对象,包含数据数组和数据标签数组。数据标签数组称为index。如果用户没有明确指定,index可以使用从0 到 n-1的整数自动指定。

  • DataFrame: 这是表格数据的表示,例如包含列列表的工作表。DataFrame 对象有助于在行和列中存储和操作表格数据。有趣的是,DataFrame 对象既有列索引也有行索引。

  • Panel: 这是一个三维数据容器。

DataFrame 是数据分析中使用的关键数据结构。在本节的剩余部分,我们将广泛使用 DataFrame 对象在我们的代码示例中。在我们讨论有关这些 pandas DataFrame 对象的任何高级技巧之前,我们将快速回顾 DataFrame 对象的基本操作。

学习 DataFrame 操作

我们将首先创建 DataFrame 对象。创建 DataFrame 的方法有很多,例如从字典、CSV 文件、Excel 表或从 NumPy 数组创建。其中一种最简单的方法是使用字典中的数据作为输入。以下代码片段显示了如何根据存储在字典中的每周天气数据构建 DataFrame 对象:

# pandas1.py
import pandas as pd
weekly_data = {'day':['Monday','Tuesday', 'Wednesday', \
'Thursday','Friday', 'Saturday', 'Sunday'],
               'temperature':[40, 33, 42, 31, 41, 40, 30],
               'condition':['Sunny','Cloudy','Sunny','Rain'
               ,'Sunny','Cloudy','Rain']
        }
df = pd.DataFrame(weekly_data)
print(df)

控制台输出将显示 DataFrame 的内容如下:

图 6.2 – DataFrame 的内容

图 6.2 – DataFrame 的内容

pandas 库在方法和属性方面非常丰富。然而,本节的范围超出了涵盖所有这些内容。相反,我们将快速总结 DataFrame 对象常用的属性和方法,以便在使用即将到来的代码示例之前刷新我们的知识:

  • index: 此属性提供了 DataFrame 对象的索引(或标签)列表。

  • columns: 此属性提供了 DataFrame 对象中的列列表。

  • size: 这返回 DataFrame 对象的大小,即行数乘以列数。

  • shape: 这为我们提供了一个表示 DataFrame 对象维度的元组。

  • axes: 此属性返回一个表示 DataFrame 对象轴的列表。简单来说,它包括行和列。

  • describe: 此强大方法生成统计数据,如计数、平均值、标准差以及最小值和最大值。

  • head: 此方法从类似于文件中 head 命令的 DataFrame 对象返回n(默认=5)行。

  • tail:此方法从 DataFrame 对象返回最后 n 行(默认 = 5)。

  • drop_duplicates:此方法基于 DataFrame 中的所有列删除重复行。

  • dropna:此方法从 DataFrame 中删除缺失值(如行或列)。通过传递适当的参数给此方法,我们可以删除行或列。此外,我们可以设置是否基于缺失值的单次出现或仅当行或列中的所有值都缺失时删除行或列。

  • sort_values:此方法可用于根据单个或多个列对行进行排序。

在以下章节中,我们将回顾 DataFrame 对象的一些基本操作。

设置自定义索引

列标签(索引)通常是按照提供的字典中的数据或根据使用的其他输入数据流添加的。我们可以使用以下选项之一更改 DataFrame 的索引:

  • 通过使用简单语句,如以下示例,将其中一个数据列设置为索引,例如之前提到的 day

    df_new = df.set_index('day')
    

    DataFrame 将开始使用 day 列作为索引列,其内容如下:

    ![图 6.3 – 使用 day 列作为索引后 DataFrame 的内容 img/B17189_06_03.jpg

图 6.3 – 使用 day 列作为索引后 DataFrame 的内容

  • 通过提供列表手动设置索引,如下所示代码片段:

    # pandas2.py
    weekly_data = <same as previous example>
    df = pd.DataFrame(weekly_data)
    df.index = ['MON','TUE','WED','THU','FRI','SAT','SUN']
    print(df)
    

    使用此代码片段,DataFrame 将开始使用我们通过列表对象提供的索引。DataFrame 的内容将显示此更改如下:

![图 6.4 – 设置索引列的自定义条目后 DataFrame 的内容img/B17189_06_04.jpg

图 6.4 – 设置索引列的自定义条目后 DataFrame 的内容

接下来,我们将讨论如何使用特定的索引和列在 DataFrame 中进行导航。

在 DataFrame 内部导航

有几十种方法可以从 DataFrame 对象中获取一行数据或特定位置的数据。在 DataFrame 内部导航的典型方法有 lociloc 方法。我们将通过使用与上一个示例相同的样本数据,探索如何使用这些方法在 DataFrame 对象中导航的几种选项:

# pandas3.py
import pandas as pd
weekly_data = <same as in pandas1.py example>
df = pd.DataFrame(weekly_data)
df.index = ['MON', 'TUE','WED','THU','FRI','SAT','SUN']

接下来,我们将讨论一些关于如何在 DataFrame 对象中选择行或位置的技巧,并附上代码示例:

  • 我们可以使用 loc 方法通过索引标签选择一行或多行。索引标签以单个项或列表的形式提供。在以下代码片段中,我们展示了如何选择一行或多行的两个示例:

    print(df.loc['TUE'])
    print(df.loc[['TUE','WED']])
    
  • 我们可以使用行索引标签和列标签从 DataFrame 对象的某个位置选择一个值,如下所示:

    print(df.loc['FRI','temp'])
    
  • 我们也可以通过不提供任何标签而使用索引值来选择一行:

    #Provide a row with index 2
    print(df.iloc[2])
    
  • 我们可以通过将 DataFrame 对象视为二维数组,使用行索引值和列索引值来从位置选择一个值。在下一个代码片段中,我们将从行索引 = 2 和列索引 = 2 的位置获取一个值:

    print(df.iloc[2,2])
    

接下来,我们将讨论如何向 DataFrame 对象中添加一行或一列。

向 DataFrame 中添加行或列

向 DataFrame 对象中添加一行的最简单方法是将一个值列表赋给一个索引位置或索引标签。例如,我们可以通过以下语句为前一个示例(即 pandas3.py)添加一个带有 TST 标签的新行:

 df.loc['TST'] = ['Test day 1', 50, 'NA']

重要的是要注意,如果行标签已经在 DataFrame 对象中存在,相同的代码行可以更新带有新值的行。

如果我们不是使用索引标签而是默认索引,我们可以使用索引号通过以下代码行来更新现有行或添加新行:

df.loc[8] = ['Test day 2', 40, 'NA']

以下是一个完整的代码示例供参考:

# pandas4.py
import pandas as pd
weekly_data = <same as in pandas1.py example>
df = pd.DataFrame(weekly_data)
df.index = ['MON', 'TUE','WED','THU','FRI','SAT','SUN']
df.loc['TST1'] = ['Test day 1', 50, 'NA']
df.loc[7] = ['Test day 2', 40, 'NA']
print(df)

要向 DataFrame 对象中添加新列,pandas 库中提供了多种选项。我们将仅展示三种选项,如下:

  • 在列标签旁边添加值列表:此方法将在现有列之后添加列。如果我们使用现有的列标签,此方法也可以用于更新或替换现有列。

  • 使用 insert 方法:此方法将接受一个标签和一个值列表作为参数。当您想在任何位置插入列时,此方法特别有用。请注意,此方法不允许在 DataFrame 对象中已存在具有相同标签的列的情况下插入列。这意味着此方法不能用于更新现有列。

  • 使用 assign 方法:当您想一次添加多个列时,此方法很有用。如果我们使用现有的列标签,此方法也可以用于更新或替换现有列。

在以下代码示例中,我们将使用三种方法中的所有三种来向 DataFrame 对象中插入新列:

# pandas5.py
import pandas as pd
weekly_data = <same as in pandas1.py example>
df = pd.DataFrame(weekly_data)
#Adding a new column and then updating it
df['Humidity1'] = [60, 70, 65,62,56,25,'']
df['Humidity1'] = [60, 70, 65,62,56,251,'']
#Inserting a column at column index of 2 using the insert method
df.insert(2, "Humidity2",[60, 70, 65,62,56,25,''])
#Adding two columns using the assign method
df1 = df.assign(Humidity3 = [60, 70, 65,62,56,25,''],  Humidity4 = [60, 70, 65,62,56,25,''])
print(df1)

接下来,我们将评估如何从 DataFrame 对象中删除行和列。

从 DataFrame 中删除索引、行或列

删除索引相对简单,您可以使用 reset_index 方法来完成。然而,reset_index 方法会添加默认索引并保留自定义索引列作为数据列。要完全删除自定义索引列,我们必须使用 drop 参数与 reset_index 方法一起使用。以下代码片段使用了 reset_index 方法:

# pandas6.py
import pandas as pd
weekly_data = <same as in pandas1.py example>
df = pd.DataFrame(weekly_data)
df.index = ['MON', 'TUE','WED','THU','FRI','SAT','SAT']
print(df)
print(df.reset_index(drop=True))

要从 DataFrame 对象中删除重复行,我们可以使用 drop_duplicate 方法。要删除特定的行或列,我们可以使用 drop 方法。在以下代码示例中,我们将删除任何带有 SATSUN 标签的行以及任何带有 condition 标签的列:

#pandas7.py
import pandas as pd
weekly_data = <same as in pandas1.py example>
df = pd.DataFrame(weekly_data)
df.index = ['MON', 'TUE','WED','THU','FRI','SAT','SUN']
print(df)
df1= df.drop(index=['SUN','SAT'])
df2= df1.drop(columns=['condition'])
print(df2)

接下来,我们将检查如何重命名索引或列。

在 DataFrame 中重命名索引和列

要重命名索引或列标签,我们将使用 rename 方法。以下是如何重命名索引和列的代码示例:

#pandas8.py
import pandas as pd
weekly_data = <same as in pandas1.py example>
df = pd.DataFrame(weekly_data)
df.index = ['MON', 'TUE','WED','THU','FRI','SAT','SUN']
df1=df.rename(index={'SUN': 'SU', 'SAT': 'SA'})
df2=df1.rename(columns={'condition':'cond'})
print(df2)

重要的是要注意,当前标签和新标签作为字典提供。接下来,我们将讨论一些使用 DataFrame 对象的高级技巧。

学习 DataFrame 对象的高级技巧

在上一节中,我们评估了可以在 DataFrame 对象上执行的基本操作。在本节中,我们将探讨 DataFrame 对象数据评估和转换的下一级操作。这些操作将在以下子节中讨论。

替换数据

一个常见的需求是将数值数据或字符串数据替换为另一组值。pandas 库提供了许多选项来执行此类数据替换。这些操作中最受欢迎的方法是使用 at 方法。at 方法提供了一种简单的方式来访问或更新 DataFrame 中的任何单元格中的数据。对于批量替换操作,您还可以使用 replace 方法,并且我们可以以多种方式使用此方法。例如,我们可以使用此方法将一个数字替换为另一个数字或一个字符串替换为另一个字符串,或者我们可以替换任何匹配正则表达式的项。此外,我们可以使用此方法替换通过列表或字典提供的任何条目。在以下代码示例(即 pandastrick1.py)中,我们将涵盖这些替换选项的大部分。对于此代码示例,我们将使用与之前代码示例相同的 DataFrame 对象。以下是示例代码:

# pandastrick1.py
import pandas as pd
weekly_data = <same as in pandas1.py example>
df = pd.DataFrame(weekly_data)

接下来,我们将逐一探索对这个 DataFrame 对象的几个替换操作:

  • 使用以下语句在 DataFrame 对象中将所有出现的 40 的数值替换为 39

    df.replace(40,39, inplace=True)
    
  • 使用以下语句在 DataFrame 对象中将所有出现的 Sunny 字符串替换为 Sun

    df.replace("Sunny","Sun",inplace=True)
    
  • 使用以下语句替换基于正则表达式的字符串(目的是将 Cloudy 替换为 Cloud):

    df.replace(to_replace="^Cl.*",value="Cloud", inplace=True,regex=True)
    #or we can apply on a specific column as well. 
    df["condition"].replace(to_replace="^Cl.*",value="Cloud", inplace=True,regex=True)
    

    注意,使用 to_replacevalue 参数标签是可选的。

  • 使用以下语句将表示为列表的多个字符串替换为另一个字符串列表:

    df.replace(["Monday","Tuesday"],["Mon","Tue"], inplace=True)
    

    在此代码中,我们将 MondayTuesday 替换为 MonTue

  • 使用字典中的键值对替换 DataFrame 对象中的多个字符串。您可以通过以下语句来完成此操作:

    df.replace({"Wednesday":"Wed","Thursday":"Thu"}, inplace=True)
    

    在这种情况下,字典的键(即 WednesdayThursday)将被它们对应的值(即 WedThu)替换。

  • 使用多个字典替换特定列中的字符串。您可以通过使用列名作为字典的键以及如下示例语句来完成此操作:

    df.replace({"day":"Friday"}, {"day":"Fri"}, inplace=True)
    

    在这种情况下,第一个字典用于指示列名和要替换的值。第二个字典用于指示相同的列名,但具有将替换原始值的值。在我们的例子中,我们将 day 列中所有 Friday 的实例替换为 Fri 的值。

  • 使用嵌套字典替换多个字符串的出现。你可以通过以下代码示例来完成此操作:

    df.replace({"day":{"Saturday":"Sat", "Sunday":"Sun"},
                "condition":{"Rainy":"Rain"}}, inplace=True)
    

    在这种情况下,外层字典(在我们的代码示例中包含 daycondition 键)用于标识此操作的列,内层字典用于存储要替换的数据以及替换值。通过使用这种方法,我们在 day 列中将 SaturdaySunday 替换为 SatSun,在 condition 列中将 Rainy 字符串替换为 Rain

包含所有这些示例操作的完整代码位于本章源代码中的 pandastrick1.py。请注意,我们可以触发跨 DataFrame 对象的替换操作,或者将其限制在特定的列或行。

重要提示

inplace=True 参数与所有 replace 方法调用一起使用。此参数用于在同一个 DataFrame 对象内设置 replace 方法的输出。默认选项是返回一个新的 DataFrame 对象,而不更改原始对象。此参数在许多 DataFrame 方法中可用,以方便使用。

将函数应用于 DataFrame 对象的列或行

有时,我们在开始数据分析之前想要清理数据、调整数据或转换数据。有一种简单的方法可以在 DataFrame 上应用某种类型的函数,使用 applyapplymapmap 方法。apply 方法适用于列或行,而 applymap 方法对整个 DataFrame 的每个元素进行操作。相比之下,map 方法对单个序列的每个元素进行操作。现在,我们将通过几个代码示例来讨论 applymap 方法的使用。

通常,将数据导入 DataFrame 对象时可能需要进行一些清理。例如,它可能有尾随或前导空格、换行符或任何不需要的字符。这些可以通过使用 map 方法和列序列上的 lambda 函数轻松地从数据中删除。lambda 函数用于列的每个元素。在我们的代码示例中,首先,我们将删除尾随空格、句点和逗号。然后,我们将删除 condition 列的前导空格、下划线和破折号。

在清理 condition 列中的数据之后,下一步是从 temp 列的值创建一个新的 temp_F 列,并将它们从摄氏单位转换为华氏单位。请注意,我们将为此转换使用另一个 lambda 函数,并使用 apply 方法。当我们从 apply 方法得到结果时,我们将它存储在一个新的列标签 temp_F 中,以创建一个新的列。以下是完整的代码示例:

# pandastrick2.py
import pandas as pd
weekly_data = {'day':['Monday','Tuesday', 'Wednesday',                    'Thursday','Friday', 'Saturday', 'Sunday'],
                'temp':[40, 33, 42, 31, 41, 40, 30],
                'condition':['Sunny,','_Cloudy ',                'Sunny','Rainy','--Sunny.','Cloudy.','Rainy']
        }
df = pd.DataFrame(weekly_data)
print(df)
df["condition"] = df["condition"].map(
                lambda x: x.lstrip('_- ').rstrip(',. '))
df["temp_F"] = df["temp"].apply(lambda x: 9/5*x+32 )
print(df)

注意,对于前面的代码示例,我们提供了与之前示例相同的输入数据,除了我们在 condition 列的数据中添加了尾随和前导字符。

在 DataFrame 对象中查询行

要根据某一列的值查询行,一种常见的方法是应用使用 ANDOR 逻辑运算的过滤器。然而,对于像在值范围内搜索行这样的简单需求,这很快就会变得杂乱无章。pandas 库提供了一个更干净的工具:between 方法,它在某种程度上类似于 SQL 中的 between 关键字。

以下代码示例使用了我们在上一个示例中使用的相同的 weekly_data DataFrame 对象。首先,我们将展示传统过滤器的使用,然后我们将展示使用 between 方法查询温度值在 30 到 40(包括)之间的行:

# pandastrick3.py
import pandas as pd
weekly_data = <same as in pandas1.py example>
df = pd.DataFrame(weekly_data)
print(df[(df.temp >= 30) & (df.temp<=40)])
print(df[df.temp.between(30,40)])

我们得到了两种方法都相同的控制台输出。然而,使用 between 方法比编写条件过滤器要方便得多。

在 pandas 库中,基于文本数据的行查询也得到了很好的支持。这可以通过在 DataFrame 对象的字符串类型列上使用 str 访问器来实现。例如,如果我们想根据 RainySunny 等条件在我们的 weekly_data DataFrame 对象中搜索行,我们既可以编写传统的过滤器,也可以在具有 contains 方法的列上使用 str 访问器。以下代码示例说明了使用这两种选项获取 condition 列中数据值为 RainySunny 的行:

# pandastrick4.py
import pandas as pd
weekly_data = <same as in pandas1.py example>
df = pd.DataFrame(weekly_data)
print(df[(df.condition=='Rainy') | (df.condition=='Sunny')])
print(df[df['condition'].str.contains('Rainy|Sunny')])

如果你运行前面的代码,你会发现我们用于搜索数据的两种方法都得到了相同的控制台输出。

获取 DataFrame 对象数据的统计信息

要获取诸如集中趋势、标准差和形状之类的统计数据,我们可以使用 describe 方法。describe 方法的数值列输出包括以下内容:

  • count

  • mean

  • standard deviation

  • min

  • max

  • 25th 百分位数,50th 百分位数,75th 百分位数

可以通过使用 percentiles 参数并指定所需的分解来更改百分位数的默认分解。

如果 describe 方法用于非数值数据,例如字符串,我们将得到 countuniquetopfreqtop 值是最常见的值,而 freq 是最常见的值频率。默认情况下,除非我们提供带有适当值的 include 参数,否则 describe 方法只会评估数值列。

在下面的代码示例中,我们将评估同一个 weekly_date DataFrame 对象:

  • 使用或不用 include 参数的 describe 方法

  • 使用 describe 方法的 percentiles 参数

  • 使用 groupby 方法按列分组数据,然后在其上使用 describe 方法

完整的代码示例如下:

# pandastrick5.py
import pandas as pd
import numpy as np
pd.set_option('display.max_columns', None)
weekly_data = <same as in pandas1.py example>
df = pd.DataFrame(weekly_data)
print(df.describe())
print(df.describe(include="all"))
print(df.describe(percentiles=np.arange(0, 1, 0.1)))
print(df.groupby('condition').describe(percentiles=np.arange(0,   1, 0.1)))

注意,我们在开始时更改了 pandas 库的 max_columns 选项,以便在控制台输出中显示我们预期的所有列。如果没有这样做,groupby 方法的控制台输出中的一些列将被截断。

这就结束了我们对 DataFrame 对象高级技巧的讨论。这套技巧和提示将使任何人都能开始使用 pandas 库进行数据分析。对于额外的先进概念,我们建议您参考 pandas 库的官方文档。

摘要

在本章中,我们介绍了一些在您想要编写高效且简洁的 Python 程序时非常重要的高级技巧。我们从高级函数,如 mapper、reducer 和 filter 函数开始。我们还讨论了函数的几个高级概念,如内部函数、lambda 函数和装饰器。随后,我们讨论了如何使用数据结构,包括嵌套字典和推导式。最后,我们回顾了 DataFrame 对象的基本操作,然后使用一些 DataFrame 对象的高级操作评估了一些用例。

本章主要关注如何使用 Python 的高级概念的实际知识和经验。这对于任何想要开发 Python 应用程序的人来说都很重要,尤其是对于数据分析。本章提供的代码示例对于您开始学习可用于函数、数据结构和 pandas 库的高级技巧非常有帮助。

在下一章中,我们将探讨 Python 中的多进程和多线程。

问题

  1. mapfilterreduce 函数中哪些是 Python 的内置函数?

  2. 标准装饰器是什么?

  3. 对于大数据集,您更愿意使用生成器推导式还是列表推导式?

  4. 在 pandas 库的上下文中,DataFrame 是什么?

  5. pandas 库方法中 inplace 参数的目的是什么?

进一步阅读

  • 《精通 Python 设计模式》,作者:Sakis Kasampalis

  • 《Python 数据分析》,作者:Wes McKinney

  • 《使用 Pandas 进行动手数据分析》(第二版),作者:Stefanie Molin

  • 官方 Pandas 文档,可在pandas.pydata.org/docs/找到

答案

  1. mapfilter函数是内置的。

  2. 标准装饰器是没有任何参数的装饰器。

  3. 在这种情况下,推荐使用生成器推导式。因为它内存效率高,值是逐个生成的。

  4. DataFrame 是表格数据的表示,如电子表格,是使用 pandas 库进行数据分析的常用对象。

  5. 当 pandas 库方法中的inplace参数设置为True时,操作的输出结果将保存到应用操作的同一 DataFrame 对象上。

第三部分:超越单线程的扩展

在本书的这一部分,我们的旅程将转向可扩展应用的编程。典型的 Python 解释器在一个单线程上运行,该线程在一个单独的进程中运行。在我们旅程的这一部分,我们将讨论如何将 Python 扩展到单个进程上的单个线程之外。为了做到这一点,我们首先研究单机上的多线程、多进程和异步编程。然后,我们探索如何超越单机,使用 Apache Spark 在集群上运行我们的应用。之后,我们研究使用云计算环境,专注于应用,并将基础设施管理留给云服务提供商。

本节包含以下章节:

  • 第七章, 多进程、多线程和异步编程

  • 第八章**, 使用集群扩展 Python

  • 第九章**, 云端的 Python 编程

第七章:第七章:多进程、多线程和异步编程

我们可以编写高效且优化的代码以加快执行时间,但程序运行过程中可用的资源量总是有限的。然而,我们仍然可以通过在相同机器或不同机器上并行执行某些任务来提高应用程序的执行时间。本章将涵盖在单台机器上运行的应用程序的并行处理或并发性。我们将在下一章中介绍使用多台机器进行并行处理。在本章中,我们关注 Python 中用于实现并行处理的内置支持。我们将从 Python 中的多线程开始,然后讨论多进程。之后,我们将讨论如何使用异步编程设计响应式系统。对于每种方法,我们将设计并讨论一个并发应用程序以从 Google Drive 目录下载文件的案例研究。

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

  • 理解 Python 中的多线程及其限制

  • 超越单个 CPU – 实现多进程

  • 使用异步编程实现响应式系统

完成本章后,您将了解使用内置 Python 库构建多线程或多进程应用程序的不同选项。这些技能将帮助您构建不仅更高效的应用程序,还能构建面向大规模用户的应用程序。

技术要求

以下为本章的技术要求:

  • Python 3(3.7 或更高版本)

  • 一个 Google Drive 账户

  • 为您的 Google Drive 账户启用了 API 密钥

本章的示例代码可在github.com/PacktPublishing/Python-for-Geeks/tree/master/Chapter07找到。

我们将首先从 Python 中的多线程概念开始讨论。

理解 Python 中的多线程及其限制

线程是操作系统进程中的一个基本执行单元,它包含自己的程序计数器、一个堆栈和一组寄存器。应用程序进程可以使用多个线程构建,这些线程可以同时运行并共享相同的内存。

对于程序中的多线程,一个进程的所有线程共享公共代码和其他资源,例如数据和系统文件。对于每个线程,所有相关信息均存储在操作系统内核内部的数据结构中,这个数据结构称为线程控制块(TCB)。TCB 具有以下主要组件:

  • 程序计数器(PC):用于跟踪程序的执行流程。

  • 系统寄存器(REG):这些寄存器用于存储变量数据。

  • 堆栈:堆栈是一个寄存器数组,用于管理执行历史。

线程的解剖结构在 图 7.1 中展示,有三个线程。每个线程都有自己的 PC、堆栈和 REG,但与其他线程共享代码和其他资源:

![图 7.1 – 进程中的多个线程

![img/B17189_07_01.jpg]

图 7.1 – 进程中的多个线程

TCB 还包含一个线程标识符、线程状态(例如运行、等待或停止),以及指向它所属进程的指针。多线程是操作系统的一个概念。它是通过系统内核提供的一项功能。操作系统简化了在相同进程上下文中同时执行多个线程,使它们能够共享进程内存。这意味着操作系统完全控制哪个线程将被激活,而不是应用程序。我们需要强调这一点,以便在比较不同并发选项的后续讨论中提及。

当线程在单 CPU 机器上运行时,操作系统实际上会切换 CPU 从一个线程到另一个线程,使得线程看起来是并发运行的。在单 CPU 机器上运行多个线程有什么优势吗?答案是是和否,这取决于应用程序的性质。对于仅使用本地内存运行的应用程序,可能没有优势;实际上,由于在单个 CPU 上切换线程的开销,它可能表现出更低的性能。但对于依赖于其他资源的应用程序,由于 CPU 的更好利用,执行可以更快:当一个线程等待另一个资源时,另一个线程可以利用 CPU。

当在多处理器或多 CPU 核心上执行多个线程时,它们可以并发执行。接下来,我们将讨论 Python 多线程编程的限制。

什么是 Python 的盲点?

从编程的角度来看,多线程是同时运行应用程序不同部分的一种方法。Python 使用多个内核线程,可以运行 Python 用户线程。但 Python 实现(CPython)允许线程通过一个全局锁访问 Python 对象,这个锁被称为 全局解释器锁(GIL)。简单来说,GIL 是一个互斥锁,它允许一次只有一个线程使用 Python 解释器,并阻止所有其他线程。这是必要的,以保护 Python 中每个对象管理的引用计数,防止垃圾回收。如果没有这种保护,如果多个线程同时更新,引用计数可能会被破坏。这种限制的原因是为了保护内部解释器数据结构和不是线程安全的第三方 C 代码。

重要提示

这种全局解释器锁(GIL)的限制在 Jython 和 IronPython 中不存在,它们是 Python 的其他实现。

这种 Python 限制可能会给我们一种印象,即编写 Python 多线程程序没有优势。这并不正确。我们仍然可以在 Python 中编写并发或并行运行的代码,我们将在我们的案例研究中看到这一点。在以下情况下,多线程可能是有益的:

  • I/O 密集型任务:当与多个 I/O 操作一起工作时,通过使用多个线程运行任务,总是有改进性能的空间。当一个线程正在等待 I/O 资源的响应时,它将释放 GIL 并让其他线程工作。原始线程将在 I/O 资源响应到达时立即唤醒。

  • 响应式 GUI 应用程序:对于交互式 GUI 应用程序,有必要有一个设计模式来显示在后台运行的任务(例如,下载文件)的进度,并且允许用户在后台运行一个或多个任务的同时,工作在其他 GUI 功能上。所有这些都可以通过使用为用户通过 GUI 发起的操作创建的单独线程来实现。

  • 多用户应用程序:线程也是构建多用户应用程序的先决条件。Web 服务器和文件服务器是这样的应用程序的例子。一旦此类应用程序的主线程收到新的请求,就会创建一个新的线程来处理请求,而主线程在后台监听新的请求。

在讨论多线程应用程序的案例研究之前,介绍 Python 多线程编程的关键组件是很重要的。

学习 Python 多线程编程的关键组件

Python 中的多线程允许我们并发运行程序的不同组件。为了创建应用程序的多个线程,我们将使用 Python 的threading模块,接下来将描述该模块的主要组件。

我们将首先讨论 Python 中的threading模块。

线程模块

threading模块是一个标准模块,它提供了简单且易于使用的多线程构建方法。在底层,此模块使用较低级别的_thread模块,这在 Python 早期版本中是多线程的一个流行选择。

要创建一个新线程,我们将创建一个Thread类的对象,该对象可以接受一个作为target属性要执行的功能名称,以及作为args属性传递给函数的参数。一个线程可以被赋予一个名称,可以在创建时使用构造函数的name参数来设置。

在创建Thread类的对象之后,我们需要使用start方法启动线程。为了使主程序或线程等待新创建的线程对象完成,我们需要使用join方法。join方法确保主线程(调用线程)等待被调用join方法的线程完成其执行。

为了解释创建、启动和等待线程执行完成的过程,我们将创建一个包含三个线程的简单程序。下面展示了这样一个程序的完整代码示例:

# thread1.py to create simple threads with function
from threading import current_thread, Thread as Thread
from time import sleep
def print_hello():
    sleep(2)
    print("{}: Hello".format(current_thread().name))
def print_message(msg):
    sleep(1)
    print("{}: {}".format(current_thread().name, msg))
# create threads
t1 = Thread(target=print_hello, name="Th 1")
t2 = Thread(target=print_hello, name="Th 2")
t3 = Thread(target=print_message, args=["Good morning"], 
        name="Th 3")
# start the threads
t1.start()
t2.start()
t3.start()
# wait till all are done
t1.join()
t2.join()
t3.join()

在这个程序中,我们实现了以下功能:

  • 我们创建了两个简单的函数print_helloprint_message,这些函数将由线程使用。我们在两个函数中都使用了time模块中的sleep函数,以确保两个函数在不同的时间完成它们的执行时间。

  • 我们创建了三个Thread对象。其中两个对象将执行一个函数(print_hello),以展示线程之间的代码共享,第三个线程对象将使用第二个函数(print_message),该函数也接受一个参数。

  • 我们使用start方法逐个启动所有三个线程。

  • 我们通过使用join方法等待每个线程完成。

可以将Thread对象存储在列表中,以简化使用for循环的startjoin操作。该程序的控制台输出将如下所示:

Th 3: Good morning
Th 2: Hello
Th 1: Hello

线程 1 和线程 2 的睡眠时间比线程 3 长,因此线程 3 将始终先完成。线程 1 和线程 2 的完成顺序取决于哪个线程首先获得处理器。

重要提示

默认情况下,join方法会无限期地阻塞调用线程。但我们可以将超时时间(以秒为单位)作为join方法的参数。这将使调用线程仅在超时期间被阻塞。

在讨论更复杂的案例研究之前,我们将回顾几个更多概念。

守护线程

在正常的应用程序中,我们的主程序会隐式地等待所有其他线程完成执行。然而,有时我们需要在后台运行一些线程,以便它们可以在不阻塞主程序终止的情况下运行。这些线程被称为守护线程。只要主程序(包含非守护线程)在运行,这些线程就会保持活跃状态,一旦非守护线程退出,就可以安全地终止守护线程。在不需要担心线程在执行过程中意外死亡而丢失或损坏数据的情况下,守护线程的使用非常普遍。

可以通过以下两种方法之一将线程声明为守护线程:

  • 使用构造函数将daemon属性设置为Truedaemon = True)。

  • 在线程实例上设置daemon属性为Truethread.daemon = True)。

如果一个线程被设置为守护线程,我们启动线程然后忘记它。当调用它的程序退出时,线程将被自动杀死。

下面的代码展示了同时使用守护线程和非守护线程的使用方法:

#thread2.py to create daemon and non-daemon threads
from threading import current_thread, Thread as Thread
from time import sleep
def daeom_func():
    #print(threading.current_thread().isDaemon())
    sleep(3)
    print("{}: Hello from daemon".format           (current_thread().name))
def nondaeom_func():
    #print(threading.current_thread().isDaemon())
    sleep(1)
    print("{}: Hello from non-daemon".format(        current_thread().name))
#creating threads
t1 = Thread(target=daeom_func, name="Daemon Thread",     daemon=True)
t2 = Thread(target=nondaeom_func, name="Non-Daemon Thread")
# start the threads
t1.start()
t2.start()
print("Exiting the main program")

在这个代码示例中,我们创建了一个守护线程和一个非守护线程。守护线程(daeom_func)正在执行一个睡眠时间为3秒的函数,而非守护线程正在执行一个睡眠时间为 1 秒的函数(nondaeom_func)。这两个函数的睡眠时间被设置为确保非守护线程首先完成其执行。该程序的输出控制台如下所示:

Exiting the main program
Non-Daemon Thread: Hello from non-daemon 

由于我们没有在任何线程中使用join方法,主线程首先退出,然后非守护线程稍后通过打印消息完成。但是守护线程没有打印消息。这是因为非守护线程在非守护线程完成执行后立即终止。如果我们将nondaeom_func函数中的睡眠时间改为5,控制台输出将如下所示:

Exiting the main program
Daemon Thread: Hello from daemon
Non-Daemon Thread: Hello from non-daemon

通过延迟非守护线程的执行,我们确保守护线程完成了其执行,并且不会突然终止。

重要注意事项

如果我们在守护线程上使用join,主线程将被迫等待守护线程完成其执行。

接下来,我们将探讨如何在 Python 中同步线程。

同步线程

线程同步是一种机制,确保两个或更多线程不会同时执行共享代码块。通常访问共享数据或共享资源的代码块也称为关键部分。这个概念可以通过以下图示来解释得更清楚:

![图 7.2 – 两个线程访问程序的关键部分

![img/B17189_07_02.jpg]

图 7.2 – 两个线程访问程序的关键部分

同时访问关键部分的多个线程可能会尝试同时访问或更改数据,这可能会导致数据出现不可预测的结果。这种情况称为竞态条件

为了说明竞态条件的概念,我们将实现一个简单的程序,其中包含两个线程,每个线程将共享变量增加 1 百万次。我们选择了一个较高的增加次数,以确保我们可以观察到竞态条件的结果。在较慢的 CPU 上,通过降低增加循环的值也可以观察到竞态条件。在这个程序中,我们将创建两个线程,它们使用相同的函数(在这种情况下是inc)作为目标。访问共享变量并将其增加 1 的代码发生在关键部分,并且两个线程都在没有任何保护的情况下访问它。完整的代码示例如下:

# thread3a.py when no thread synchronization used
from threading import Thread as Thread
def inc():
    global x
    for _ in range(1000000):
        x+=1
#global variabale
x = 0
# creating threads
t1 = Thread(target=inc, name="Th 1")
t2 = Thread(target=inc, name="Th 2")
# start the threads
t1.start()
t2.start()
#wait for the threads
t1.join()
t2.join()
print("final value of x :", x)

执行结束时 x 的预期值是 2,000,000,这将在控制台输出中观察不到。每次我们执行这个程序时,我们都会得到一个比 2,000,000 低得多的 x 值。这是因为两个线程之间的竞争条件。让我们看看线程 Th 1Th 2 同时运行临界区 (x+=1) 的场景。两个线程都会请求 x 的当前值。如果我们假设 x 的当前值是 100,两个线程都会读取它为 100 并将其增加到新的值 101。两个线程会将新的值 101 写回内存。这是一个一次性增加,实际上,两个线程应该独立于彼此增加变量,x 的最终值应该是 102。我们如何实现这一点?这就是线程同步发挥作用的地方。

可以通过使用 threading 模块中的 Lock 类来实现线程同步。Lock 类通过提供 acquirerelease 两个方法来实现锁,下面将进行描述:

  • 使用 acquire 方法来获取锁。如果锁是 unlocked(未锁定)的,那么锁会被提供给请求的线程以继续执行。在非阻塞获取请求的情况下,线程执行不会被阻塞。如果锁可用(unlocked),则将锁提供给(并 locked)请求的线程以继续执行,否则请求的线程会得到 False 作为响应。

  • 使用 release 方法来释放锁,这意味着它将锁重置为 unlocked 状态。如果有任何线程正在阻塞并等待锁,它将允许其中一个线程继续执行。

thread3a.py 代码示例通过在共享变量 x 的增量语句周围使用锁进行了修改。在这个修改后的示例中,我们在主线程级别创建了一个锁,并将其传递给 inc 函数以获取和释放围绕共享变量的锁。完整的修改后的代码示例如下:

# thread3b.py when thread synchronization is used
from threading import Lock, Thread as Thread
def inc_with_lock (lock):
    global x
    for _ in range(1000000):
        lock.acquire()
        x+=1
        lock.release()
x = 0
mylock = Lock()
# creating threads
t1 = Thread(target= inc_with_lock, args=(mylock,), name="Th     1")
t2 = Thread(target= inc_with_lock, args=(mylock,), name="Th     2")
# start the threads
t1.start()
t2.start()
#wait for the threads
t1.join()
t2.join()
print("final value of x :", x)

使用 Lock 对象后,x 的值总是 2000000Lock 对象确保一次只有一个线程增加共享变量。线程同步的优势在于你可以使用系统资源,以增强性能和可预测的结果。

然而,锁必须谨慎使用,因为不当使用锁可能导致死锁情况。假设一个线程在资源 A 上获取了锁并正在等待获取资源 B 的锁。但另一个线程已经持有资源 B 的锁并正在尝试获取资源 A 的锁。这两个线程将等待对方释放锁,但这种情况永远不会发生。为了避免死锁情况,多线程和进程库提供了添加资源持有锁的超时时间等机制,或者使用上下文管理器来获取锁。

使用同步队列

Python 中的Queue模块实现了多生产者和多消费者队列。在多线程应用程序中,当需要在不同的线程之间安全地交换信息时,队列非常有用。同步队列的美丽之处在于它们自带所有必要的锁定机制,无需使用额外的锁定语义。

Queue模块中有三种类型的队列:

  • FIFO:在 FIFO 队列中,首先添加的任务首先被检索。

  • LIFO:在 LIFO 队列中,最后添加的任务首先被检索。

  • 优先队列:在此队列中,条目被排序,具有最低值的条目首先被检索。

这些队列使用锁来保护对队列条目的访问,防止来自竞争线程的访问。使用带有多线程程序的队列的最佳示例是代码示例。在下一个示例中,我们将创建一个包含虚拟任务的 FIFO 队列。为了从队列中处理任务,我们将通过继承Thread类来实现一个自定义线程类。这是实现线程的另一种方式。

要实现一个自定义线程类,我们需要重写initrun方法。在init方法中,需要调用超类(Thread类)的init方法。run方法是线程类的执行部分。完整的代码示例如下:

# thread5.py with queue and custom Thread class
from queue import Queue
from threading import Thread as Thread
from time import sleep
class MyWorker (Thread):
   def __init__(self, name, q):
      threading.Thread.__init__(self)
      self.name = name
      self.queue = q
   def run(self):
      while True:
          item = self.queue.get()
          sleep(1)
          try:
              print ("{}: {}".format(self.name, item))
          finally:
            self.queue.task_done()
#filling the queue
myqueue = Queue()
for i in range (10):
    myqueue.put("Task {}".format(i+1))
# creating threads
for i in range (5):
    worker = MyWorker("Th {}".format(i+1), myqueue)
    worker.daemon = True
    worker.start()
myqueue.join()

在此代码示例中,我们使用自定义线程类(MyThread)创建了五个工作线程。这五个工作线程访问队列以从中获取任务项。获取任务项后,线程将休眠 1 秒钟,然后打印线程名称和任务名称。对于队列中每个项目的get调用,随后的task_done()调用表示已完成任务的处理。

重要的是要注意,我们是在myqueue对象上而不是在线程上使用join方法。队列上的join方法会阻塞主线程,直到队列中的所有项目都已被处理并完成(对它们调用task_done)。这是使用队列对象持有线程的任务数据时阻塞主线程的推荐方式。

接下来,我们将实现一个应用程序,使用Thread类、Queue类和一些第三方库从 Google Drive 下载文件。

案例研究 - 一个从 Google Drive 下载文件的多线程应用程序

在前一个章节中,我们讨论了 Python 中的多线程应用程序在处理输入和输出任务时表现突出。这就是为什么我们选择实现一个从 Google Drive 共享目录下载文件的应用程序。为了实现此应用程序,我们需要以下内容:

  • Google Drive:一个 Google Drive 账户(一个免费的基本账户即可),其中一个目录被标记为共享。

  • API 密钥:要访问 Google API,需要一个 API 密钥。需要启用 API 密钥才能使用 Google Drive 的 Google API。可以通过遵循 Google 开发者网站上的指南启用 API(developers.google.com/drive/api/v3/enable-drive-api)。

  • pip 工具。

  • 也可以使用 pip 工具。还有其他库提供相同的功能。我们选择了 gdown 库,因为它易于使用。

要使用 getfilelistpy 模块,我们需要创建一个资源数据结构。这个数据结构将包括一个文件夹标识符作为 id(在我们的情况下,这将是一个 Google Drive 文件夹 ID),用于访问 Google Drive 文件夹的 API 安全密钥(api_key),以及当我们获取文件列表时需要获取的文件属性列表(fields)。我们按照以下方式构建资源数据结构:

resource = {
    "api_key": "AIzaSyDYKmm85kebxddKrGns4z0",
    "id": "0B8TxHW2Ci6dbckVwTRtTl3RUU",
    "fields": "files(name, id, webContentLink)",
}
'''API key and id used in the examples are not original, so should be replaced as per your account and shared directory id''' 

我们将文件属性限制为 file idname 和其 web link(URL)仅此。接下来,我们需要将每个文件项作为任务添加到队列中,以便线程处理。队列将由多个工人线程用于并行下载文件。

为了使应用程序在工人数量方面更加灵活,我们可以构建一个工人线程池。线程池的大小由一个全局变量控制,该变量在程序开始时设置。我们根据线程池的大小创建了工人线程。池中的每个工人线程都可以访问队列,其中包含文件列表。与之前的代码示例一样,每个工人线程一次从队列中取一个文件项,下载文件,并使用 task_done 方法将文件项标记为完成。定义资源数据结构和定义工人线程类的示例代码如下:

#threads_casestudy.py
from queue import Queue
from threading import Thread
import time
from getfilelistpy import getfilelist
import gdown
THREAD_POOL_SIZE = 1
resource = {
    "api_key": "AIzaSyDYKmm85kea2bxddKrGns4z0",
    "id": "0B8TxHW2Ci6dbckVweTRtTl3RUU ",
    "fields": "files(name,id,webContentLink)",
}
class DownlaodWorker(Thread):
    def __init__(self, name, queue):
        Thread.__init__(self)
        self.name = name
        self.queue = queue
    def run(self):
        while True:
            # Get the file id and name from the queue
            item1 = self.queue.get()
            try:
                gdown.download( item1['webContentLink'], 
                    './files/{}'.format(item1['name']), 
                    quiet=False)
            finally:
                self.queue.task_done()

我们使用以下方式使用资源数据结构从 Google Drive 目录获取文件的元数据:

def get_files(resource):
        #global files_list
        res = getfilelist.GetFileList(resource)
        files_list = res['fileList'][0]
        return files_list

main 函数中,我们创建一个 Queue 对象,将文件元数据插入队列。Queue 对象被传递给一组工人线程,用于下载文件。如前所述,工人线程将下载文件。我们使用 time 类来测量从 Google Drive 目录下载所有文件所需的时间。main 函数的代码如下:

def main():
    start_time = time.monotonic()
    files = get_files(resource)
    #add files info into the queue
    queue = Queue()
    for item in files['files']:
        queue.put(item)
    for i in range (THREAD_POOL_SIZE):
        worker = DownlaodWorker("Thread {}".format(i+1), 
                queue)
        worker.daemon = True
        worker.start()
    queue.join()
    end_time = time.monotonic()
    print('Time taken to download: {} seconds'.
          format( end_time - start_time))
main()

对于这个应用程序,我们在 Google Drive 目录中有 10 个文件,大小从 500 KB 到 3 MB 不等。我们使用 1、5 和 10 个工人线程运行了应用程序。使用 1 个线程下载 10 个文件的总时间大约为 20 秒。这几乎等同于不使用任何线程编写代码。实际上,我们已经编写了一个不使用任何线程下载相同文件的代码,并将其作为本书源代码的示例提供。使用非线程应用程序下载 10 个文件的时间大约为 19 秒。

当我们将工作线程的数量更改为 5 时,在我们的 MacBook 机器(Intel Core i5,16 GB RAM)上下载 10 个文件所需的时间显著减少,大约为 6 秒。如果你在你的电脑上运行相同的程序,时间可能会有所不同,但如果我们增加工作线程的数量,肯定会得到改善。使用 10 个线程时,我们观察到执行时间大约为 4 秒。这一观察表明,无论 GIL 限制如何,通过使用多线程来处理 I/O 密集型任务都可以提高执行时间。

这就结束了我们对如何在 Python 中实现线程以及如何使用Lock类和Queue类来利用不同的锁定机制的讨论。接下来,我们将讨论 Python 中的多进程编程。

超越单个 CPU – 实现多进程

我们已经看到了多线程编程的复杂性和其局限性。问题是多线程的复杂性是否值得付出努力。对于 I/O 相关的任务可能值得,但对于通用应用场景则不然,尤其是当存在替代方法时。替代方法是使用多进程,因为独立的 Python 进程不受 GIL(全局解释器锁)的限制,可以并行执行。这在应用程序运行在多核处理器上且涉及密集型 CPU 需求任务时尤其有益。实际上,在 Python 的内置库中,使用多进程是利用多个处理器核心的唯一选项。

图形处理单元GPU)比常规 CPU 拥有更多的核心,被认为更适合数据处理任务,尤其是在并行执行时。唯一的缺点是,为了在 GPU 上执行数据处理程序,我们必须将数据从主内存传输到 GPU 内存。当我们处理大型数据集时,这一额外的数据传输步骤将会得到补偿。但如果我们的数据集很小,那么将几乎没有好处。使用 GPU 进行大数据处理,特别是用于训练机器学习模型,正变得越来越受欢迎。NVIDIA 推出了一种用于并行处理的 GPU,称为 CUDA,它通过 Python 的外部库得到了良好的支持。

每个进程在操作系统级别都有一个称为进程控制块PCB)的数据结构。像 TCB(线程控制块)一样,PCB 有一个进程 IDPID)用于进程识别,存储进程的状态(如运行或等待),并具有程序计数器、CPU 寄存器、CPU 调度信息以及许多其他属性。

在多 CPU 进程的情况下,内存共享不是原生的。这意味着数据损坏的可能性较低。如果两个进程需要共享数据,它们需要使用某种进程间通信机制。Python 通过其原语支持进程间通信。在接下来的小节中,我们将首先讨论在 Python 中创建进程的基本原理,然后讨论如何实现进程间通信。

创建多个进程

对于多进程编程,Python 提供了一个与多线程包非常相似的multiprocessing包。multiprocessing包包括两种实现多进程的方法,即使用Process对象和Pool对象。我们将逐一讨论这些方法。

使用 Process 对象

可以通过创建Process对象并使用其start方法(类似于启动Thread对象的start方法)来生成进程。实际上,Process对象提供了与Thread对象相同的 API。创建多个子进程的简单代码示例如下:

# process1.py to create simple processes with function
import os
from multiprocessing import Process, current_process as cp
from time import sleep
def print_hello():
    sleep(2)
    print("{}-{}: Hello".format(os.getpid(), cp().name))
def print_message(msg):
    sleep(1)
    print("{}-{}: {}".format(os.getpid(), cp().name, msg))
def main():
    processes = []
    # creating process
    processes.append(Process(target=print_hello, name="Process       1"))
    processes.append(Process(target=print_hello, name="Process       2"))
    processes.append(Process(target=print_message,      args=["Good morning"], name="Process 3"))
    # start the process
    for p in processes:
        p.start()
    # wait till all are done
    for p in processes:
        p.join()
    print("Exiting the main process")
if __name__ == '__main__':
    main()

如前所述,用于Process对象的方法与用于Thread对象的方法几乎相同。这个示例的解释与多线程代码示例中的示例代码相同。

使用 Pool 对象

Pool对象提供了一个方便的方法(使用其map方法)来创建进程,将函数分配给每个新进程,并将输入参数分配给各个进程。我们选择了池大小为3的代码示例,但提供了五个进程的输入参数。将池大小设置为3的原因是确保一次最多只有三个子进程处于活动状态,无论我们通过Pool对象的map方法传递多少参数。额外的参数将在子进程完成当前执行后立即传递给相同的子进程。以下是一个池大小为3的代码示例:

# process2.py to create processes using a pool
import os
from multiprocessing import Process, Pool, current_process     as cp
from time import sleep
def print_message(msg):
    sleep(1)
    print("{}-{}: {}".format(os.getpid(), cp().name, msg))
def main():
    # creating process from a pool
    with Pool(3) as proc:
        proc.map(print_message, ["Orange", "Apple", "Banana",
                                 "Grapes","Pears"])
    print("Exiting the main process")
if __name__ == '__main__':
    main()

将输入参数分配给与一组池进程相关联的函数的魔法是通过map方法实现的。map方法会等待所有函数完成执行,这就是为什么如果使用Pool对象创建进程,则不需要使用join方法的原因。

使用Process对象与使用Pool对象之间的一些差异如下表所示:

表 7.1 – 使用 Pool 对象和 Process 对象比较

表 7.1 – 使用 Pool 对象和 Process 对象比较

接下来,我们将讨论如何在进程间交换数据。

进程间共享数据

在多进程包中,有两种方法可以在进程间共享数据。这些是共享内存服务器进程。下面将进行描述。

使用共享 ctype 对象(共享内存)

在这种情况下,创建了一个共享内存块,进程可以访问这个共享内存块。当我们在multiprocessing包中初始化一个ctype数据类型时,就会创建共享内存。数据类型有ArrayValueArray数据类型是一个ctype数组,而Value数据类型是一个通用的ctype对象,两者都是从共享内存中分配的。为了创建一个ctype数组,我们将使用如下语句:

mylist = multiprocessing.Array('i', 5)

这将创建一个大小为5integer数据类型的数组。i是类型码之一,代表整数。我们可以使用d类型码来表示浮点数据类型。我们还可以通过提供序列作为第二个参数(而不是大小)来初始化数组,如下所示:

mylist = multiprocessing.Array('i', [1,2,3,4,5])

要创建一个Value ctype对象,我们将使用类似以下语句:

obj = multiprocessing.Value('i')

这将创建一个integer数据类型的对象,因为类型码被设置为i。这个对象的价值可以通过使用value属性来访问或设置。

这两个ctype对象都有一个可选的Lock参数,默认设置为True。当这个参数设置为True时,用于创建一个新的递归锁对象,该对象提供对对象值的同步访问。如果设置为False,则没有保护,并且不是一个安全的过程。如果你的进程只用于读取共享内存,可以将Lock设置为False。我们在接下来的代码示例中保留这个Lock参数为默认值(True)。

为了说明从共享内存中使用这些ctype对象,我们将创建一个包含三个数值的默认列表,一个大小为3ctype数组来保存原始数组的增量值,以及一个ctype对象来保存增量数组的总和。这些对象将由父进程在共享内存中创建,并由子进程从共享内存中访问和更新。父进程和子进程与共享内存的这种交互在以下图中展示:

图 7.3 – 父进程和子进程使用共享内存

图 7.3 – 父进程和子进程使用共享内存

下一个示例展示了使用共享内存的完整代码:

# process3.py to use shared memory ctype objects
import multiprocessing
from multiprocessing import Process, Pool, current_process   as cp
def inc_sum_list(list, inc_list, sum):
    sum.value = 0
    for index, num in enumerate(list):
        inc_list[index] = num + 1
        sum.value = sum.value + inc_list[index]
def main():
    mylist = [2, 5, 7]
    inc_list = multiprocessing.Array('i', 3)
    sum = multiprocessing.Value('i')
    p = Process(target=inc_sum_list,                args=(mylist, inc_list, sum))
    p.start()
    p.join()
    print("incremented list: ", list(inc_list))
    print("sum of inc list: ", sum.value)
    print("Exiting the main process")
if __name__ == '__main__':
    main()

共享数据类型(在本例中为inc_listsum)被父进程和子进程访问。重要的是要提到,使用共享内存不是一个推荐的选择,因为它在多个进程访问相同的共享内存对象且Lock参数设置为False时,需要同步和锁定机制(类似于我们为多线程所做的那样)。

在进程之间共享数据的下一个方法是使用服务器进程。

使用服务器进程

在这种情况下,一旦 Python 程序启动,就会启动一个服务器进程。这个新进程用于创建和管理父进程请求的新子进程。这个服务器进程可以持有其他进程可以通过代理访问的 Python 对象。

要实现服务器进程并在进程之间共享对象,multiprocessing 包提供了一个 Manager 对象。Manager 对象支持以下数据类型:

  • 列表

  • Dictionaries

  • Rlocks

  • 队列

  • Values

  • Arrays

我们选择的用于说明服务器进程的代码示例使用 Manager 对象创建了一个 dictionary 对象,然后将字典对象传递给不同的子进程以插入更多数据并打印字典内容。在我们的例子中,我们将创建三个子进程:两个用于向字典对象中插入数据,一个用于将字典内容作为控制台输出。父进程、服务器进程和三个子进程之间的交互在 图 7.4 中显示。父进程在执行新进程请求时立即创建服务器进程,使用的是 Manager 上下文。子进程由服务器进程创建和管理。共享数据在服务器进程中可用,并且所有进程都可以访问,包括父进程:

![Figure 7.4 – 使用服务器进程在进程间共享数据img/B17189_07_04.jpg

Figure 7.4 – 使用服务器进程在进程间共享数据

完整的代码示例如下:

# process4.py to use shared memory using the server process
import multiprocessing
from multiprocessing import Process, Manager
def insert_data (dict1, code, subject):
    dict1[code] =  subject
def output(dict1):
    print("Dictionary data: ", dict1)
def main():
    with multiprocessing.Manager() as mgr:
        # create a dictionary in the server process
        mydict = mgr.dict({100: "Maths", 200: "Science"})
        p1 = Process(target=insert_data, args=(mydict, 300,           "English"))
        p2 = Process(target=insert_data, args=(mydict, 400,           "French"))
        p3 = Process(target=output, args=(mydict,))
        p1.start()
        p2.start()
        p1.join()
        p2.join()
        p3.start()
        p3.join()
    print("Exiting the main process")
if __name__ == '__main__':
    main()

服务器进程方法比共享内存方法提供了更多的灵活性,因为它支持大量不同类型的对象。然而,这以比共享内存方法更慢的性能为代价。

在下一节中,我们将探讨进程之间直接通信的选项。

进程间交换对象

在上一节中,我们学习了如何通过外部内存块或新进程来在进程之间共享数据。在本节中,我们将探讨使用 Python 对象在进程之间交换数据。multiprocessing 模块为此提供了两种选项。这些是使用 Queue 对象和 Pipe 对象。

使用队列对象

Queue 对象可以从 multiprocessing 包中获得,几乎与我们在多线程中使用的同步队列对象 (queue.Queue) 相同。这个 Queue 对象是进程安全的,不需要任何额外的保护。下面是一个代码示例,用于说明如何使用多进程 Queue 对象进行数据交换:

# process5.py to use queue to exchange data
import multiprocessing
from multiprocessing import Process, Queue
def copy_data (list, myqueue):
    for num in list:
        myqueue.put(num)
def output(myqueue):
    while not myqueue.empty():
        print(myqueue.get())
def main():
    mylist = [2, 5, 7]
    myqueue = Queue()
    p1 = Process(target=copy_data, args=(mylist, myqueue))
    p2 = Process(target=output, args=(myqueue,))
    p1.start()
    p1.join()
    p2.start()
    p2.join()
    print("Queue is empty: ",myqueue.empty())
    print("Exiting the main process")
if __name__ == '__main__':
    main()

在这个代码示例中,我们创建了一个标准的list对象和一个多进程Queue对象。listQueue对象被传递给一个新的进程,该进程连接到一个名为copy_data的函数。这个函数将从list对象复制数据到Queue对象。启动了一个新的进程来打印Queue对象的内容。请注意,Queue对象中的数据由前一个进程设置,并且数据将可供新进程使用。这是一种方便的数据交换方式,无需增加共享内存或服务器进程的复杂性。

使用Pipe对象

Pipe对象就像两个进程之间交换数据的管道。这就是为什么这个对象在需要双向通信时特别有用。当我们创建一个Pipe对象时,它提供了两个连接对象,这是Pipe对象的两个端点。每个连接对象提供了一个sendrecv方法来发送和接收数据。

为了说明Pipe对象的概念和使用,我们将创建两个函数,这两个函数将连接到两个不同的进程:

  • 第一个函数是通过Pipe对象连接发送消息。我们将发送一些数据消息,并通过一个BYE消息完成通信。

  • 第二个函数是使用Pipe对象的另一个连接对象接收消息。这个函数将运行在一个无限循环中,直到它接收到一个BYE消息。

这两个函数(或进程)提供了管道的两个连接对象。完整的代码如下:

# process6.py to use Pipe to exchange data
from multiprocessing import Process, Pipe
def mysender (s_conn):
    s_conn.send({100, "Maths"})
    s_conn.send({200, "Science"})
    s_conn.send("BYE")
def myreceiver(r_conn):
    while True:
        msg = r_conn.recv()
        if msg == "BYE":
            break
        print("Received message : ", msg)
def main():
    sender_conn, receiver_conn= Pipe()
    p1 = Process(target=mysender, args=(sender_conn, ))
    p2 = Process(target=myreceiver, args=(receiver_conn,))
    p1.start()
    p2.start()
    p1.join()
    p2.join()
    print("Exiting the main process")
if __name__ == '__main__':
    main()

重要的是要提到,如果两个进程同时尝试使用相同的连接对象从Pipe对象中读取或写入数据,那么Pipe对象中的数据很容易被损坏。这就是为什么多进程队列是首选选项:因为它们在进程之间提供了适当的同步。

进程间的同步

进程间的同步确保两个或多个进程不会同时访问相同的资源或程序代码,这也称为Lock对象,类似于我们在多线程情况下使用的情况。

我们使用Lock设置为Truequeuesctype数据类型说明了其使用,这是进程安全的。在下一个代码示例中,我们将使用Lock对象来确保一次只有一个进程可以访问控制台输出。我们使用Pool对象创建了进程,并且为了将相同的Lock对象传递给所有进程,我们使用了Manager对象中的Lock而不是多进程包中的Lock。我们还使用了partial函数将Lock对象绑定到每个进程,以及一个要分配给每个进程函数的列表。以下是完整的代码示例:

# process7.py to show synchronization and locking
from functools import partial
from multiprocessing import Pool, Manager
def printme (lock, msg):
    lock.acquire()
    try:
        print(msg)
    finally:
        lock.release()
def main():
    with Pool(3) as proc:
        lock = Manager().Lock()
        func = partial(printme,lock)
        proc.map(func, ["Orange", "Apple", "Banana",
                                 "Grapes","Pears"])
    print("Exiting the main process")
if __name__ == '__main__':
    main()

如果我们不使用Lock对象,不同进程的输出可能会混合在一起。

案例研究 - 一个多进程应用程序,用于从 Google Drive 下载文件

在本节中,我们将实现与我们在案例研究 – 从 Google Drive 下载文件的多线程应用程序部分中实现的相同案例研究,但使用处理器。前提条件和目标与多线程应用程序的案例研究描述相同。

对于这个应用程序,我们使用了为多线程应用程序构建的相同代码,只是我们使用了进程而不是线程。另一个区别是我们使用了multiprocessing模块中的JoinableQueue对象来实现与从常规Queue对象获得的功能相同。定义资源数据结构和从 Google Drive 下载文件的函数的代码如下:

#processes_casestudy.py
import time
from multiprocessing import Process, JoinableQueue
from getfilelistpy import getfilelist
import gdown
PROCESSES_POOL_SIZE = 5
resource = {
    "api_key": "AIzaSyDYKmm85keqnk4bF1Da2bxddKrGns4z0",
    "id": "0B8TxHW2Ci6dbckVwetTlV3RUU",
    "fields": "files(name,id,webContentLink)",
}
def mydownloader( queue):
    while True:
        # Get the file id and name from the queue
        item1 =  queue.get()
        try:
            gdown.download(item1['webContentLink'],
                           './files/{}'.format(item1['name']),
                           quiet=False)
        finally:
            queue.task_done()

我们使用以下资源数据结构从 Google Drive 目录获取文件的元数据,例如名称和 HTTP 链接:

def get_files(resource):
    res = getfilelist.GetFileList(resource)
    files_list = res['fileList'][0]
    return files_list

在我们的main函数中,我们创建一个JoinableQueue对象,并将文件的元数据插入队列中。队列将被交给一个进程池以下载文件。进程将下载文件。我们使用了time类来测量从 Google Drive 目录下载所有文件所需的时间。main函数的代码如下:

def main ():
    files = get_files(resource)
    #add files info into the queue
    myqueue = JoinableQueue()
    for item in files['files']:
        myqueue.put(item)
    processes = []
    for id in range(PROCESSES_POOL_SIZE):
        p = Process(target=mydownloader, args=(myqueue,))
        p.daemon = True
        p.start()
    start_time = time.monotonic()
    myqueue.join()
    total_exec_time = time.monotonic() - start_time
    print(f'Time taken to download: {total_exec_time:.2f}         seconds')
if __name__ == '__main__':
    main()

我们通过改变不同的进程数量来运行这个应用程序,例如35710。我们发现下载相同文件所需的时间(与多线程案例研究的情况相同)略好于多线程应用程序。执行时间会因机器而异,但在我们这台机器上(MacBook Pro:Intel Core i5,16 GB RAM),使用 5 个进程时大约需要 5 秒,使用 10 个进程并行运行时需要 3 秒。与多线程应用程序相比,这种 1 秒的改进与预期结果相符,因为多进程提供了真正的并发性。

使用异步编程实现响应式系统

在多进程和多线程编程中,我们主要处理的是同步编程,其中我们请求某事,并在收到响应之前等待,然后才移动到下一块代码。如果应用了上下文切换,则由操作系统提供。Python 中的异步编程主要在以下两个方面有所不同:

  • 需要创建的任务用于异步执行。这意味着父调用者不需要等待另一个进程的响应。进程将在执行完成后向调用者做出响应。

  • 操作系统不再管理进程和线程之间的上下文切换。异步程序将只在一个进程中获得一个线程,但我们可以用它做很多事情。在这种执行风格中,每个进程或任务在空闲或等待其他资源时都会自愿释放控制权,以确保其他任务有机会。这个概念被称为协同多任务

协作多任务处理是实现应用程序级别并发的一种有效工具。在协作多任务处理中,我们不构建进程或线程,而是构建任务,这包括 yield(在恢复之前保持对象堆栈的控制)。

对于基于协作多任务处理的系统,总有一个何时将控制权交还给调度器或事件循环的问题。最常用的逻辑是使用 I/O 操作作为释放控制的事件,因为每次进行 I/O 操作时都涉及等待时间。

但等等,这难道不是我们用于多线程的逻辑吗?我们发现,在处理 I/O 操作时,多线程可以提高应用程序的性能。但这里有一个区别。在多线程的情况下,操作系统正在管理线程之间的上下文切换,并且可以出于任何原因抢占任何正在运行的线程,并将控制权交给另一个线程。但在异步编程或协作多任务处理中,任务或协程对操作系统是不可见的,并且不能被抢占。实际上,协程不能被主事件循环抢占。但这并不意味着操作系统不能抢占整个 Python 进程。主要的 Python 进程仍然在操作系统级别与其他应用程序和进程竞争资源。

在下一节中,我们将讨论 Python 中异步编程的一些构建块,这些构建块由 asyncio 模块提供,并以一个综合案例研究结束。

理解 asyncio 模块

Python 3.5 或更高版本中的 asyncio 模块可用于使用 async/await 语法编写并发程序。但建议使用 Python 3.7 或更高版本来构建任何严肃的 asyncio 应用程序。该库功能丰富,支持创建和运行 Python 协程,执行网络 I/O 操作,将任务分配到队列中,以及同步并发代码。

我们将从如何编写和执行协程和任务开始。

协程和任务

协程是那些需要异步执行的功能。以下是一个使用协程将字符串发送到控制台输出的简单示例:

#asyncio1.py to build a basic coroutine
import asyncio
import time
async def say(delay, msg):
    await asyncio.sleep(delay)
    print(msg)
print("Started at ", time.strftime("%X"))
asyncio.run(say(1,"Good"))
asyncio.run(say(2, "Morning"))
print("Stopped at ", time.strftime("%X"))

在此代码示例中,以下事项很重要:

  • 协程接受 delaymsg 参数。delay 参数用于在将 msg 字符串发送到控制台输出之前添加延迟。

  • 我们使用 asyncio.sleep 函数而不是传统的 time.sleep 函数。如果使用 time.sleep 函数,则不会将控制权交还给事件循环。这就是为什么使用兼容的 asyncio.sleep 函数很重要的原因。

  • 通过使用 run 方法,协程使用两个不同的 delay 参数值执行两次。run 方法不会并发执行协程。

这个程序的控制台输出将如下所示。这表明协程是按照添加的总延迟顺序依次执行的,总延迟为 3 秒:

Started at 15:59:55
Good
Morning
Stopped at 15:59:58

为了并行运行协程,我们需要使用asyncio模块中的create_task函数。这个函数创建一个任务,可以用来调度协程以并发运行。

下一个代码示例是asyncio1.py的修订版,其中我们使用create_task函数将协程(在我们的例子中是say)包装成一个任务。在这个修订版中,我们创建了两个任务,它们都包装了say协程。我们使用await关键字等待两个任务完成:

#asyncio2.py to build and run coroutines in parallel
import asyncio
import time
async def say(delay, msg):
    await asyncio.sleep(delay)
    print(msg)
async def main ():
    task1 = asyncio.create_task( say(1, 'Good'))
    task2 = asyncio.create_task( say(1, 'Morning'))
    print("Started at ", time.strftime("%X"))
    await task1
    await task2
    print("Stopped at ", time.strftime("%X"))
asyncio.run(main())

这个程序的输出如下:

Started at 16:04:40
Good
Morning
Stopped at  16:04:41

这个控制台输出显示,两个任务在 1 秒内完成,这证明了任务是并行执行的。

使用可等待对象

如果我们可以对对象应用await语句,则该对象是可等待的。asyncio函数和模块的大多数内部设计都是为了与可等待对象一起工作。但大多数 Python 对象和第三方库都不是为异步编程而构建的。在构建异步应用程序时,选择提供可等待对象的兼容库非常重要。

可等待对象主要分为三种类型:协程、任务,Future是一个低级对象,类似于用于处理来自async/await的结果的回调机制。通常不会将Future对象暴露给用户级编程。

并行运行任务

如果我们必须并行运行多个任务,我们可以像上一个例子中那样使用await关键字。但有一种更好的方法,那就是使用gather函数。这个函数将按提供的顺序运行可等待对象。如果任何可等待对象是协程,它将被调度为一个任务。我们将在下一节中通过代码示例看到gather函数的使用。

使用队列分配任务

asyncio包中的Queue对象类似于Queue模块,但它不是线程安全的。asyncio模块提供了各种队列实现,例如 FIFO 队列、优先队列和 LIFO 队列。asyncio模块中的队列可以用来将工作负载分配给任务。

为了说明队列与任务的使用,我们将编写一个小程序,通过随机休眠一段时间来模拟真实函数的执行时间。随机休眠时间计算了 10 次这样的执行,并由主进程将这些执行时间作为工作项添加到Queue对象中。Queue对象被传递给三个任务池。池中的每个任务执行分配的协程,按照它可用的队列条目消耗执行时间。完整的代码如下:

#asyncio3.py to distribute work via queue
import asyncio
import random
import time
async def executer(name, queue):
    while True:
        exec_time = await queue.get()
        await asyncio.sleep(exec_time)
        queue.task_done()
        #print(f'{name} has taken  {exec_time:.2f} seconds')
async def main ():
    myqueue = asyncio.Queue()
    calc_exuection_time = 0
    for _ in range(10):
        sleep_for = random.uniform(0.4, 0.8)
        calc_exuection_time += sleep_for
        myqueue.put_nowait(sleep_for)
    tasks = []
    for id in range(3):
        task = asyncio.create_task(executer(f'Task-{id+1}',                 myqueue))
        tasks.append(task)
    start_time = time.monotonic()
    await myqueue.join()
    total_exec_time = time.monotonic() - start_time
    for task in tasks:
        task.cancel()
    await asyncio.gather(*tasks, return_exceptions=True)
    print(f"Calculated execution time         {calc_exuection_time:0.2f}")
    print(f"Actual execution time {total_exec_time:0.2f}")
asyncio.run(main())

我们使用了Queue对象的put_no_wait函数,因为它是一个非阻塞操作。这个程序的输出如下:

Calculated execution time 5.58
Actual execution time 2.05

这清楚地表明,任务是以并行方式执行的,执行效率比顺序执行任务提高了三倍。

到目前为止,我们已经介绍了 Python 中asyncio包的基本概念。在结束这个主题之前,我们将通过使用asyncio任务来实现它来回顾我们在多线程部分所做的案例研究。

案例研究 – 使用 asyncio 从 Google Drive 下载文件的应用程序

我们将实现与我们在案例研究 – 从 Google Drive 下载文件的多线程应用程序部分所做的相同的案例研究,但使用asyncio模块以及asyncawaitasync queue。这个案例研究的先决条件与之前相同,只是我们使用aiohttpaiofiles库而不是gdown库。原因很简单:gdown库不是作为一个异步模块构建的。使用异步编程与之结合没有好处。这是一个在选择用于异步应用程序的库时需要考虑的重要观点。

对于这个应用程序,我们构建了一个协程mydownloader,用于使用aiohttpaiofiles模块从 Google Drive 下载文件。这在上面的代码中显示,与之前的案例研究不同的代码被突出显示:

#asyncio_casestudy.py
import asyncio
import time
import aiofiles, aiohttp
from getfilelistpy import getfilelist
TASK_POOL_SIZE = 5
resource = {
    "api_key": "AIzaSyDYKmm85keqnk4bF1DpYa2dKrGns4z0",
    "id": "0B8TxHW2Ci6dbckVwetTlV3RUU",
    "fields": "files(name, id, webContentLink)",
}
async def mydownloader(name, queue):
    while True:
        # Get the file id and name from the queue
        item = await queue.get()
        try:
            async with aiohttp.ClientSession() as sess:
                async with sess.get(item['webContentLink']) 
                    as resp:
                    if resp.status == 200:
                       f = await aiofiles.open('./files/{}'                         .format(
                            item['name']), mode='wb')
                        await f.write(await resp.read())
                        await f.close()
        finally:
            print(f"{name}: Download completed for 
                        ",item['name'])
            queue.task_done()

从共享 Google Drive 文件夹中获取文件列表的过程与我们之前在多线程和多进程案例研究中使用的方法相同。在本案例研究中,我们基于mydownloader协程创建了一个任务池(可配置)。然后,这些任务被安排一起运行,我们的父进程等待所有任务完成执行。以下是一个从 Google Drive 获取文件列表并使用asyncio任务下载文件的代码示例:

def get_files(resource):
    res = getfilelist.GetFileList(resource)
    files_list = res['fileList'][0]
    return files_list
async def main ():
    files = get_files(resource)
    #add files info into the queue
    myqueue = asyncio.Queue()
    for item in files['files']:
        myqueue.put_nowait(item)
    tasks = []
    for id in range(TASK_POOL_SIZE):
        task = asyncio.create_task(
            mydownloader(f'Task-{id+1}', myqueue))
        tasks.append(task)
    start_time = time.monotonic()
    await myqueue.join()
    total_exec_time = time.monotonic() - start_time
    for task in tasks:
        task.cancel()
    await asyncio.gather(*tasks, return_exceptions=True)
    print(f'Time taken to download: {total_exec_time:.2f}         seconds')
asyncio.run(main())

我们通过改变任务数量来运行这个应用程序,例如 3、5、7 和 10。我们发现,使用asyncio任务下载文件所需的时间比我们使用多线程方法或多进程方法下载相同文件的时间要低。多线程方法和多进程方法所需的确切时间细节可以在案例研究 – 从 Google Drive 下载文件的多线程应用程序案例研究 – 从 Google Drive 下载文件的多进程应用程序部分中找到。

执行时间可能会因机器而异,但在我们机器上(MacBook Pro:Intel Core i5,16 GB RAM),当有 5 个任务并行运行时,大约需要 4 秒,当有 10 个任务并行运行时,需要 2 秒。与我们在多线程和多进程案例研究中观察到的数字相比,这是一个显著的改进。这与预期结果一致,因为asyncio在处理 I/O 相关任务时提供了一个更好的并发框架,但它必须使用正确的编程对象集来实现。

这结束了我们对异步编程的讨论。本节提供了使用 asyncio 包构建异步应用程序的所有核心要素。

摘要

在本章中,我们讨论了使用 Python 标准库进行并发编程的不同选项。我们从介绍并发编程的核心概念开始,介绍了多线程的挑战,例如 GIL,它允许一次只有一个线程访问 Python 对象。我们通过 Python 代码的实际示例探讨了锁定和同步的概念。我们还通过案例研究讨论了多线程编程更有效的任务类型。

我们研究了如何在 Python 中使用多个进程实现并发。通过多进程编程,我们学习了如何使用共享内存和服务器进程在进程之间共享数据,以及如何使用 Queue 对象和 Pipe 对象在进程之间安全地交换对象。最后,我们构建了与多线程示例相同的案例研究,但使用进程。然后,我们通过使用异步编程引入了一种完全不同的实现并发的途径。这是一个概念上的完全转变,我们从查看 asyncawait 关键字的高级概念以及如何使用 asyncio 包构建任务或协程开始。我们以与多进程和多线程相同的案例研究结束本章。

本章提供了大量关于如何在 Python 中实现并发应用程序的实战示例。这些知识对于任何想要使用 Python 中的标准库构建多线程或异步应用程序的人来说都很重要。

在下一章中,我们将探讨使用第三方库在 Python 中构建并发应用程序。

问题

  1. Python 线程是由什么协调的?是 Python 解释器吗?

  2. Python 中的 GIL 是什么?

  3. 你应该在什么情况下使用守护线程?

  4. 对于内存有限的系统,我们应该使用 Process 对象还是 Pool 对象来创建进程?

  5. asyncio 包中的 Future 是什么?

  6. 异步编程中的事件循环是什么?

  7. 你如何在 Python 中编写异步协程或函数?

进一步阅读

  • 《Python 并发编程》,作者 Elliot Forbes

  • 《精通 Python 编程》,作者 Michal Jaworski 和 Tarek Ziade

  • 《Python 3 面向对象编程》,第二版,作者 Dusty Phillips

  • 《精通 Python 并发》,作者 Quan Nguyen

  • 《Python Concurrency with asyncio》,作者 Mathew Fowler

答案

  1. 线程和进程由操作系统内核协调。

  2. Python 的 GIL 是 Python 用来允许一次只执行一个线程的锁定机制。

  3. 当线程的终止不会成为问题的时候,可以使用守护线程。

  4. Pool对象只保留内存中的活动进程,因此它是一个更好的选择。

  5. 期货(Futures)就像是一个回调机制,用于处理来自 async/await 调用的结果。

  6. 事件循环对象负责跟踪任务,并处理它们之间的控制流。

  7. 我们可以通过使用async def来编写异步协程。

第八章:第八章:使用集群扩展 Python

在上一章中,我们讨论了使用线程和进程在单台机器上进行并行处理。在本章中,我们将扩展我们的并行处理讨论,从单台机器扩展到集群中的多台机器。集群是一组协同工作以执行计算密集型任务(如数据处理)的计算设备。特别是,我们将研究 Python 在数据密集型计算领域的功能。数据密集型计算通常使用集群来并行处理大量数据。尽管有相当多的框架和工具可用于数据密集型计算,但我们将专注于 Apache Spark 作为数据处理引擎,以及 PySpark 作为构建此类应用的 Python 库。

如果 Apache Spark 与 Python 配置和实现得当,您应用程序的性能可以大幅提升,并超越如 Hadoop MapReduce 等竞争对手平台。我们还将探讨如何在集群环境中利用分布式数据集。本章将帮助您了解集群计算平台在大型数据处理中的应用,以及如何使用 Python 实现数据处理应用程序。为了说明 Python 在具有集群计算需求的应用中的实际应用,我们将包括两个案例研究;第一个是计算 π(圆周率)的值,第二个是从数据文件生成词云。

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

  • 了解并行处理中的集群选项

  • 介绍 弹性分布式数据集RDD

  • 使用 PySpark 进行并行数据处理

  • 使用 Apache Spark 和 PySpark 的案例研究

到本章结束时,您将了解如何使用 Apache Spark,以及您如何编写可以在 Apache Spark 集群的 worker 节点上执行的数据处理 Python 应用程序。

技术要求

本章的技术要求如下:

  • 在您的计算机上安装了 Python 3.7 或更高版本

  • 一个 Apache Spark 单节点集群

  • 在驱动程序开发上安装了 Python 3.7 或更高版本的 PySpark

    注意

    与 Apache Spark 一起使用的 Python 版本必须与运行驱动程序的 Python 版本相匹配。

本章的示例代码可以在 github.com/PacktPublishing/Python-for-Geeks/tree/master/Chapter08 找到。

我们将首先讨论可用于并行处理的一般集群选项。

了解并行处理中的集群选项

当我们处理大量数据时,使用单台具有多个核心的机器来高效地处理数据可能并不高效,有时甚至不可行。当处理实时流数据时,这尤其是一个挑战。对于此类场景,我们需要多个系统可以以分布式方式处理数据,并在多台机器上并行执行这些任务。使用多台机器并行且分布式地处理计算密集型任务被称为集群计算。有几个大数据分布式框架可用于协调集群中作业的执行,但 Hadoop MapReduce 和 Apache Spark 是这场竞赛的领先竞争者。这两个框架都是 Apache 的开源项目。这两个平台有许多变体(例如,Databricks),它们具有附加功能和维护支持,但基本原理保持不变。

如果我们观察市场,Hadoop MapReduce 的部署数量可能比 Apache Spark 多,但随着其日益流行,Apache Spark 最终会扭转局势。由于 Hadoop MapReduce 由于其庞大的安装基础仍然非常相关,因此讨论 Hadoop MapReduce 究竟是什么以及 Apache Spark 如何成为更好的选择是很重要的。让我们在下一个小节中快速概述这两个框架。

Hadoop MapReduce

Hadoop 是一个通用的分布式处理框架,它能够在 Hadoop 集群中的数百或数千个计算节点上执行大规模数据处理作业。Hadoop 的三个核心组件如下所示:

图 8.1 – Apache Hadoop MapReduce 生态系统

图 8.1 – Apache Hadoop MapReduce 生态系统

以下是三个核心组件的描述:

  • Hadoop 分布式文件系统(HDFS):这是一个 Hadoop 原生文件系统,用于存储文件,以便这些文件可以在集群中并行化。

  • 另一个资源协调器(YARN):这是一个处理存储在 HDFS 中的数据并调度提交的作业(用于数据处理)以由处理系统运行的系统。处理系统可用于图处理、流处理或批量处理。

  • map(映射)和reduce(归约)函数与我们在第六章中讨论的相同,即《Python 高级技巧与窍门》。关键区别在于我们使用多个mapreduce函数并行处理多个数据集。

    在将大型数据集分解为小数据集后,我们可以将这些小数据集作为输入提供给多个 mapper 函数,以便在 Hadoop 集群的不同节点上处理。每个 mapper 函数接收一组数据作为输入,根据程序员设定的目标处理数据,并以键值对的形式产生输出。一旦所有小数据集的输出都可用,一个或多个 reducer 函数将接收来自 mapper 函数的输出,并根据 reducer 函数的目标汇总结果。

    为了更详细地解释,我们可以以计数大量文本数据中特定的单词,如attackweapon为例。文本数据可以被划分为小数据集,例如,八个数据集。我们可以为每个数据集提供八个 mapper 函数来计数这两个单词。每个 mapper 函数为其提供的每个数据集提供attackweapon单词的计数作为输出。在下一阶段,所有 mapper 函数的输出被提供给两个 reducer 函数,每个单词一个。每个 reducer 函数汇总每个单词的计数,并将汇总结果作为输出。下面展示了 MapReduce 框架在此单词计数示例中的操作。请注意,在 Python 编程中,mapper 函数通常实现为map,reducer 函数实现为reduce

图 8.2 – MapReduce 框架的工作原理

图 8.2 – MapReduce 框架的工作原理

我们将跳过 Hadoop 组件的下一级,因为它们与本章的讨论无关。Hadoop 主要用 Java 编写,但可以使用任何编程语言,如 Python,来编写定制的 mapper 和 reducer 组件,用于 MapReduce 模块。

Hadoop MapReduce 通过将数据分成小块来处理大量数据,非常适合。集群节点分别处理这些块,然后将结果汇总后发送给请求者。Hadoop MapReduce 从文件系统中处理数据,因此在性能方面不是非常高效。然而,如果处理速度不是关键要求,例如,如果数据处理可以安排在夜间进行,它工作得非常好。

Apache Spark

Apache Spark 是一个开源的集群计算框架,适用于实时和批量数据处理。Apache Spark 的主要特点是它是一个内存数据处理框架,这使得它在实现低延迟方面非常高效,并且由于以下额外因素,它适合许多现实世界的场景:

  • 它能够快速为关键任务和时间敏感的应用程序提供结果,例如实时或近实时场景。

  • 由于内存处理,它非常适合以高效的方式重复或迭代地执行任务。

  • 您可以利用现成的机器学习算法。

  • 您可以利用 Java、Python、Scala 和 R 等额外编程语言的支持。

事实上,Apache Spark 涵盖了广泛的工作负载,包括批量数据、迭代处理和流数据。Apache Spark 的美丽之处在于它可以使用 Hadoop(通过 YARN)作为部署集群,但它也有自己的集群管理器。

在高层次上,Apache Spark 的主要组件分为三个层次,如下所示:

![图 8.3 – Apache Spark 生态系统

![img/B17189_08_03.jpg]

图 8.3 – Apache Spark 生态系统

接下来讨论这些层次。

支持的语言

Scala 是 Apache Spark 的本地语言,因此在开发中非常流行。Apache Spark 还提供了 Java、Python 和 R 的高级 API。在 Apache Spark 中,通过使用远程过程调用RPC)接口提供多语言支持。Scala 为每种语言编写了一个 RPC 适配器,将用不同语言编写的客户端请求转换为原生 Scala 请求。这使得它在开发社区中的采用更加容易。

核心组件

接下来简要概述每个核心组件:

  • Spark Core 和 RDDs:Spark Core 是 Spark 的核心引擎,负责为 RDDs 提供抽象,调度和分配作业到集群,与 HDFS、Amazon S3 或 RDBMS 等存储系统交互,以及管理内存和故障恢复。RDD 是一个弹性分布式数据集,是一个不可变且可分发的数据集合。RDD 被分区以在集群的不同节点上执行。我们将在下一节中更详细地讨论 RDD。

  • Spark SQL:此模块用于使用抽象接口查询存储在 RDDs 和外部数据源中的数据。使用这些通用接口使开发者能够将 SQL 命令与特定应用程序的分析工具混合使用。

  • Spark Streaming:此模块用于处理实时数据,这对于以低延迟分析实时数据流至关重要。

  • MLlib机器学习库MLlib)用于在 Apache Spark 中应用机器学习算法。

  • GraphX:此模块提供基于图并行计算的 API。此模块包含各种图算法。请注意,图是一个基于顶点和边的数学概念,它表示一组对象之间的关系或相互依赖。对象由顶点表示,它们之间的关系由边表示。

集群管理

Apache Spark 支持一些集群管理器,如 Standalone、Mesos、YARN 和 Kubernetes。集群管理器的关键功能是在集群节点上调度和执行作业以及管理集群节点上的资源。但是,为了与一个或多个集群管理器交互,主程序或驱动程序中使用了特殊对象,称为SparkContext对象,但它的 API 现在被封装为SparkSession对象的一部分。从概念上讲,以下图显示了SparkSessionSparkContext)和集群中的工作节点之间的交互:

图 8.4 – Apache Spark 生态系统

图 8.4 – Apache Spark 生态系统

SparkSession对象可以连接到不同类型的集群管理器。一旦连接,通过集群管理器在集群节点上获取执行器。执行器是运行作业并存储计算作业结果的 Spark 进程。主节点上的集群管理器负责将应用程序代码发送到工作节点上的执行器进程。一旦应用程序代码和数据(如果适用)移动到工作节点,驱动程序程序中的SparkSession对象将直接与执行器进程交互以执行任务。

根据 Apache Spark 3.1 版本,以下集群管理器得到支持:

  • Standalone:这是一个简单的集群管理器,它是 Spark Core 引擎的一部分。Standalone 集群基于主进程和工作进程(或从进程)。主进程基本上是一个集群管理器,工作进程托管执行器。尽管主节点和工作节点可以托管在单个机器上,但这并不是 Spark Standalone 集群的真实部署场景。建议将工作进程分布到不同的机器上以获得最佳效果。Standalone 集群易于设置并提供所需的集群的大部分功能。

  • Apache Mesos:这是另一个通用集群管理器,也可以运行 Hadoop MapReduce。对于大规模集群环境,Apache Mesos 是首选选项。这个集群管理器的理念是将物理资源聚合为单个虚拟资源,该资源充当集群并提供节点级抽象。它是一个设计上的分布式集群管理器。

  • Hadoop YARN:这个集群管理器是针对 Hadoop 的。它本质上也是一个分布式框架。

  • Kubernetes:这更多处于实验阶段。这个集群管理器的目的是自动化容器化应用的部署和扩展。Apache Spark 的最新版本包括了 Kubernetes 调度器。

在结束本节之前,值得提及其他一个框架,Dask,这是一个用 Python 编写的开源库,用于并行计算。Dask 框架可以直接与分布式硬件平台如 Hadoop 一起工作。与 Apache Spark 相比,Dask 是一个更小、更轻量级的框架,可以处理从小型到中型规模的集群。相比之下,Apache Spark 支持多种语言,并且是大型集群的最合适选择。

在介绍并行计算的集群选项之后,我们将在下一节讨论 Apache Spark 的核心数据结构,即 RDD。

介绍 RDD

RDD 是 Apache Spark 的核心数据结构。这种数据结构不仅是一个分布式的对象集合,而且是以一种方式分区,使得每个数据集都可以在集群的不同节点上处理和计算。这使得 RDD 成为分布式数据处理的核心元素。此外,RDD 对象具有容错性,即在发生故障的情况下,框架可以重建数据。当我们创建 RDD 对象时,主节点会将 RDD 对象复制到多个执行器或工作节点。如果任何执行器进程或工作节点失败,主节点会检测到故障,并在另一个节点上启用执行器进程以接管执行。新的执行器节点将已经拥有 RDD 对象的副本,并且可以立即开始执行。在原始执行器节点失败之前处理的所有数据将丢失,将由新的执行器节点重新计算。

在接下来的小节中,我们将学习两个关键的 RDD 操作以及如何从不同的数据源创建 RDD 对象。

学习 RDD 操作

RDD 是一个不可变对象,这意味着一旦创建,就不能更改。但可以对 RDD 的数据进行两种类型的操作。这些是转换动作。以下将描述这些操作。

转换

这些操作应用于 RDD 对象,并导致创建一个新的 RDD 对象。这种类型的操作以 RDD 作为输入,并产生一个或多个 RDD 作为输出。我们还需要记住,这些转换本质上是惰性的。这意味着它们只有在触发动作操作(另一种类型的操作)时才会执行。为了解释惰性评估的概念,我们可以假设我们通过从 RDD 中的每个元素减去 1 然后对输出 RDD 中的转换步骤进行算术加法(动作)来转换 RDD 中的数值数据。由于惰性评估,转换操作将不会发生,直到我们调用动作操作(在这种情况下是加法)。

Apache Spark 提供了几个内置的转换函数。常用的转换函数如下:

  • mapmap函数遍历 RDD 对象的每个元素或每行,并对每个元素应用定义的map函数。

  • filter:这个函数将从原始 RDD 中过滤数据,并提供一个包含过滤结果的新 RDD。

  • union:如果两个 RDD 类型相同,则应用此函数,结果生成另一个 RDD,它是输入 RDD 的并集。

操作

操作是在 RDD 上应用的计算操作,这些操作的输出结果将返回给驱动程序(例如,SparkSession)。Apache Spark 提供了几个内置的操作函数。常用的操作函数如下:

  • countcount操作返回 RDD 中的元素数量。

  • collect:这个操作将整个 RDD 返回给驱动程序。

  • reduce:这个操作将从 RDD 中减少元素。一个简单的例子是对 RDD 数据集进行加法操作。

对于转换和操作函数的完整列表,我们建议您查看 Apache Spark 的官方文档。接下来,我们将研究如何创建 RDD。

创建 RDD 对象

创建 RDD 对象有三种主要方法,下面将逐一描述。

并行化集合

这是 Apache Spark 创建 RDD 中使用的一种更简单的方法。在这种方法中,创建或加载一个集合到程序中,然后将其传递给SparkContext对象的parallelize方法。这种方法仅用于开发和测试,因为它要求整个数据集都存储在一台机器上,这对大量数据来说并不方便。

外部数据集

Apache Spark 支持从本地文件系统、HDFS、HBase 或甚至 Amazon S3 等分布式数据集。在这种创建 RDD 的方法中,数据直接从外部数据源加载。SparkContext对象提供了方便的方法,可以将各种数据加载到 RDD 中。例如,可以使用textFile方法从本地或远程资源加载文本数据,使用适当的 URL(例如,file://hdfs://s3n://)。

从现有 RDD 中

如前所述,可以使用转换操作来创建 RDD。这是 Apache Spark 与 Hadoop MapReduce 的不同之处之一。输入 RDD 不会改变,因为它是一个不可变对象,但可以从现有的 RDD 创建新的 RDD。我们已经看到了一些使用mapfilter函数从现有 RDD 创建 RDD 的例子。

这就结束了我们对 RDD 的介绍。在下一节中,我们将使用 Python 代码示例和 PySpark 库提供更多细节。

使用 PySpark 进行并行数据处理

如前所述,Apache Spark 是用 Scala 语言编写的,这意味着它没有对 Python 的原生支持。由于 Python 拥有丰富的库集,许多数据科学家和分析专家更喜欢使用 Python 进行数据处理。因此,仅为了分布式数据处理而切换到另一种编程语言并不方便。因此,将 Python 与 Apache Spark 集成不仅对数据科学社区有益,也为那些希望采用 Apache Spark 而无需学习或切换到新编程语言的人打开了大门。

Apache Spark 社区构建了一个 Python 库,PySpark,以促进使用 Python 与 Apache Spark 协同工作。为了使 Python 代码与建立在 Scala(和 Java)之上的 Apache Spark 协同工作,开发了一个 Java 库,Py4J。这个 Py4J 库与 PySpark 捆绑在一起,允许 Python 代码与 JVM 对象交互。这就是为什么当我们安装 PySpark 时,我们首先需要在我们的系统上安装 JVM。

PySpark 提供了几乎与 Apache Spark 相同的功能和优势。这包括内存计算、并行化工作负载的能力、使用延迟评估设计模式,以及支持 Spark、YARN 和 Mesos 等多达多个集群管理器。

安装 PySpark(以及 Apache Spark)超出了本章的范围。本章的重点是讨论如何使用 PySpark 来利用 Apache Spark 的力量,而不是如何安装 Apache Spark 和 PySpark。但值得提及一些安装选项和依赖关系。

在线有针对 Apache Spark/PySpark 每个版本以及各种目标平台(例如 Linux、macOS 和 Windows)的许多安装指南。PySpark 包含在 Apache Spark 的官方版本中,现在可以从 Apache Spark 网站下载(spark.apache.org/)。PySpark 也可以通过 PyPI 上的pip工具获得,可用于本地设置或连接到远程集群。安装 PySpark 时的另一个选项是使用Anaconda,这是另一个流行的包和环境管理系统。如果我们要在目标机器上安装 PySpark 和 Apache Spark,我们需要以下内容可用或已安装:

  • JVM

  • Scala

  • Apache Spark

对于后面将要讨论的代码示例,我们在 macOS 上安装了包含 PySpark 的 Apache Spark 版本 3.1.1。PySpark 自带SparkSessionSparkContext对象,可以用来与 Apache Spark 的核心引擎交互。以下图显示了 PySpark shell 的初始化:

![Figure 8.5 – PySpark shell

![img/B17189_08_05.jpg]

图 8.5 – PySpark shell

从 PySpark shell 的初始化步骤中,我们可以观察到以下内容:

  • SparkContext对象已经创建,并且其实例在 shell 中作为sc可用。

  • SparkSession对象也被创建,其实例作为spark可用。现在,SparkSession是 PySpark 框架的入口点,可以动态创建 RDD 和 DataFrame 对象。SparkSession对象也可以通过编程方式创建,我们将在后面的代码示例中讨论这一点。

  • Apache Spark 自带一个 Web UI 和一个 Web 服务器来托管 Web UI,并且在我们的本地机器安装中,它通过http://192.168.1.110:4040启动。请注意,此 URL 中提到的 IP 地址是一个特定于我们机器的私有地址。端口4040是由 Apache Spark 选定的默认端口。如果此端口已被占用,Apache Spark 将尝试在下一个可用的端口上托管,例如40414042

在接下来的小节中,我们将学习如何创建SparkSession对象,探索 PySpark 的 RDD 操作,以及学习如何使用 PySpark DataFrame 和 PySpark SQL。我们将从使用 Python 创建 Spark 会话开始。

创建 SparkSession 和 SparkContext 程序

在 Spark 2.0 版本发布之前,SparkContext被用作 PySpark 的入口点。自 Spark 2.0 版本发布以来,SparkSession已被引入作为 PySpark 底层框架的入口点,用于处理 RDD 和 DataFrame。SparkSession还包括SparkContextSQLContextStreamingContextHiveContext中可用的所有 API。现在,SparkSession也可以通过使用其builder方法通过SparkSession类来创建。这在下一个代码示例中进行了说明:

import pyspark
from pyspark.sql import SparkSession
spark1 = SparkSession.builder.master("local[2]")
    .appName('New App').getOrCreate()

当我们在 PySpark shell 中运行此代码时,它已经创建了一个默认的SparkSession对象作为spark,它将返回与builder方法输出相同的会话。以下控制台输出显示了两个SparkSession对象(sparkspark1)的位置,这证实它们指向同一个SparkSession对象:

>>> spark
<pyspark.sql.session.SparkSession object at 0x1091019e8>
>>> spark1
<pyspark.sql.session.SparkSession object at 0x1091019e8>

关于builder方法需要理解的一些关键概念如下:

  • getOrCreate:这是我们将在 PySpark shell 的情况下获得已创建会话的原因。如果没有已存在的会话,此方法将创建一个新的会话;否则,它将返回一个已存在的会话。

  • master:如果我们想创建一个连接到集群的会话,我们将提供主机的名称,这可以是 Spark 的实例名称,或者是 YARN 或 Mesos 集群管理器。如果我们使用的是本地部署的 Apache Spark 选项,我们可以使用local[n],其中n是一个大于零的整数。n将确定要为 RDD 和 DataFrame 创建的分区数量。对于本地设置,n可以是系统上的 CPU 核心数。如果我们将其设置为local[*],这是一个常见的做法,这将创建与系统上逻辑核心数量相同的工人线程。

如果需要创建一个新的 SparkSession 对象,我们可以使用 newSession 方法,该方法在现有的 SparkSession 对象实例级别上可用。下面是一个创建新的 SparkSession 对象的代码示例:

import pyspark
from pyspark.sql import SparkSession
spark2 = spark.newSession()

spark2 对象的控制台输出确认,这不同于之前创建的 SparkSession 对象:

>>> spark2
<pyspark.sql.session.SparkSession object at 0x10910df98>

SparkContext 对象也可以通过编程方式创建。从 SparkSession 实例获取 SparkContext 对象的最简单方法是使用 sparkContext 属性。PySpark 库中还有一个 SparkConext 类,也可以用来直接创建 SparkContext 对象,这在 Spark 发布 2.0 之前是一个常见的方法。

注意

我们可以有多个 SparkSession 对象,但每个 JVM 只有一个 SparkContext 对象。

SparkSession 类提供了一些更多有用的方法和属性,以下将进行总结:

  • getActiveSession:此方法返回当前 Spark 线程下的一个活动 SparkSession

  • createDataFrame:此方法从 RDD、对象列表或 pandas DataFrame 对象创建 DataFrame 对象。

  • conf:此属性返回 Spark 会话的配置接口。

  • catalog:此属性提供了一个接口来创建、更新或查询相关的数据库、函数和表。

可以使用 PySpark 的 SparkSession 类文档中的完整方法列表和属性列表进行探索,文档地址为 spark.apache.org/docs/latest/api/python/reference/api/

探索 PySpark 用于 RDD 操作

介绍 RDD 部分中,我们介绍了一些 RDD 的关键函数和操作。在本节中,我们将通过代码示例扩展 PySpark 上下文中的讨论。

从 Python 集合和外部文件创建 RDD

在上一节中,我们讨论了几种创建 RDD 的方法。在下面的代码示例中,我们将讨论如何从内存中的 Python 集合和外部文件资源创建 RDD。这两种方法如下所述:

  • 要从 Python 数据集合创建 RDD,我们可以在 sparkContext 实例下使用 parallelize 方法。此方法将集合分发以形成一个 RDD 对象。该方法接受一个集合作为参数。parallelize 方法还提供了一个可选的第二个参数,用于设置要创建的分区数。默认情况下,此方法根据本地机器上的核心数或创建 SparkSession 对象时设置的核心数创建分区。

  • 要从外部文件创建 RDD,我们将使用在 sparkContext 实例下可用的 textFile 方法。textFile 方法可以从 HDFS 或本地文件系统(在所有集群节点上可用)加载文件作为 RDD。对于基于本地系统的部署,可以提供绝对和/或相对路径。可以使用此方法设置要为 RDD 创建的最小分区数。

下面的快速示例代码(rddcreate.py)展示了用于创建新 RDD 的 PySpark 语句的确切语法:

data = [5, 4, 6, 3, 2, 8, 9, 2, 8, 7,
        8, 4, 4, 8, 2, 7, 8, 9, 6, 9]
rdd1 = spark.sparkContext.parallelize(data)
print(rdd1.getNumPartitions())
rdd2 = spark.sparkContext.textFile('sample.txt')
print(rdd2.getNumPartitions())

注意,sample.txt 文件包含随机文本数据,其内容与这个代码示例无关。

使用 PySpark 的 RDD 转换操作

PySpark 提供了几个内置的转换操作。为了说明如何使用 PySpark 实现转换操作,例如 map,我们将以文本文件作为输入,并使用 RDD 中可用的 map 函数将其转换为另一个 RDD。下面的示例代码(rddtranform1.py)展示了:

rdd1 = spark.sparkContext.textFile('sample.txt') 
rdd2 = rdd1.map(lambda lines: lines.lower())
rdd3 = rdd1.map(lambda lines: lines.upper())
print(rdd2.collect())
print(rdd3.collect())

在这个示例代码中,我们使用 map 操作应用了两个 lambda 函数,将 RDD 中的文本转换为小写和大写。最后,我们使用 collect 操作来获取 RDD 对象的内容。

另一个流行的转换操作是 filter,它可以用来过滤掉一些数据条目。下面的示例代码(rddtranform2.py)展示了如何从一个 RDD 中过滤出所有偶数:

data = [5, 4, 6, 3, 2, 8, 9, 2, 8, 7,
        8, 4, 4, 8, 2, 7, 8, 9, 6, 9]
rdd1 = spark.sparkContext.parallelize(data)
rdd2 = rdd1.filter(lambda x: x % 2 !=0 )
print(rdd2.collect())

当你执行此代码时,它将提供包含 3、7、7 和 9 作为集合条目的控制台输出。接下来,我们将探索一些使用 PySpark 的动作示例。

使用 PySpark 的 RDD 动作操作

为了说明动作操作的实施,我们将使用从 Python 集合创建的 RDD,然后应用 PySpark 库中的一些内置动作操作。下面的示例代码(rddaction1.py)展示了:

data = [5, 4, 6, 3, 2, 8, 9, 2, 8, 7,
        8, 4, 4, 8, 2, 7, 8, 9, 6, 9]
rdd1 = spark.sparkContext.parallelize(data)
print("RDD contents with partitions:" + str(rdd1.glom().  collect()))
print("Count by values: " +str(rdd1.countByValue()))
print("reduce function: " + str(rdd1.glom().collect()))
print("Sum of RDD contents:"+str(rdd1.sum()))
print("top: " + str(rdd1.top(5)))
print("count: " + str(rdd1.count()))
print("max: "+ str(rdd1.max()))
print("min" + str(rdd1.min()))
time.sleep(60)

在这个代码示例中使用的某些动作操作是自解释的且简单(countmaxmincountsum)。其余的动作操作(非简单)将在下面解释:

  • glom:这会创建一个 RDD,它通过将所有数据条目与每个分区合并到一个列表中而创建。

  • collect:此方法返回 RDD 的所有元素作为列表。

  • reduce:这是一个通用的函数,可以应用于 RDD 以减少其中的元素数量。在我们的例子中,我们使用 lambda 函数将两个元素合并为一个,依此类推。这会导致将 RDD 中的所有元素相加。

  • top(x):如果数组中的元素是有序的,这个动作返回数组中的前 x 个元素。

我们已经介绍了如何使用 PySpark 创建 RDD,以及如何在 RDD 上实现转换和动作操作。在下一节中,我们将介绍 PySpark DataFrame,这是另一个主要用于分析的热门数据结构。

了解 PySpark DataFrame

PySpark DataFrame 是一个由行和列组成的表格数据结构,类似于我们在关系型数据库中拥有的表,以及我们在第六章“Python 高级技巧与窍门”中介绍的 pandas DataFrame。与 pandas DataFrame 相比,关键区别在于 PySpark DataFrame 对象是在集群中分布的,这意味着数据存储在集群的不同节点上。DataFrame 的使用主要是为了以分布式方式处理大量结构化或非结构化数据,这些数据可能达到 PB 级别。与 RDDs 类似,PySpark DataFrame 是不可变的,并且基于懒加载,这意味着评估将延迟到需要执行时。

我们可以在 DataFrame 中存储数值型以及字符串数据类型。PySpark DataFrame 中的列不能为空;它们必须具有相同的数据类型,并且长度必须相同。DataFrame 中的行可以具有不同的数据类型。DataFrame 中的行名必须是唯一的。

在接下来的小节中,我们将学习如何创建 DataFrame,并介绍使用 PySpark 在 DataFrame 上的一些关键操作。

创建 DataFrame 对象

可以使用以下数据源之一创建 PySpark DataFrame:

  • Python 的集合,如列表、元组和字典。

  • 文件(CSV、XML、JSON、Parquet 等)。

  • 通过使用 PySpark 的 toDF 方法或 createDataFrame 方法。

  • 可以使用 SparkSession 对象的 readStream 方法将 Apache Kafka 流消息转换为 PySpark DataFrame。

  • 可以使用传统的 SQL 命令查询数据库(例如 Hive 和 HBase)表,输出将被转换为 PySpark DataFrame。

我们将从 Python 集合创建 DataFrame 开始,这是最简单的方法,但更有助于说明目的。下面的示例代码展示了如何从员工数据集合创建 PySpark DataFrame:

data = [('James','','Bylsma','HR','M',40000),
  ('Kamal','Rahim','','HR','M',41000),
  ('Robert','','Zaine','Finance','M',35000),
  ('Sophia','Anne','Richer','Finance','F',47000),
  ('John','Will','Brown','Engineering','F',65000)
]
columns = ["firstname","middlename","lastname",
           "department","gender","salary"]
df = spark.createDataFrame(data=data, schema = columns)
print(df.printSchema())
print(df.show())

在此代码示例中,我们首先创建了一个员工行数据列表,然后创建了一个带有列名的模式。当模式仅是一个列名列表时,每个列的数据类型由数据确定,并且每个列默认标记为可空。可以使用更高级的 API (StructTypeStructField) 手动定义 DataFrame 模式,这包括设置数据类型和标记列是否可空。下面是此示例代码的控制台输出,首先显示模式,然后以表格形式显示 DataFrame 内容:

root
 |-- firstname: string (nullable = true)
 |-- middlename: string (nullable = true)
 |-- lastname: string (nullable = true)
 |-- department: string (nullable = true)
 |-- gender: string (nullable = true)
 |-- salary: long (nullable = true)
+---------+----------+--------+-----------+------+-------+
|firstname|middlename|lastname| department|gender|salary|
+---------+----------+--------+-----------+------+-------+
|    James|          |  Bylsma|         HR|     M|  40000|
|    Kamal|     Rahim|        |         HR|     M|  41000|
|   Robert|          |   Zaine|    Finance|     M|  35000|
|   Sophia|      Anne|  Richer|    Finance|     F|  47000|
|     John|      Will|   Brown|Engineering|     F|  65000|
+---------+----------+--------+-----------+------+-------+ 

在下一个代码示例中,我们将从 CSV 文件创建 DataFrame。CSV 文件将具有与上一个代码示例中相同的条目。在此示例代码 (dfcreate2.py) 中,我们还使用 StructTypeStructField 对象手动定义了模式:

schemas = StructType([ \
    StructField("firstname",StringType(),True), \
    StructField("middlename",StringType(),True), \
    StructField("lastname",StringType(),True), \
    StructField("department", StringType(), True), \
    StructField("gender", StringType(), True), \
    StructField("salary", IntegerType(), True) \
  ])
df = spark.read.csv('df2.csv', header=True, schema=schemas)
print(df.printSchema())
print(df.show())

此代码的控制台输出将与前面代码示例中显示的相同。使用类似语法,read方法支持将 JSON、文本或 XML 文件导入 DataFrame。对其他数据源的支持,如 RDDs 和数据库,留给你作为练习来评估和实现。

在 PySpark DataFrame 上工作

一旦我们从某些数据中创建了一个 DataFrame,无论数据的来源如何,我们就可以准备分析它、转换它,并对其采取一些操作以从中获得有意义的成果。PySpark DataFrame 支持的大多数操作与 RDDs 和 pandas DataFrame 类似。为了说明目的,我们将与前面的代码示例相同的数据加载到一个 DataFrame 对象中,然后执行以下操作:

  1. 使用select方法从 DataFrame 对象中选择一个或多个列。

  2. 使用字典和replace方法替换列中的值。PySpark 库中还有更多选项可用于替换列中的数据。

  3. 根据现有列的数据添加一个新列。

完整的示例代码(dfoperations.py)如下所示:

data = [('James','','Bylsma','HR','M',40000),
  ('Kamal','Rahim','','HR','M',41000),
  ('Robert','','Zaine','Finance','M',35000),
  ('Sophia','Anne','Richer','Finance','F',47000),
  ('John','Will','Brown','Engineering','F',65000)
]
columns = ["firstname","middlename","lastname",
           "department","gender","salary"]
df = spark.createDataFrame(data=data, schema = columns)
#show two columns
print(df.select([df.firstname, df.salary]).show())
#replacing values of a column
myDict = {'F':'Female','M':'Male'}
df2 = df.replace(myDict, subset=['gender'])
#adding a new colum Pay Level based on an existing column   values
df3 = df2.withColumn("Pay Level",
      when((df2.salary < 40000), lit("10")) \
     .when((df.salary >= 40000) & (df.salary <= 50000),           lit("11")) \
     .otherwise(lit("12")) \
  )
print(df3.show())

下面的输出是前面代码示例的结果:

图 8.6 – dfoperations.py 程序的控制台输出

图 8.6 – dfoperations.py 程序的控制台输出

第一张表显示了select操作的结果。下一张表显示了在gender列上执行replace操作的结果,以及一个新的列,Pay Level

有许多内置操作可用于与 PySpark DataFrame 一起使用,其中许多与我们在 pandas DataFrame 中讨论的相同。这些操作的详细信息可以通过使用 Apache Spark 官方文档(针对您使用的软件版本)进行探索。

在这一点上,任何人都可能会提出一个合法的问题,那就是,为什么我们应该使用 PySpark DataFrame,当我们已经有了提供相同类型操作的 pandas DataFrame 时? 答案非常简单。PySpark 提供了分布式 DataFrame,对这些 DataFrame 的操作旨在在节点集群上并行执行。这使得 PySpark DataFrame 的性能显著优于 pandas DataFrame。

到目前为止,我们已经看到,作为程序员,我们实际上并不需要编写任何关于如何将分布式 RDDs 和 DataFrames 委派给独立或分布式集群中不同执行器的代码。我们的重点只是数据处理方面的编程。与本地或远程节点集群的协调和通信由SparkSessionSparkContext自动处理。这正是 Apache Spark 和 PySpark 的美丽之处:让程序员专注于解决实际问题,而不是担心工作负载的执行方式。

介绍 PySpark SQL

Spark SQL 是 Apache Spark 的关键模块之一;它用于结构化数据处理,并充当分布式 SQL 查询引擎。正如你可以想象的那样,Spark SQL 具有高度的扩展性,作为一个分布式处理引擎。通常,Spark SQL 的数据源是一个数据库,但 SQL 查询可以应用于临时视图,这些视图可以从 RDDs 和 DataFrames 中构建。

为了展示使用 PySpark 库与 Spark SQL 的结合使用,我们将使用与前面示例代码相同的 DataFrame,使用员工数据构建一个 TempView 实例以进行 SQL 查询。在我们的代码示例中,我们将执行以下操作:

  1. 我们将创建一个 PySpark DataFrame,用于存储来自 Python 集合的员工数据,就像我们在前面的代码示例中所做的那样。

  2. 我们将使用 createOrReplaceTempView 方法从 PySpark DataFrame 创建一个 TempView 实例。

  3. 使用 Spark Session 对象的 sql 方法,我们将在 TempView 实例上执行传统的 SQL 查询,例如查询所有员工记录、查询薪资高于 45,000 的员工、查询按性别类型划分的员工数量,以及使用 group by SQL 命令对 gender 列进行分组。

完整的代码示例 (sql1.py) 如下:

data = [('James','','Bylsma','HR','M',40000),
  ('Kamal','Rahim','','HR','M',41000),
  ('Robert','','Zaine','Finance','M',35000),
  ('Sophia','Anne','Richer','Finance','F',47000),
  ('John','Will','Brown','Engineering','F',65000)
]
columns = ["firstname","middlename","lastname",
           "department","gender","salary"]
df = spark.createDataFrame(data=data, schema = columns)
df.createOrReplaceTempView("EMP_DATA")
df2 = spark.sql("SELECT * FROM EMP_DATA")
print(df2.show())
df3 = spark.sql("SELECT firstname,middlename,lastname,    salary FROM EMP_DATA WHERE SALARY > 45000")
print(df3.show())
df4 = spark.sql(("SELECT gender, count(*) from EMP_DATA     group by gender"))
print(df4.show())

控制台输出将显示三个 SQL 查询的结果:

+---------+----------+--------+-----------+------+------+
|firstname|middlename|lastname| department|gender|salary|
+---------+----------+--------+-----------+------+------+
|    James|          |  Bylsma|         HR|     M| 40000|
|    Kamal|     Rahim|        |         HR|     M| 41000|
|   Robert|          |   Zaine|    Finance|     M| 35000|
|   Sophia|      Anne|  Richer|    Finance|     F| 47000|
|     John|      Will|   Brown|Engineering|     F| 65000|
+---------+----------+--------+-----------+------+------+
+---------+----------+--------+------+
|firstname|middlename|lastname|salary|
+---------+----------+--------+------+
|   Sophia|      Anne|  Richer| 47000|
|     John|      Will|   Brown| 65000|
+---------+----------+--------+------+
+------+--------+
|gender|count(1)|
+------+--------+
|     F|       2|
|     M|       3|
+------+--------+

Spark SQL 是 Apache Spark 中的一个重要主题。我们只提供了 Spark SQL 的简介,以展示在不知道数据源的情况下,在 Spark 数据结构上使用 SQL 命令的强大功能。这标志着我们使用 PySpark 进行数据处理和数据分析的讨论结束。在下一节中,我们将讨论几个案例研究,以构建一些实际应用。

使用 Apache Spark 和 PySpark 的案例研究

在前面的章节中,我们介绍了 Apache Spark 和 PySpark 的基本概念和架构。在本节中,我们将讨论两个案例研究,以实现 Apache Spark 的两个有趣且流行的应用。

案例研究 1 – Apache Spark 上的 Pi (π) 计算器

我们将使用运行在我们本地机器上的 Apache Spark 集群来计算 Pi (π)。当圆的半径为 1 时,Pi 是圆的面积。在讨论此应用的算法和驱动程序之前,介绍用于此案例研究的 Apache Spark 设置非常重要。

设置 Apache Spark 集群

在所有之前的代码示例中,我们都在没有集群的情况下在我们的机器上使用本地安装的 PySpark。对于这个案例研究,我们将通过使用多个虚拟机来设置一个 Apache Spark 集群。有许多虚拟化软件工具可用,例如 VirtualBox,并且这些软件工具中的任何一种都可以用于构建这种类型的设置。

我们使用 Ubuntu Multipass (multipass.run/)在 macOS 上构建虚拟机。Multipass 在 Linux 和 Windows 上也能工作。Multipass 是一个轻量级的虚拟化管理器,专为开发者设计,用于通过单个命令创建虚拟机。Multipass 命令非常少,这使得它更容易使用。如果您决定使用 Multipass,我们建议您使用官方文档进行安装和配置。在我们的虚拟机设置中,我们使用 Multipass 创建了以下虚拟机:

图 8.7 – 为我们的 Apache Spark 集群创建的虚拟机

图 8.7 – 为我们的 Apache Spark 集群创建的虚拟机

我们通过使用apt-get实用程序在每个虚拟机上安装了Apache Spark 3.1.1。我们在vm1上以主节点启动 Apache Spark,然后通过提供主 Spark URI(在我们的案例中是Spark://192.168.64.2.7077)在vm2vm3上以工作节点启动 Apache Spark。完整的 Spark 集群设置将如下所示:

图 8.8 – Apache Spark 集群节点详细信息

图 8.8 – Apache Spark 集群节点详细信息

主 Spark 节点的 Web UI 如下所示:

图 8.9 – Apache Spark 集群中主节点的 Web UI

图 8.9 – Apache Spark 集群中主节点的 Web UI

这里给出了主节点的 Web UI 摘要:

  • Web UI 提供了节点名称和 Spark URL。在我们的案例中,我们使用了 IP 地址作为主机名,这就是为什么 URL 中有 IP 地址的原因。

  • 这里是工作节点的详细信息,在我们的案例中有两个。每个工作节点使用 1 个 CPU 核心和 1GB 内存。

  • Web UI 还提供了正在运行和已完成的应用程序的详细信息。

工作节点的 Web UI 将如下所示:

图 8.10 – Apache Spark 集群中工作节点的 Web UI

图 8.10 – Apache Spark 集群中工作节点的 Web UI

这里给出了工作节点的 Web UI 摘要:

  • Web UI 还提供了工作 ID 以及节点名称和工人监听请求的端口。

  • Web UI 还提供了主节点 URL。

  • 分配给工作节点的 CPU 核心和内存的详细信息也可用。

  • Web UI 提供了正在进行的作业(运行中的执行器)和已完成作业的详细信息。

编写 Pi 计算的驱动程序

为了计算 Pi,我们使用了一个常用的算法(蒙特卡洛算法),该算法假设一个面积为 4 的正方形包围着一个单位圆(半径值为 1 的圆)。想法是在一个边长为 2 的正方形域内生成大量随机数。我们可以假设有一个直径值与正方形边长相同的圆在正方形内部。这意味着圆将内嵌在正方形内。Pi 的值是通过计算位于圆内的点数与生成的总点数之比来估计的。

下面的示例展示了驱动程序的完整代码。在这个程序中,我们决定使用两个分区,因为我们有两个可用的工作者。我们为每个工作者使用了 10,000,000 个点。另一个需要注意的重要事项是,在创建 Apache Spark 会话时,我们使用了 Spark 主节点 URL 作为主属性:

#casestudy1.py: Pi calculator
from operator import add
from random import random
from pyspark.sql import SparkSession
spark = SparkSession.builder.master
        ("spark://192.168.64.2:7077") \
    .appName("Pi claculator app") \
    .getOrCreate()
partitions = 2
n = 10000000 * partitions
def func(_):
    x = random() * 2 – 1
    y = random() * 2 – 1
    return 1 if x ** 2 + y ** 2 <= 1 else 0
count = spark.sparkContext.parallelize(range(1, n + 1),     partitions).map(func).reduce(add)
print("Pi is roughly %f" % (4.0 * count / n))

控制台输出如下:

Pi is roughly 3.141479 

Spark 网页用户界面将在应用程序运行时提供其状态,甚至在执行完成后。在下面的屏幕截图中,我们可以看到有两个工作者参与了完成作业:

![Figure 8.11 – Pi 计算器在 Spark 网页用户界面中的状态img/B17189_08_11.jpg

图 8.11 – Pi 计算器在 Spark 网页用户界面中的状态

我们可以点击应用程序名称,以查看应用程序的下一级详细情况,如图 8.12 所示。这个屏幕截图显示了哪些工作者参与了完成任务以及正在使用哪些资源(如果任务仍在运行):

![Figure 8.12 – Pi 计算器应用程序执行器级别的细节img/B17189_08_12.jpg

图 8.12 – Pi 计算器应用程序执行器级别的细节

在这个案例研究中,我们介绍了如何为测试和实验目的设置 Apache Spark 集群,以及如何使用 PySpark 库在 Python 中构建驱动程序程序以连接到 Apache Spark 并提交我们的作业以在两个不同的集群节点上处理。

在下一个案例研究中,我们将使用 PySpark 库构建一个词云。

案例研究 2 – 使用 PySpark 的词云

词云是某些文本数据中出现单词频率的视觉表示。简单来说,如果一个特定的单词在文本中出现的频率更高,它在词云中就会更大、更粗。这些也被称为标签云文本云,是识别某些文本数据哪些部分更重要非常有用的工具。这个工具的一个实际用例是分析社交媒体上的内容,这在市场营销、商业分析和安全方面有许多应用。

为了说明目的,我们构建了一个简单的词云应用程序,该程序从本地文件系统读取文本文件。文本文件被导入到 RDD 对象中,然后进行处理以计算每个单词出现的次数。我们进一步处理数据以过滤掉出现次数少于两次的单词,并过滤掉长度小于四个字母的单词。单词频率数据被输入到 WordCloud 库对象中。为了显示词云,我们使用了 matplotlib 库。完整的示例代码如下:

#casestudy2.py: word count application
import matplotlib.pyplot as plt
from pyspark.sql import SparkSession
from wordcloud import WordCloud
spark = SparkSession.builder.master("local[*]")\
    .appName("word cloud app")\
    .getOrCreate()
wc_threshold = 1
wl_threshold = 3
textRDD = spark.sparkContext.textFile('wordcloud.txt',3)
flatRDD = textRDD.flatMap(lambda x: x.split(' '))
wcRDD = flatRDD.map(lambda word: (word, 1)).\
    reduceByKey(lambda v1, v2: v1 + v2)
# filter out words with fewer than threshold occurrences
filteredRDD = wcRDD.filter(lambda pair: pair[1] >=     wc_threshold)
filteredRDD2 = filteredRDD.filter(lambda pair:     len(pair[0]) > wl_threshold)
word_freq = dict(filteredRDD2.collect())
# Create the wordcloud object
wordcloud = WordCloud(width=480, height=480, margin=0).\
    generate_from_frequencies(word_freq)
# Display the generated cloud image
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis("off")
plt.margins(x=0, y=0)
plt.show()

这个程序的输出以窗口应用程序的形式展示,输出结果将如以下所示,基于提供给应用程序的样本文本(wordcloud.txt):

图 8.13 – 使用 PySpark RDDs 构建的词云

图片

图 8.13 – 使用 PySpark RDDs 构建的词云

注意,我们在这个示例中没有使用一个非常大的文本数据样本。在现实世界中,源数据可以极其庞大,这证明了使用 Apache Spark 集群进行处理的必要性。

这两个案例研究为您提供了使用 Apache Spark 进行大规模数据处理的能力。它们为对自然语言处理(NLP)、文本分析和情感分析感兴趣的您提供了一个基础。如果您是数据科学家,并且您的日常工作需要数据分析以及构建 NLP 算法,这些技能对您来说非常重要。

摘要

在本章中,我们探讨了如何在机器集群上执行数据密集型作业以实现并行处理。并行处理对于大规模数据非常重要,也称为大数据。我们首先评估了可用于数据处理的不同集群选项。我们提供了 Hadoop MapReduce 和 Apache Spark 的比较分析,这两个是集群的两个主要竞争平台。分析表明,Apache Spark 在支持的编程语言和集群管理系统方面具有更大的灵活性,并且由于其内存数据处理模型,它在实时数据处理方面优于 Hadoop MapReduce。

一旦我们确定 Apache Spark 是各种数据处理应用的最合适选择,我们就开始研究其基本数据结构,即 RDD。我们讨论了如何从不同的数据源创建 RDD,并介绍了两种类型的操作:转换和行动。

在本章的核心部分,我们探讨了使用 PySpark 通过 Python 创建和管理 RDD。这包括几个转换和行动操作的代码示例。我们还介绍了用于分布式数据处理下一级别的 PySpark DataFrames。我们通过介绍 PySpark SQL 和一些代码示例来结束这个主题。

最后,我们探讨了两个使用 Apache Spark 和 PySpark 的案例研究。这些案例研究包括计算π和从文本数据构建词云。在案例研究中,我们还介绍了如何为测试目的在本地机器上设置一个独立的 Apache Spark 实例。

本章为您提供了大量在本地设置 Apache Spark 以及使用虚拟化设置 Apache Spark 集群的经验。本章提供了大量的代码示例,供您增强实践技能。这对于任何希望使用集群以提高效率和规模来处理大数据问题的人来说都很重要。

在下一章中,我们将探讨利用 Apache Beam 等框架的选项,并扩展我们关于使用公共云进行数据处理讨论。

问题

  1. Apache Spark 与 Hadoop MapReduce 有何不同?

  2. 在 Apache Spark 中,转换与操作有何不同?

  3. Apache Spark 中的懒评估是什么?

  4. 什么是SparkSession

  5. PySpark DataFrame 与 pandas DataFrame 有何不同?

进一步阅读

  • 由 Jean-Georges Perrin 所著的《Spark in Action, 第二版

  • 由 Tomasz Drabas 和 Denny Lee 所著的《Learning PySpark

  • 由 Raju Kumar Mishra 所著的《PySpark Recipes

  • 使用您正在使用的版本spark.apache.org/docs/rel#的《Apache Spark 文档

  • 可在multipass.run/docs找到的《Multipass 文档

答案

  1. Apache Spark 是一个内存数据处理引擎,而 Hadoop MapReduce 则需要从文件系统中读取和写入。

  2. 转换应用于将数据从一种形式转换为另一种形式,并且结果保留在集群内。操作是对数据应用以获取返回给驱动程序的函数的结果。

  3. 懒评估主要应用于转换操作,这意味着转换操作在数据对象上触发操作之前不会执行。

  4. SparkSession是 Spark 应用程序的入口点,用于连接一个或多个集群管理器,并与执行器协同工作以执行任务。

  5. PySpark DataFrame 是分布式的,旨在在 Apache Spark 集群的多个节点上可用,以便进行并行处理。

第九章:第九章:云编程的 Python

云计算是一个广泛的概念,用于各种用例。这些用例包括提供物理或虚拟计算平台、软件开发平台、大数据处理平台、存储、网络功能、软件服务等等。在本章中,我们将从两个相关方面探讨云计算中的 Python。首先,我们将研究使用 Python 为云运行时构建应用程序的选项。然后,我们将从集群扩展到云环境,继续我们关于数据密集型处理的讨论,该讨论始于第八章使用集群扩展 Python。本章讨论的重点将主要集中在三个公共云平台;即,Google Cloud PlatformGCP)、Amazon Web ServicesAWS)和Microsoft Azure

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

  • 了解 Python 应用程序的云选项

  • 为云部署构建 Python 网络服务

  • 使用 Google Cloud Platform 进行数据处理

到本章结束时,您将了解如何开发和部署应用程序到云平台,以及如何一般性地使用 Apache Beam,特别是对于 Google Cloud Platform。

技术要求

以下为本章的技术要求:

  • 您需要在您的计算机上安装 Python 3.7 或更高版本。

  • 您需要一个 Google Cloud Platform 的服务帐户。免费帐户即可。

  • 您需要在您的计算机上安装 Google Cloud SDK。

  • 您需要在您的计算机上安装 Apache Beam。

本章的示例代码可在github.com/PacktPublishing/Python-for-Geeks/tree/master/Chapter09找到。

我们将首先探讨可用于为云部署开发应用程序的云选项。

了解 Python 应用程序的云选项

云计算现在是程序员的最终前沿。在本节中,我们将研究如何使用云开发环境或使用特定的软件开发工具包SDK)进行云部署来开发 Python 应用程序,然后如何在云环境中执行 Python 代码。我们还将研究有关数据密集型处理的选择,例如云上的 Apache Spark。我们将从基于云的开发环境开始。

介绍云开发环境中的 Python 开发环境

当涉及到为三种主要公共云之一设置 Python 开发环境时,有两种类型的模型可供选择:

  • 云原生集成开发环境IDE

  • 带有云集成选项的本地安装 IDE

我们将在接下来讨论这两种模式。

云原生 IDE

通常,有几个云原生开发环境可供选择,它们并不特定于三个公共云提供商。这些包括PythonAnyWhereRepl.itTrinketCodeanywhere。这些云环境大多数除了付费版本外,还提供免费许可证。这些公共云平台提供了开发环境的工具组合,如这里所述:

  • AWS:它提供了一种复杂的云 IDE,形式为AWS Cloud9,可以通过网页浏览器访问。这个云 IDE 为开发者提供了一套丰富的功能,并支持多种编程语言,包括 Python。重要的是要理解,AWS Cloud9 是以托管在 Amazon EC2 实例(虚拟机)上的应用程序的形式提供的。使用 AWS Cloud9 没有直接费用,但使用底层的 Amazon EC2 实例和存储空间将产生费用,对于有限的使用来说,这个费用非常微小。AWS 平台还提供用于构建和测试代码以实现持续集成CI)和持续交付CD)目标的工具。

    AWS CodeBuild是另一种可用的服务,它编译我们的源代码,运行测试,并为部署构建软件包。它是一个类似于 Bamboo 的构建服务器。AWS CodeStar通常与 AWS Cloud9 一起使用,提供了一个基于项目的平台,以帮助开发、构建和部署软件。AWS CodeStar 提供预定义的项目模板,以定义从代码发布到整个持续交付工具链的整个过程。

  • Microsoft Azure:这包括了Visual Studio集成开发环境(IDE),如果你是 Azure DevOps 平台的一部分,它可以在网上(基于云)使用。Visual Studio IDE 的在线访问基于付费订阅。Visual Studio IDE 因其丰富的功能和为团队协作提供的环境而闻名。Microsoft Azure 提供Azure Pipelines用于构建、测试和将代码部署到任何平台,如 Azure、AWS 和 GCP。Azure Pipelines 支持多种语言,如 Node.js、Python、Java、PHP、Ruby、C/C++和.NET,甚至移动开发工具包。

  • Google:Google 提供Cloud Code,用于编写、测试和部署代码,这些代码可以通过浏览器(如通过 ASW Cloud9)或使用你选择的本地 IDE 编写。Cloud Code 包含了对最流行的 IDE 的插件,如 IntelliJ IDE、Visual Studio Code 和 JetBrains PyCharm。Google Cloud Code 免费提供,针对容器运行时环境。像 AWS CodeBuild 和 Azure Pipelines 一样,Google 提供了一种等效服务,也称为Cloud Build,用于在多个环境中持续构建、测试和部署软件,如虚拟机和容器。Google 还提供 Google ColaboratoryGoogle Colab,它提供远程的 Jupyter Notebooks。Google Colab 选项在数据科学家中很受欢迎

    谷歌云也提供了TektonJenkins服务,用于构建 CI/CD 开发和交付模型。

除了所有这些专用工具和服务之外,这些云平台还提供了在线以及本地安装的 shell 环境。这些 shell 环境也是以有限容量管理代码的快捷方式。

接下来,我们将讨论使用 Python 进行云开发的本地 IDE 选项。

本地 IDE 用于云开发

云原生开发环境是一个很好的工具,可以在您的云生态系统中的其他部分实现原生集成选项。这使得即时实例化资源然后部署变得方便,而且不需要任何身份验证令牌。但这也带来了一些注意事项。首先,尽管这些工具大多是免费的,但它们所使用的底层资源并不是。第二个注意事项是,这些云原生工具的离线可用性并不无缝。开发者喜欢在没有在线连接的情况下编写代码,这样他们就可以在任何地方进行,比如在火车上或在公园里。

由于这些注意事项,开发者喜欢在部署到云平台之前,使用本地编辑器或 IDE 进行软件开发和测试,然后再使用额外的工具。例如,微软 Azure 的 IDE(如 Visual Studio 和 Visual Studio Code)适用于本地机器。AWS 和谷歌平台提供自己的 SDK(类似 shell 的环境)和插件,以便与您选择的 IDE 集成。我们将在本章后面探讨这些开发模型。

接下来,我们将讨论公共云上可用的运行时环境。

介绍 Python 的云运行时选项

获取 Python 运行时环境的最简单方法是通过获取安装了 Python 的 Linux 虚拟机或容器。一旦我们有了虚拟机或容器,我们也可以安装我们选择的 Python 版本。对于数据密集型工作负载,可以在云的计算节点上设置 Apache Spark 集群。但这要求我们拥有所有平台相关的任务和维护,以防万一出现问题。几乎所有的公共云平台都提供了更优雅的解决方案,以简化开发人员和 IT 管理员的生活。这些云服务提供商提供基于应用程序类型的一个或多个预构建的运行时环境。我们将讨论来自三个公共云提供商(亚马逊 AWS、GCP 和微软 Azure)的一些可用的运行时环境。

什么是运行时环境?

运行时环境是一个运行 Python 代码的执行平台。

亚马逊 AWS 提供的运行时选项

亚马逊 AWS 提供了以下运行时选项:

  • AWS Beanstalk:这个平台即服务PaaS)提供可以用于部署使用 Java、.NET、PHP、Node.js、Python 等开发的应用程序。这项服务还提供了使用 Apache、Nginx、Passenger 或 IIS 作为 Web 服务器的选项。这项服务在管理底层基础设施方面提供了灵活性,这对于部署复杂应用程序有时是必需的。

  • AWS App Runner:这项服务可用于运行带有 API 的容器化 Web 应用程序和微服务。这项服务是完全管理的,这意味着您没有管理责任,也没有访问底层基础设施的权限。

  • AWS Lambda:这是一个无服务器计算运行时,允许您运行代码而无需担心管理任何底层服务器。这个服务器支持多种语言,包括 Python。尽管 Lambda 代码可以直接从应用程序中执行,但这非常适合在触发其他 AWS 服务的事件时必须运行某些代码的情况。

  • AWS Batch:这个选项用于以批处理的形式运行大量计算作业。这是 Amazon 提供的一种云选项,是 Apache Spark 和 Hadoop MapReduce 集群选项的替代品。

  • AWS Kinesis:这项服务也是用于数据处理,但针对实时流数据。

GCP 提供的运行时选项

以下是从 GCP 可用的运行时选项:

  • 应用引擎:这是 GCP 提供的一种 PaaS 选项,可用于大规模开发和托管 Web 应用程序。应用程序作为容器在应用引擎上部署,但您的源代码由部署工具打包到容器中。这种复杂性对开发者来说是隐藏的。

  • CloudRun:这个选项用于托管已构建为容器的任何代码。容器应用程序必须具有 HTTP 端点才能在 CloudRun 上部署。与应用引擎相比,将应用程序打包到容器是开发者的责任。

  • 云函数:这是一个事件驱动的、无服务器且单一用途的解决方案,用于托管轻量级的 Python 代码。托管代码通常通过监听其他 GCP 服务上的事件或通过直接 HTTP 请求来触发。这类似于 AWS Lambda 服务。

  • 数据流:这是另一个无服务器选项,但主要用于数据处理,具有最小延迟。通过消除底层处理平台的复杂性,并基于 Apache Beam 提供数据管道模型,这简化了数据科学家的生活。

  • 数据科学:这项服务提供基于 Apache Spark、Apache Flink、Presto 等众多工具的计算平台。这个平台适合那些有依赖 Spark 或 Hadoop 生态系统的数据处理作业的人。这项服务要求我们手动配置集群。

微软 Azure 提供的运行时选项

微软 Azure 提供以下运行环境:

  • App Service:这项服务用于大规模构建和部署 Web 应用程序。这个 Web 应用程序可以作为容器部署,或者在 Windows 或 Linux 上运行。

  • Azure Functions:这是一个无服务器事件驱动的运行时环境,用于根据特定事件或直接请求执行代码。这与 AWS Lambda 和 GCP CloudRun 相当。

  • Batch:正如其名所示,这项服务用于运行需要数百或数千个虚拟机的云规模作业。

  • Azure Databricks:微软与 Databricks 合作,提供这个基于 Apache Spark 的平台,用于大规模数据处理。

  • Azure Data Factory:这是 Azure 提供的一个无服务器选项,你可以用它来处理流数据并将数据转换为有意义的成果。

正如我们所见,三大云服务提供商提供了基于可用应用程序和工作负载的多种执行环境。以下用例可以在云平台上部署:

  • 开发网络服务和 Web 应用程序

  • 使用云运行时进行数据处理

  • 使用 Python 的基于微服务的应用程序(容器)

  • 云服务中的无服务器函数或应用程序

我们将在本章接下来的部分中解决前两个用例。剩余的用例将在接下来的章节中讨论,因为它们需要更广泛的讨论。在下一节中,我们将开始使用 Python 构建一个网络服务,并探讨如何在 GCP App Engine 运行时环境中部署它。

为云部署构建 Python 网络服务

为云部署构建应用程序与本地部署略有不同。在开发和部署应用程序到任何云平台时,我们必须考虑以下三个关键要求:

  • Web 界面:对于大多数云部署,具有图形用户界面(GUI)或应用程序编程接口(API)的应用程序是主要候选者。基于命令行界面的应用程序除非部署在专用的虚拟机实例中,否则在云环境中不会获得其可用性。我们可以使用 SSH 或 Telnet 在虚拟机实例上执行它们。这就是为什么我们选择了基于 Web 界面的应用程序进行讨论。

  • 环境设置:所有公共云平台都支持多种语言,以及单一语言的多个版本。例如,截至 2021 年 6 月,GCP App Engine 支持 Python 3.7、3.8 和 3.9 版本。有时,云服务还允许你部署自己的版本。对于 Web 应用程序,设置一个访问代码和项目级设置的入口点也很重要。这些通常定义在一个单独的文件中(在 GCP App Engine 应用程序的情况下是一个 YAML 文件)。

  • 可以手动或使用PIP freeze命令来安装requirements.txt。还有其他优雅的方法可以解决这个问题,例如,将所有第三方库与应用程序打包成一个文件以进行云部署,例如 Java 网络归档文件(.war文件)。另一种方法是捆绑包含应用程序代码和目标执行平台的所有依赖项到一个容器中,并直接在容器托管平台上部署该容器。我们将在第十一章中探讨基于容器的部署选项,使用 Python 进行微服务开发

在 GCP App Engine 上部署 Python 网络服务应用程序至少有三种选项,如下所示:

  • 通过 CLI 界面使用 Google Cloud SDK

  • 使用 GCP 网络控制台(门户)以及 Cloud Shell(CLI 界面)

  • 使用第三方 IDE,如 PyCharm

我们将详细讨论第一个选项,并总结我们对其他两个选项的经验。

重要提示

在 AWS 和 Azure 上部署 Python 应用程序,在原则上步骤相同,但具体细节取决于每个云提供商提供的 SDK 和 API 支持。

使用 Google Cloud SDK

在本节中,我们将讨论如何使用 Google Cloud SDK(主要是 CLI 界面)创建和部署一个示例应用程序。此示例应用程序将部署在Google App EngineGAE)平台上。GAE 是一个 PaaS 平台,非常适合使用各种编程语言部署网络应用程序,包括 Python。

要使用 Google Cloud SDK 进行 Python 应用程序部署,我们必须在本地机器上满足以下先决条件:

  • 安装并初始化 Cloud SDK。安装后,您可以通过 CLI 界面访问它,并使用以下命令检查其版本。请注意,几乎所有 Cloud SDK 命令都以gcloud开头:

    gcloud –version
    
  • 安装 Cloud SDK 组件以添加 Python 3 的 App Engine 扩展。这可以通过以下命令完成:

    gcloud components install app-engine-python
    
  • GCP CloudBuild API 必须在 GCP 云项目中启用。

  • 即使您使用的是试用账户,也必须通过将 GCP 计费账户与项目关联来启用云项目的云计费。

  • 设置新的 App Engine 应用程序和启用 API 服务的 GCP 用户权限应在所有者级别完成。

接下来,我们将描述如何设置 GCP 云项目,创建一个示例网络服务应用程序,并将其部署到 GAE。

设置 GCP 云项目

GCP 云项目的概念与我们大多数开发 IDE 中看到的概念相同。一个 GCP 云项目由一组项目级设置组成,这些设置管理我们的代码如何与 GCP 服务交互以及跟踪项目使用的资源。一个 GCP 项目必须与一个计费账户关联。这是按项目跟踪消耗了多少 GCP 服务和资源的先决条件。

接下来,我们将解释如何使用 Cloud SDK 设置项目:

  1. 使用以下命令登录到 Cloud SDK。这将带您进入网页浏览器,以便您可以登录,如果您尚未这样做的话:

    gcloud init
    
  2. 创建一个名为 time-wsproj 的新项目。项目的名称应简短,仅使用字母和数字。为了更好的可读性,允许使用 -

    gcloud projects create time-wsproj
    
  3. 如果您尚未这样做,请使用以下命令将 Cloud SDK 的默认作用域切换到新创建的项目:

    gcloud config set project time-wsproj
    

    这将使 Cloud SDK 能够将此项目作为任何通过 Cloud SDK CLI 推送命令的默认项目。

  4. 通过使用以下命令之一并带有 project 属性,在默认项目或任何项目中创建 App Engine 实例:

    gcloud app create                   #for default project
    gcloud app create --project=time-wsproj  #for specific project
    

    注意,此命令将预留云资源(主要是计算和存储),并将提示您选择一个区域和区域来托管资源。您可以选择离您最近且从受众的角度看更合适的区域和区域:

  5. 为当前项目启用 Cloud Build API 服务。正如我们之前讨论的,Google Cloud Build 服务用于在部署到 Google 运行时(如 App Engine)之前构建应用程序。通过 GCP 网页控制台启用 Cloud Build API 服务更容易,因为它只需点击几下。要使用 Cloud SDK 启用它,首先,我们需要知道服务的确切名称。我们可以通过使用 gcloud services list 命令来获取可用 GCP 服务的列表。

    此命令将为您提供一系列 GCP 服务,以便您可以查找与 Cloud Build 相关的服务。您还可以使用带有任何命令的 format 属性来美化 Cloud SDK 的输出。为了使这更加方便,您可以使用 Linux 的 grep 工具(如果您使用的是 Linux 或 macOS)与该命令一起过滤结果,然后使用 enable 命令启用服务:

    gcloud services list --available | grep cloudbuild
    #output will be like: NAME: cloudbuild.googleapis.com
    #Cloud SDK command to enable this service
    gcloud services enable cloudbuild.googleapis.com
    
  6. 要为我们的项目启用 Cloud Billing API 服务,首先,我们需要将计费账户与我们的项目关联。Cloud SDK 中尚未通过此处提供的 beta 命令实现计费账户的支持:

    gcloud beta commands for the first time, you will be prompted to install the beta component. You should go ahead and install it. If you are already using a Cloud SDK version with a billing component included for GA, you can skip using the beta keyword or use the appropriate commands, as per the Cloud SDK release documentation.
    
  7. 通过遵循我们用于启用 Cloud Build API 的相同步骤,为当前项目启用 Cloud Billing API 服务。首先,我们必须找到 API 服务的名称,然后使用以下 Cloud SDK 命令集启用它:

    gcloud services list --available | grep cloudbilling
    #output will be: NAME: cloudbilling.googleapis.com
    #command to enable this service
    gcloud services enable cloudbilling.googleapis.com
    

对于经验丰富的云用户来说,设置云项目的步骤非常简单,不会超过几分钟。一旦项目设置完成,我们可以通过运行以下命令来获取项目配置详细信息:

gcloud projects describe time-wsproj

此命令的输出将提供项目生命周期的状态、项目名称、项目 ID 和项目编号。以下是一些示例输出:

createTime: '2021-06-05T12:03:31.039Z'
lifecycleState: ACTIVE
name: time-wsproj
projectId: time-wsproj
projectNumber: '539807460484'

现在项目已经设置好了,我们可以开始开发我们的 Python Web 应用程序。我们将在下一节中这样做。

构建 Python 应用程序

对于云部署,我们可以使用 IDE 或系统编辑器构建一个 Python 应用程序,然后使用 Cloud SDK 和 app-engine-python 组件 在本地模拟 App Engine 运行时,这些组件是我们作为先决条件安装的。作为一个例子,我们将构建一个基于 Web 服务的应用程序,通过 REST API 提供日期和时间。该应用程序可以通过 API 客户端或使用网页浏览器触发。我们没有启用任何身份验证,以保持部署简单。

要构建 Python 应用程序,我们将使用 Python 的 venv 包设置 Python 虚拟环境。使用 venv 包创建的虚拟环境将用于封装 Python 解释器、核心和第三方库和脚本,以保持它们与系统 Python 环境和其他 Python 虚拟环境分离。自 Python 3.3 以来,Python 已经支持使用 venv 包创建和管理虚拟环境。还有其他工具可用于创建虚拟环境,例如 virtualenvpipenv。PyPA 建议使用 venv 创建虚拟环境,因此我们选择了它来展示本书中的大多数示例。

作为第一步,我们将创建一个名为 time-wsproj 的 Web 应用程序项目目录,其中包含以下文件:

  • app.yaml

  • main.py

  • requirements.txt

我们使用了与创建云项目相同的目录名称,只是为了方便,但这不是必需的。让我们更详细地看看这些文件。

YAML 文件

此文件包含 App Engine 应用程序的部署和运行时设置,例如运行时版本号。对于 Python 3,app.yaml 文件必须至少有一个运行时参数(runtime: python38)。Web 应用程序中的每个服务都可以有自己的 YAML 文件。为了简化,我们将只使用一个 YAML 文件。在我们的情况下,此 YAML 文件将只包含运行时属性。为了说明目的,我们在示例 YAML 文件中添加了一些其他属性:

runtime: python38

main.py Python 文件

我们选择了 Flask 库来构建我们的示例应用程序。Flask 是一个广为人知的 Web 开发库,主要是因为它提供的强大功能和易用性。我们将在下一章中详细介绍 Flask。

main.py Python 模块是应用程序的入口点。应用程序的完整代码在此处展示:

from flask import Flask
from datetime import date, datetime
# If 'entrypoint' is not defined in app.yaml, App Engine will look #for an app variable. This is the case in our YAML file
app = Flask(__name__)
@app.route('/')
def welcome():
    return 'Welcome Python Geek! Use appropriate URI for date       and time'
@app.route('/date')
def today():
    today = date.today()
    return "{date:" + today.strftime("%B %d, %Y") + '}'
@app.route('/time')
def time():
    now = datetime.now()
    return "{time:" + now.strftime("%H:%M:%S") + '}'
if __name__ == '__main__':
    # For local testing
    app.run(host='127.0.0.1', port=8080, debug=True)

此模块提供了以下关键功能:

  • 在此模块中定义了一个默认的入口点 appapp 变量用于重定向发送到此模块的请求。

  • 使用 Flask 的注解,我们已为三个 URL 定义了处理程序:

    a) 根 / URL 将触发名为 welcome 的函数。welcome 函数返回一个字符串形式的问候消息。

    b) /date URL 将触发 today 函数,该函数将以 JSON 格式返回今天的日期。

    c) /time URL 将执行 time 函数,该函数将以 JSON 格式返回当前时间。

  • 在模块结束时,我们添加了一个 __main__ 函数来启动 Flask 附带的本地网络服务器,用于测试目的。

需求文件

此文件包含项目依赖项列表,用于第三方库。此文件的内容将被 App Engine 用于使所需的库可供我们的应用程序使用。在我们的案例中,我们需要 Flask 库来构建我们的示例网络应用程序。我们项目的此文件内容如下:

Flask==2.0.1

一旦我们创建了项目目录并创建了这些文件,我们必须在项目目录内或外创建一个虚拟环境,并使用 source 命令激活它:

python -m venv myenv
source myenv/bin/activate

在激活虚拟环境后,我们必须根据 requirements.txt 文件安装必要的依赖项。我们将使用位于 requirements.txt 文件所在目录的 pip 工具:

pip install -r requirements.txt

一旦 Flask 库及其依赖项已安装,在我们的 PyCharm IDE 中的目录结构将如下所示:

![图 9.1 – 示例网络应用的目录结构img/B17189_09_01.jpg

图 9.1 – 示例网络应用的目录结构

一旦设置好项目文件和依赖项,我们将使用以下命令在本地启动网络服务器:

python main.py

服务器将以以下调试消息启动,这清楚地表明此服务器选项仅用于测试目的,而不是用于生产环境:

* Serving Flask app 'main' (lazy loading)
* Environment: production
   WARNING: This is a development server. Do not use it in a      production deployment.
   Use a production WSGI server instead.
* Debug mode: on
* Running on http://127.0.0.1:8080/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 668-656-035

我们可以使用以下 URI 访问我们的网络服务应用程序:

  • http://localhost:8080/

  • http://localhost:8080/date

  • http://localhost:8080/time

这些 URI 的网页服务器的响应如下所示:

![图 9.2 – 来自我们的示例网络服务应用程序的网页浏览器中的响应img/B17189_09_02.jpg

图 9.2 – 来自我们的示例网络服务应用程序的网页浏览器中的响应

在我们进入下一阶段——即将此应用程序部署到 Google App Engine 之前,服务器将被停止。

部署到 Google App Engine

要将我们的网络服务应用程序部署到 GAE,我们必须从项目目录中使用以下命令:

gcloud app deploy

Cloud SDK 将读取app.yaml文件,该文件为创建此应用程序的 App Engine 实例提供输入。在部署期间,使用 Cloud Build 服务创建容器镜像;然后,将此容器镜像上传到 GCP 存储以进行部署。一旦成功部署,我们可以使用以下命令访问 Web 服务:

gcloud app browse

此命令将使用您机器上的默认浏览器打开应用程序。托管应用程序的 URL 将根据在应用程序创建期间选择的区域和区域而变化。

重要的是要理解,每次我们执行deploy命令时,它都会在 App Engine 中创建我们应用程序的新版本,这意味着将消耗更多资源。我们可以使用以下命令检查已安装的 Web 应用程序的版本:

gcloud app versions list

应用程序的老版本仍然可以处于服务状态,只是分配给它们的 URL 略有不同。可以使用gcloud app versions Cloud SDK 命令和版本 ID 来停止、启动或删除老版本。可以使用stopstart命令停止或启动应用程序,如下所示:

gcloud app versions stop <version id>
gcloud app versions start <version id>
gcloud app versions delete <version id>

当我们运行gcloud app versions list命令时,版本 ID 是可用的。这标志着我们关于构建和部署 Python Web 应用程序到 Google Cloud 的讨论结束。接下来,我们将总结如何利用 GCP 控制台部署相同的应用程序。

使用 GCP 网络控制台

GCP 控制台提供了一个易于使用的网络门户,用于访问和管理 GCP 项目,以及 Google Cloud Shell的在线版本。控制台还提供可定制的仪表板、查看项目使用的云资源、账单详情、活动日志以及许多其他功能。当涉及到使用 GCP 控制台开发和部署 Web 应用程序时,我们可以利用 Web UI 的一些功能,但大多数步骤将需要使用 Cloud Shell。这是一个通过任何浏览器在线提供的 Cloud SDK。

Cloud Shell 在几个方面都超越了 Cloud SDK:

  • 它提供了对gcloud CLI 以及kubectl CLI 的访问。kubectl用于管理 GCP Kubernetes 引擎上的资源。

  • 使用 Cloud Shell,我们可以使用Cloud Shell 编辑器来开发、调试、构建和部署我们的应用程序。

  • Cloud Shell 还提供了一个在线开发服务器,用于在部署到 App Engine 之前测试应用程序。

  • Cloud Shell 附带工具,可以在 Cloud Shell 平台和您的机器之间上传和下载文件。

  • Cloud Shell 具有在端口 8080 或您选择的端口上预览 Web 应用程序的能力。

设置新项目、构建应用程序和部署到 App Engine 所需的 Cloud Shell 命令与我们之前讨论的 Cloud SDK 中的命令相同。这就是为什么我们将这个步骤留给你,让你按照我们在上一节中描述的相同步骤进行探索。请注意,项目可以使用 GCP 控制台设置。可以通过在顶部菜单栏的右侧使用 Cloud Shell 图标来启用 Cloud Shell 界面。一旦 Cloud Shell 被启用,命令行界面将出现在控制台网页的底部。这在上面的屏幕截图中显示:

![Figure 9.3 – GCP console with Cloud Shell]

![img/B17189_09_03.jpg]

图 9.3 – 带有 Cloud Shell 的 GCP 控制台

如我们之前提到的,Cloud Shell 附带一个编辑器工具,可以通过使用打开编辑器按钮启动。以下屏幕截图显示了在Cloud Shell 编辑器中打开的 Python 文件:

Figure 9.4 – GCP console with Cloud Shell Editor

图 9.4 – 带有 Cloud Shell 编辑器的 GCP 控制台

在构建和部署 Web 应用程序时,另一个选择是使用带有 Google App Engine 插件的第三方 IDE。根据我们的经验,适用于常用 IDE(如 PyCharm 和 Eclipse)的插件大多是为 Python 2 和旧版 Web 应用程序库构建的。直接将 IDE 与 GCP 集成需要更多的工作和演变。在撰写本文时,最佳选择是直接使用您选择的编辑器或 IDE 配合 Cloud SDK 或 Cloud Shell 进行应用程序开发。

在本节中,我们介绍了使用 Python 开发 Web 应用程序并将其部署到 GCP App Engine 平台。Amazon 提供了 AWS Beanstalk 服务用于 Web 应用程序部署。在 AWS Beanstalk 中部署 Web 应用程序的步骤几乎与 GCP App Engine 相同,只是 AWS Beanstalk 不需要设置项目作为先决条件。因此,我们可以在 AWS Beanstalk 中更快地部署应用程序。

要在 AWS Beanstalk 中部署我们的 Web 服务应用程序,我们必须提供以下信息,无论是使用 AWS 控制台还是使用 AWS CLI:

  • 应用程序名称

  • 平台(Python 版本 3.7 或 3.8,在我们的例子中)

  • 源代码版本

  • 源代码,以及一个requirements.txt文件

我们建议对于依赖于第三方库的 Web 应用程序使用 AWS CLI。我们可以将我们的源代码作为 ZIP 文件或 Web 存档(WAR文件)从本地机器上传,或者从Amazon S3位置复制。

在 AWS Beanstalk 上部署 Web 应用程序的详细步骤可在 docs.aws.amazon.com/elasticbeanstalk/latest/dg/create-deploy-python-flask.html 获取。Azure 提供了 App Service 用于构建和部署 Web 应用程序。您可以在 docs.microsoft.com/en-us/azure/app-service/quickstart-python 找到在 Azure 上创建和部署 Web 应用程序的步骤。

接下来,我们将探讨使用云平台构建数据处理的驱动程序程序。

使用 Google Cloud Platform 进行数据处理

Google Cloud Platform 提供了 Cloud Dataflow 作为数据处理服务,旨在服务于批处理和实时数据流应用程序。此服务旨在为数据科学家和数据分析应用程序开发者提供设置数据处理 管道 的能力,以便他们可以进行数据分析。Cloud Dataflow 在底层使用 Apache Beam。Apache Beam 起源于 Google,但现在是一个 Apache 下的开源项目。该项目为使用管道构建数据处理提供了一个编程模型。这些管道可以使用 Apache Beam 创建,然后使用 Cloud Dataflow 服务执行。

Google Cloud Dataflow 服务类似于 Amazon Kinesis、Apache Storm、Apache Spark 和 Facebook Flux。在我们讨论如何使用 Python 进行 Google Dataflow 之前,我们将介绍 Apache Beam 及其管道概念。

学习 Apache Beam 的基础知识

在当前时代,数据对于许多组织来说就像一头现金牛。应用程序、设备和人类与系统交互都会产生大量数据。在消费数据之前,处理数据非常重要。在 Apache Beam 的术语中,为数据处理定义的步骤通常被称为管道。换句话说,数据管道是一系列对来自不同来源的原始数据进行操作的动作,然后将这些数据移动到目的地,以便由分析或商业应用程序消费。

Apache Beam 用于将问题分解成可以并行处理的小数据包。Apache Beam 的主要用例之一是 提取、转换和加载ETL)应用程序。这三个 ETL 步骤在将数据从原始形式转换为用于消费的精炼形式时是管道的核心。

Apache Beam 的核心概念和组件如下:

  • 管道:管道是将数据从一种形式转换为另一种形式的方案,作为数据处理的一部分。

  • PCollection:PCollection,或并行集合,类似于 Apache Spark 中的 RDD。它是一个包含不可变和无序元素集合的分布式数据集。数据集的大小可以是固定的或有限的,类似于批量处理,我们知道在一个批次中要处理多少个作业。大小也可以根据持续更新和流数据源而灵活或无界。

  • PTransforms:这些是在管道中定义以转换数据的操作。这些操作在 PCollection 对象上执行。

  • SDK:一种特定于语言的软件开发工具包,可用于 Java、Python 和 Go 来构建管道并将它们提交给运行器执行。

  • 默认情况下是异步的 run (Pipeline)。一些可用的运行器包括 Apache Flink、Apache Spark 和 Google Cloud Dataflow。

  • DoFn,它基于每个元素进行操作。提供的 DoFn 实现被封装在一个为并行执行设计的 ParDo 对象中。

一个简单的管道看起来如下:

图 9.5 – 具有三个 PTransform 操作的管道流程

图 9.5 – 具有三个 PTransform 操作的管道流程

设计管道时,我们通常必须考虑三个要素:

  1. 首先,我们需要了解数据源。它是存储在文件中、数据库中,还是作为流来?基于此,我们将确定需要实现哪种类型的读取转换操作。作为读取操作的一部分或作为单独的操作,我们还需要了解数据格式或结构。

  2. 下一步是定义和设计如何处理这些数据。这是我们主要的转换操作。我们可以以串行方式或并行方式在相同的数据上执行多个转换操作。Apache Beam SDK 提供了几个预构建的转换,可以用于此。它还允许我们使用 ParDo/DoFn 函数编写自己的转换。

  3. 最后,我们需要知道我们的管道输出将是什么以及在哪里存储输出结果。这在前面的图中显示为写入转换。

在本节中,我们讨论了一个简单的管道结构来解释与 Apache Beam 和管道相关的不同概念。在实践中,管道可能相对复杂。管道可以有多个输入数据源和多个输出接收器。PTransforms 操作可能导致多个 PCollection 对象,这需要并行执行 PTransform 操作。

在接下来的几节中,我们将学习如何创建一个新的管道以及使用 Apache Beam 运行器或 Cloud Dataflow 运行器执行管道。

介绍 Apache Beam 管道

在本节中,我们将讨论如何创建 Apache Beam 管道。正如我们之前所讨论的,管道是一系列旨在实现特定数据处理目标的动作或操作。管道需要一个可以包含内存数据、本地或远程文件或流数据的输入数据源。典型管道的伪代码将如下所示:

[Final PColletcion] = ([Initial Input PCollection] |     [First PTransform] | [Second PTransform] | [Third PTransform])

初始 PCollection 被用作第一个 PTransform 操作的输入。第一个 PTransform 的输出 PCollection 将用作第二个 PTransform 的输入,依此类推。最后一个 PTransform 的 PCollection 的最终输出将被捕获为最终 PCollection 对象,并用于将结果导出到目标位置。

为了说明这个概念,我们将构建几个不同复杂程度的示例管道。这些示例旨在展示在构建和执行管道时使用的不同 Apache 组件和库的作用。最后,我们将构建一个用于著名 词频统计 应用程序的管道,该应用程序也参考了 Apache Beam 和 GCP Dataflow 文档。重要的是要强调,我们必须使用 pip 工具安装 apache-beam Python 库,以便在本节的所有代码示例中使用。

示例 1 – 使用内存中的字符串数据创建管道

在这个例子中,我们将从一个内存中的字符串集合创建一个输入 PCollection,应用几个转换操作,然后将结果打印到输出控制台。以下是完全的示例代码:

#pipeline1.py: Separate strings from a PCollection
import apache_beam as beam
with beam.Pipeline() as pipeline:
  subjects = (
      pipeline
      | 'Subjects' >> beam.Create([
          'English Maths Science French Arts', ])
      | 'Split subjects' >> beam.FlatMap(str.split)
      | beam.Map(print))

对于这个例子,重要的是要强调以下几点:

  • 我们使用了 | 来在管道中写入不同的 PTransform 操作。这是一个重载的操作符,更像是将 PTransform 应用到 PCollection 上以产生另一个 PCollection。

  • 我们使用了 >> 操作符为每个 PTransform 操作命名,用于日志记录和跟踪目的。|>> 之间的字符串用于显示和日志记录目的。

  • 我们使用了三个转换操作;它们都是 Apache Beam 库的一部分:

    a) 第一个转换操作用于创建一个 PCollection 对象,它是一个包含五个主题名称的字符串。

    b) 第二个转换操作用于使用内置的 String 对象方法(split)将字符串数据拆分到一个新的 PCollection 中。

    c) 第三个转换操作用于将 PCollection 中的每个条目打印到控制台输出。

控制台的输出将显示一个主题名称列表,每个名称占一行。

示例 2 – 使用内存中的元组数据创建和处理管道

在这个代码示例中,我们将创建一个元组 PCollection。每个元组将有一个与之关联的主题名称和成绩。该管道的核心 PTransform 操作是从数据中分离主题及其成绩。示例代码如下:

#pipeline2.py: Separate subjects with grade from a PCollection
import apache_beam as beam
def my_format(sub, marks):
    yield '{}\t{}'.format(sub,marks)
with beam.Pipeline() as pipeline:
  plants = (
      pipeline
      | 'Subjects' >> beam.Create([
      ('English','A'),
      ('Maths', 'B+'),
      ('Science', 'A-'),
      ('French', 'A'),
      ('Arts', 'A+'),
      ])
      | 'Format subjects with marks' >> beam.FlatMapTuple(my_        format)
      | beam.Map(print))

与第一个示例相比,我们使用了FlatMapTuple转换操作和自定义函数来格式化元组数据。控制台输出将显示每个主题的名称及其成绩,每行一个。

示例 3 – 使用文本文件数据创建管道

在前两个示例中,我们专注于构建一个简单的管道来解析来自大字符串的字符串数据,以及从元组的 PCollection 中拆分元组。在实践中,我们正在处理大量数据,这些数据可能来自文件或存储系统,或者来自流式源。在此示例中,我们将从本地文本文件中读取数据以构建我们的初始 PCollection 对象,并将最终结果输出到输出文件。完整的代码示例如下:

#pipeline3.py: Read data from a file and give results back to another file
import apache_beam as beam
from apache_beam.io import WriteToText, ReadFromText
with beam.Pipeline() as pipeline:
    lines = pipeline | ReadFromText('sample1.txt')
    subjects = (
      lines
      | 'Subjects' >> beam.FlatMap(str.split))
    subjects | WriteToText(file_path_prefix='subjects', 
                           file_name_suffix='.txt',
                           shard_name_template='')

在此代码示例中,我们在应用任何数据处理相关的 PTransform 操作之前,先对一个 PTransform 操作进行了应用,以从文件中读取文本数据。最后,我们应用了一个 PTransform 操作将数据写入输出文件。在此代码示例中,我们使用了两个新函数ReadFromTextWriteToText,如上所述:

  • ReadFromText:此函数是 Apache Beam I/O 模块的一部分,用于将数据从文本文件读取到字符串的 PCollection 中。文件路径或文件模式可以作为输入参数提供,以从本地路径读取。我们也可以使用gs://来访问 GCS 存储位置中的任何文件。

  • WriteToText:此函数用于将 PCollection 数据写入文本文件。至少需要提供file_path_prefix参数。我们还可以提供file_path_suffix参数来设置文件扩展名。shard_name_template设置为空,以使用前缀和后缀参数创建文件名。Apache Beam 支持基于模板定义文件名的分片名称模板。

当此管道在本地执行时,它将创建一个名为subjects.txt的文件,其中包含 PTransform 操作捕获的主题名称。

示例 4 – 使用参数创建 Apache Beam 运行器的管道

到目前为止,我们已经学习了如何创建一个简单的管道,如何从文本文件构建 PCollection 对象,以及如何将结果写回文件。除了这些核心步骤之外,我们还需要执行一些额外的步骤,以确保我们的驱动程序已准备好将作业提交给 GCP Dataflow 运行器或任何其他云运行器。这些附加步骤如下:

  • 在上一个示例中,我们在驱动程序中提供了输入文件和输出文件模式的名称。在实际应用中,我们应该期望这些参数通过命令行参数提供。我们将使用argparse库来解析和管理命令行参数。

  • 我们将添加扩展参数,例如设置运行器。此参数将用于使用 DirectRunner 或 GCP Dataflow 运行器设置管道的目标运行器。请注意,DirectRunner 是用于您本地机器的管道运行时。它确保这些管道尽可能紧密地遵循 Apache Beam 模型。

  • 我们还将实现并使用ParDo函数,该函数将利用我们自定义构建的从文本数据中解析字符串的功能。我们可以使用String函数来实现这一点,但在此处添加是为了说明如何使用ParDoDoFn与 PTransform 一起使用。

这里是步骤:

  1. 首先,我们将构建参数解析器并定义我们期望从命令行接收的参数。我们将设置这些参数的默认值,并设置附加的帮助文本,以便在命令行的help开关中显示。dest属性很重要,因为它用于识别任何在编程语句中使用的参数。我们还将定义ParDo函数,该函数将用于执行管道。以下是一些示例代码:

    #pipeline4.py(part 1): Using argument for a pipeline
    import re, argparse, apache_beam as beam
    from apache_beam.io import WriteToText, ReadFromText
    from apache_beam.options.pipeline_options import   PipelineOptions
    class WordParsingDoFn(beam.DoFn):
      def process(self, element):
        return re.findall(r'[\w\']+', element, re.UNICODE)
    def run(argv=None):
        parser = argparse.ArgumentParser()
        parser.add_argument(
            '--input',
            dest='input',
            default='sample1.txt',
            help='Input file to process.')
        parser.add_argument(
            '--output',
            dest='output',
            default='subjects',
            help='Output file to write results to.')
        parser.add_argument(
            '--extension',
            dest='ext',
            default='.txt',
            help='Output file extension to use.')
        known_args, pipeline_args = parser.parse_known_      args(argv)
    
  2. 现在,我们将设置DirectRunner作为我们的管道运行时,并命名要执行的任务。此步骤的示例代码如下:

    #pipeline4.py(part 2): under the run method
        pipeline_args.extend([
            '--runner=DirectRunner',
            '--job_name=demo-local-job',
        ])
        pipeline_options = PipelineOptions(pipeline_args)
    
  3. 最后,我们将使用之前步骤中创建的pipeline_options对象创建一个管道。该管道将从输入文本文件中读取数据,根据我们的ParDo函数转换数据,然后将结果保存为输出:

    #sample1.txt) are parsed and are put as one word in one line in the output file. 
    

Apache Beam 是一个庞大的主题,如果不写几章关于它的内容,就不可能涵盖其所有功能。然而,我们已经通过提供代码示例来涵盖基础知识,这些示例将使我们能够开始编写简单的管道,我们可以在 GCP Cloud Dataflow 上部署这些管道。我们将在下一节中介绍这一点。

构建 Cloud Dataflow 的管道

我们之前讨论的代码示例主要集中在构建简单的管道并使用 DirectRunner 执行它们。在本节中,我们将构建一个驱动程序来在 Google Cloud Dataflow 上部署单词计数数据处理管道。此驱动程序对于 Cloud Dataflow 部署非常重要,因为我们将在此程序内设置所有云相关参数。因此,将无需使用 Cloud SDK 或 Cloud Shell 来执行额外的命令。

单词计数管道将是我们pipeline4.py示例的扩展版本。部署单词计数管道所需的附加组件和步骤在此总结:

  1. 首先,我们将使用与我们在为 App Engine 部署我们的网络服务应用程序时遵循的步骤类似的步骤创建一个新的 GCP 云项目。我们可以使用 Cloud SDK、Cloud Shell 或 GCP 控制台来完成此任务。

  2. 我们将为新项目启用 Dataflow Engine API。

  3. 接下来,我们将创建一个存储桶来存储输入和输出文件,并为 Cloud Dataflow 提供临时和存档目录。我们可以通过使用 GCP 控制台、Cloud Shell 或 Cloud SDK 来实现这一点。如果我们使用 Cloud Shell 或 Cloud SDK 创建新存储桶,可以使用以下命令:

    gsutil mb gs://<bucket name>
    
  4. 如果新创建的存储桶不在与数据流管道作业相同的项目下,您可能需要将服务帐户与存储桶关联,并选择 存储对象管理员 角色以进行访问控制。

  5. 我们必须使用必要的 gcp 库安装 Apache Beam。这可以通过使用 pip 工具实现,如下所示:

    pip install apache-beam[gcp]
    
  6. 我们必须为用于 GCP 云项目的 GCP 服务帐户创建一个用于身份验证的密钥。如果我们将在 GCP 平台(如 Cloud Shell)上运行驱动程序程序,则不需要这样做。服务帐户密钥必须下载到您的本地机器上。为了使密钥对 Apache Beam SDK 可用,我们需要将密钥文件(一个 JSON 文件)的路径设置为名为 GOOGLE_APPLICATION_CREDENTIALS 的环境变量。

在讨论如何在 Cloud Dataflow 上执行管道之前,我们将快速查看此练习的样本词频驱动程序程序。在这个驱动程序程序中,我们将定义与之前代码示例(pipeline4.py)中非常相似的命令行参数,除了我们将执行以下操作:

  • 为了便于执行和测试,我们不会通过操作系统设置 GOOGLE_APPLICATION_CREDENTIALS 环境变量,而是使用我们的驱动程序程序来设置它。

  • 我们将上传 sample.txt 文件到 Google 存储,在我们的例子中是 gs//muasif/input 目录。我们将使用此 Google 存储的路径作为 input 参数的默认值。

完整的示例代码如下:

# wordcount.py(part 1): count words in a text file
import argparse, os, re, apache_beam as beam
from apache_beam.io import ReadFromText, WriteToText
from apache_beam.options.pipeline_options import PipelineOptions
from apache_beam.options.pipeline_options import SetupOptions
def run(argv=None, save_main_session=True):
  os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = "some folder/    key.json"
  parser = argparse.ArgumentParser()
  parser.add_argument(
      '--input',
      dest='input',
      default='gs://muasif/input/sample.txt',
      help='Input file to process.')
  parser.add_argument(
      '--output',
      dest='output',
      default='gs://muasif/input/result',
      help='Output file to write results to.')
  known_args, pipeline_args = parser.parse_known_args(argv)

在下一步中,我们将设置管道选项的扩展参数,以便在 Cloud Dataflow 运行时上执行我们的管道。这些参数如下:

  • 管道执行的运行平台(运行器)(在这种情况下为 DataflowRunner)

  • GCP 云项目 ID

  • GCP 区域

  • 存储输入、输出和临时文件的 Google 存储桶路径

  • 用于跟踪的作业名称

根据这些参数,我们将创建一个用于管道执行的管道选项对象。这些任务的示例代码如下:

# wordcount.py (part 2): under the run method
  pipeline_args.extend([
      '--runner=DataflowRunner',
      '--project=word-count-316612',
      '--region=us-central1',
      '--staging_location=gs://muasif/staging',
      '--temp_location=gs://muasif/temp',
      '--job_name=my-wordcount-job',
  ])
  pipeline_options = PipelineOptions(pipeline_args)
  pipeline_options.view_as(SetupOptions).\
      save_main_session = save_main_session

最后,我们将实现一个管道,其中包含已定义的管道选项,并添加我们的 PTransform 操作。对于此代码示例,我们添加了一个额外的 PTransform 操作来构建每个单词与 1 的配对。在后续的 PTransform 操作中,我们将配对分组并应用 sum 操作来计算它们的频率。这给出了输入文本文件中每个单词的计数:

# wordcount.py (part 3): under the run method
  with beam.Pipeline(options=pipeline_options) as p:
    lines = p | ReadFromText(known_args.input)
    # Count the occurrences of each word.
    counts = (
        lines
        | 'Split words' >> (
            beam.FlatMap(
                lambda x: re.findall(r'[A-Za-z\']+', x)).
                with_output_types(str))
        | 'Pair with 1' >> beam.Map(lambda x: (x, 1))
        | 'Group & Sum' >> beam.CombinePerKey(sum))
    def format_result(word_count):
      (word, count) = word_count
      return '%s: %s' % (word, count)
    output = counts | 'Format' >> beam.Map(format_result)
    output | WriteToText(known_args.output)

我们在驱动程序程序中为每个参数设置了默认值。这意味着我们可以直接使用python wordcount.py命令执行程序,或者我们可以使用以下命令通过 CLI 传递参数:

python wordcount.py \
    --project word-count-316612 \
    --region us-central1 \
    --input gs://muasif/input/sample.txt \
    --output gs://muasif/output/results \
    --runner DataflowRunner \
    --temp_location gs://muasif/temp \
    --staging_location gs://muasif/staging

输出文件将包含结果,以及文件中每个单词的计数。GCP Cloud Dataflow 为监控提交作业的进度和理解执行作业的资源利用率提供了额外的工具。以下 GCP 控制台的屏幕截图显示了已提交给 Cloud Dataflow 的作业列表。摘要视图显示了它们的状态和一些关键指标:

![图 9.6 – 云数据流作业摘要图片

图 9.6 – 云数据流作业摘要

我们可以导航到详细作业视图(通过点击任何作业名称),如下面的屏幕截图所示。此视图显示右侧的作业和环境详情以及我们为管道定义的不同 PTransforms 的进度摘要。当作业运行时,每个 PTransform 操作的状态会实时更新,如下所示:

![图 9.7 – 带流程图和指标的云数据流作业详细视图图片

图 9.7 – 带流程图和指标的云数据流作业详细视图

一个非常重要的要点是,不同的 PTransform 操作是根据我们使用>>操作符时使用的字符串命名的。这有助于方便地可视化操作。这标志着我们对构建和部署 Google Dataflow 管道的讨论结束。与 Apache Spark 相比,Apache Beam 为并行和分布式数据处理提供了更多的灵活性。随着云数据处理选项的可用性,我们可以完全专注于建模管道,并将执行管道的工作留给云服务提供商。

如我们之前提到的,亚马逊提供类似的服务(AWS Kinesis)用于部署和执行管道。AWS Kinesis 更专注于实时数据的数据流。与 AWS Beanstalk 类似,AWS Kinesis 不需要我们作为先决条件设置一个项目。使用 AWS Kinesis 进行数据处理的用户指南可在docs.aws.amazon.com/kinesis/找到。

摘要

在本章中,我们讨论了 Python 在开发云部署应用程序中的作用,以及使用 Python 和 Apache Beam 在 Google Cloud Dataflow 上部署数据处理管道的使用。我们通过比较三个主要的公共云提供商在开发、构建和部署不同类型应用程序方面所提供的内容开始本章。我们还比较了每个云提供商提供的运行环境选项。我们了解到,每个云提供商都提供基于应用程序或程序的多种运行引擎。例如,我们有针对经典 Web 应用程序、基于容器的应用程序和无服务器函数的独立运行引擎。为了探索 Python 在云原生 Web 应用程序中的有效性,我们构建了一个示例应用程序,并学习了如何使用 Cloud SDK 在 Google App Engine 上部署此类应用程序。在最后一节中,我们扩展了关于数据处理(管道)的讨论,这是我们在上一章开始的。我们介绍了使用 Apache Beam 的数据处理(管道)的新建模方法。一旦我们通过几个代码示例学习了如何构建管道,我们就将讨论扩展到如何构建用于 Cloud Dataflow 部署的管道。

本章提供了对公共云服务提供的比较分析。随后,我们介绍了构建云应用程序和数据处理应用程序的实战知识。本章包含的代码示例将使您能够开始创建云项目并为 Apache Beam 编写代码。对于任何希望使用基于云的数据处理服务来解决大数据问题的人来说,这种知识非常重要。

在下一章中,我们将探讨使用 Flask 和 Django 框架开发 Web 应用程序的 Python 的强大功能。

问题

  1. AWS Beanstalk 与 AWS App Runner 有何不同?

  2. GCP 云函数服务是什么?

  3. GCP 提供哪些数据处理服务?

  4. Apache Beam 管道是什么?

  5. PCollection 在数据处理管道中扮演什么角色?

进一步阅读

答案

  1. AWS Beanstalk 是一种通用的 PaaS 服务,用于部署 Web 应用程序,而 AWS App Runner 是一种完全托管的服务,用于部署基于容器的 Web 应用程序。

  2. GCP Cloud Function 是一种无服务器、事件驱动的服务,用于执行程序。指定的事件可以由另一个 GCP 服务触发,或者通过 HTTP 请求触发。

  3. Cloud Dataflow 和 Cloud Dataproc 是 GCP 提供的两种流行的数据处理服务。

  4. Apache Beam 管道是一组定义好的操作,用于加载数据、将数据从一种形式转换成另一种形式,并将数据写入目的地。

  5. PCollection 类似于 Apache Spark 中的 RDD,用于存储数据元素。在管道数据处理中,典型的 PTransform 操作以一个或多个 PCollection 对象作为输入,并以一个或多个 PCollection 对象的形式产生结果。

第四部分:使用 Python 进行 Web、云和网络用例

我们不会结束我们的旅程,直到我们将到目前为止所学的内容应用到构建现代解决方案中,特别是针对云的解决方案。这是我们旅程的核心部分,我们通过解决现实世界的问题来挑战我们的学习进度。首先,我们探讨如何使用 Python 框架如 Flask 构建 Web 应用程序和 REST API。接下来,我们深入探讨使用微服务架构和无服务器函数在云中实现无服务器应用程序。我们涵盖了我们的代码练习和案例研究的本地和云部署选项。稍后,我们探索如何使用 Python 构建机器学习模型并在云中部署它们。我们通过调查 Python 在网络自动化中的作用,结合现实世界的网络管理相关用例来结束我们的旅程。

本节包含以下章节:

  • 第十章, 使用 Python 进行 Web 开发和 REST API

  • 第十一章, 使用 Python 进行微服务开发

  • 第十二章, 使用 Python 构建无服务器函数

  • 第十三章, Python 与机器学习

  • 第十四章, 使用 Python 进行网络自动化

第十章:第十章:使用 Python 进行网络开发和 REST API

网络应用是一种在内部网络或互联网上由 网络服务器 托管和运行的应用程序类型,通过客户端设备上的网络浏览器访问。使用网络浏览器作为客户端界面使用户能够从任何地方访问应用程序,而无需在本地机器上安装任何额外的软件。这种易于访问的特性促进了网络应用超过二十年的成功和流行。网络应用的使用范围广泛,从提供静态和动态内容(如维基百科和报纸)、电子商务、在线游戏、社交网络、培训、多媒体内容、调查和博客,到复杂的 企业资源规划(ERP)应用。

从本质上讲,网络应用是多层的,通常是三层应用。这三层包括用户界面(UI)、业务逻辑和数据库访问。因此,开发网络应用涉及到与用于 UI 的网络服务器、用于业务逻辑的应用服务器以及用于持久化数据的数据库系统进行交互。在移动应用时代,UI 可能是一个需要通过 REST API 访问业务逻辑层的移动应用。REST API 或任何类型的网络服务接口的可用性已成为网络应用的基本需求。本章将讨论如何使用 Python 构建 multi-tier 网络应用。Python 中有多个框架可用于开发网络应用,但我们在本章选择 Flask 进行讨论,因为它功能丰富但轻量级。网络应用也被称作 web apps,以区分针对小型设备的移动应用。

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

  • 网络开发的学习需求

  • 介绍 Flask 框架

  • 使用 Python 与数据库交互

  • 使用 Python 构建 REST API

  • 案例研究:使用 REST API 构建 web 应用程序

到本章结束时,你将能够使用 Flask 框架开发网络应用,与数据库交互,并构建 REST API 或网络服务。

技术要求

以下为本章的技术要求:

  • 你需要在你的计算机上安装 Python 3.7 或更高版本。

  • Python Flask 库 2.x 及其扩展,安装在 Python 3.7 或更高版本之上。

本章的示例代码可在 github.com/PacktPublishing/Python-for-Geeks/tree/master/Chapter10 找到。

我们将首先讨论开发网络应用和 REST API 的关键需求。

网络开发的学习需求

开发网络应用程序包括构建 UI、将用户请求或用户操作路由到应用程序端点、转换用户输入数据、编写用户请求的业务逻辑、与数据层交互以读取或写入数据,并将结果返回给用户。所有这些开发组件可能需要不同的平台,有时甚至需要使用不同的编程语言来实现。在本节中,我们将了解网络开发所需的组件和工具,从网络应用程序框架或网络框架开始。

网络框架

从零开始开发网络应用程序既耗时又繁琐。为了方便网络开发者,网络应用程序框架在 Web 开发的早期阶段就被引入了。网络框架提供了一套库、目录结构、可重用组件和部署工具。网络框架通常遵循一种架构,使开发者能够在更短的时间内以优化的方式构建复杂的应用程序。

在 Python 中有几种可用的网络框架:FlaskDjango 是最受欢迎的。这两个框架都是免费和开源的。Flask 是一个轻量级框架,附带构建网络应用程序所需的标准功能,但它也允许根据需要使用额外的库或扩展。另一方面,Django 是一个全栈框架,自带所有功能,无需额外库。这两种方法都有优点和缺点,但最终,我们可以使用这些框架中的任何一个来开发任何网络应用程序。

如果你想对应用程序有完全的控制权,并且可以根据需要选择使用外部库,那么 Flask 被认为是更好的选择。当项目需求非常频繁地变化时,Flask 也是很好的选择。如果你想要所有工具和库都现成可用,并且只想专注于实现业务逻辑,那么 Django 是合适的。Django 对于大型项目来说是一个很好的选择,但对于简单的项目来说可能有些过度。Django 的学习曲线很陡峭,需要先前的网络开发经验。如果你是第一次用 Python 开发网络,Flask 是一条可行的道路。一旦你学会了 Flask,就很容易采用 Django 框架来进入下一个层次的 Web 项目开发。

当构建网络应用程序或任何 UI 应用程序时,我们经常会遇到术语 模型-视图-控制器MVC)设计模式。这是一种将应用程序分为三个层的架构设计模式:

  • 模型:模型层代表通常存储在数据库中的数据。

  • 视图:这一层是与用户交互的 UI。

  • 控制器:控制器层旨在通过 UI 提供处理用户与应用程序交互的逻辑。例如,用户可能想要创建一个新对象或更新现有对象。对于创建或更新请求,以及是否有模型(数据)的情况下,向用户展示哪个 UI(视图)的逻辑全部都在控制器层实现。

Flask 不提供对 MVC 设计模式的直接支持,但可以通过编程实现。Django 提供了足够接近 MVC 的实现,但不是完全的。Django 框架中的控制器由 Django 本身管理,并且不允许在其中编写自己的代码。Django 和许多其他 Python Web 框架遵循模型视图模板MVT)设计模式,它类似于 MVC,除了模板层。MVT 中的模板层提供了特别格式化的模板,可以生成预期的 UI,并具有在 HTML 中插入动态内容的能力。

用户界面

UI是应用程序的表示层,有时它被包括在 Web 框架的一部分中。但在这里我们单独讨论它,以突出这一层的关键技术和选择。首先,用户通过浏览器在超文本标记语言HTML)和层叠样式表CSS)中进行交互。我们可以直接编写 HTML 和 CSS 来构建我们的界面,但这既繁琐又无法及时交付动态内容。有一些技术可以帮助我们在构建 UI 时更轻松地生活:

  • UI 框架:这些主要是 HTML 和 CSS 库,提供用于构建 UI 的不同类(样式)。我们仍然需要编写或生成 UI 的核心 HTML 部分,但无需担心如何美化我们的网页。一个流行的 UI 框架示例是bootstrap,它建立在 CSS 之上。它最初由 Twitter 用于内部使用,但后来开源,任何人都可以使用。ReactJS是另一个流行的选择,但它更像是一个库而不是框架,由 Facebook 引入。

  • 模板引擎:模板引擎是另一种流行的动态生成网页内容的机制。模板更像是对期望输出的定义,它包含静态数据和动态内容的占位符。占位符是标记化字符串,在运行时会被值替换。输出可以是任何格式,如 HTML、XML、JSON 或 PDF。Jinja2是 Python 中最受欢迎的模板引擎之一,它也包含在 Flask 框架中。Django 自带其模板引擎。

  • 客户端脚本:客户端脚本是从网络服务器下载并由客户端网络浏览器执行的程序。JavaScript 是最流行的客户端脚本语言。有许多 JavaScript 库可供使用,使网页开发更加容易。

我们可以使用多种技术来开发 Web 界面。在一个典型的 Web 项目中,这三个技术都在不同的层面上被使用。

Web 服务器/应用服务器

Web 服务器是软件,它通过 HTTP 监听客户端请求,并根据请求类型交付内容(如网页、脚本、图像)。Web 服务器的基本任务是仅提供静态资源,并且无法执行代码。

应用服务器更具体地针对编程语言。应用服务器的主要任务是提供使用 Python 等编程语言编写的业务逻辑的实现访问。对于许多生产环境,为了便于部署,Web 服务器和应用服务器通常捆绑为一个软件。Flask 自带内置的 Web 服务器,名为Werkzeug,用于开发阶段,但不建议在生产中使用。对于生产,我们必须使用其他选项,如GunicornuWSGIGCP 运行时引擎

数据库

这不是一个强制性的组件,但对于任何交互式 Web 应用来说几乎是必需的。Python 提供了几个库来访问常用的数据库系统,如MySQLMariaDBPostgreSQLOracle。Python 还配备了轻量级的数据库服务器SQLite

安全性

安全性对于网络应用来说是基本的,主要是因为目标受众通常是互联网用户,在这样的环境中数据隐私是最基本的要求。安全套接字层SSL)和最近引入的传输层安全性TLS)是确保客户端和服务器之间数据传输安全性的最低可接受标准。传输层的安全性要求通常在 Web 服务器或有时代理服务器级别处理。用户级别的安全性是下一个基本要求,具有最低的用户名和密码要求。用户安全性是应用级别的安全性,开发者主要负责设计和实现它。

API

网络应用中的业务逻辑层可以被额外的客户端使用。例如,一个移动应用可以使用相同业务逻辑来访问有限的或相同的功能集。对于企业对企业B2B)应用,远程应用可以直接向业务逻辑层提交请求。如果我们公开标准接口,例如为我们的业务逻辑层提供 REST API,这一切都是可能的。在当前时代,通过 API 访问业务逻辑层是一种最佳实践,以确保 API 从第一天起就准备就绪。

文档

文档与编写编程代码一样重要。这对于 API 来说尤其如此。当我们说我们的应用程序有一个 API 时,API 消费者首先会问我们是否可以与他们分享 API 文档。拥有 API 文档的最佳方式是使用内置工具,或者可能将其集成到我们的 Web 框架中。Swagger是一个流行的工具,它可以从编码时添加的注释自动生成文档。

现在我们已经讨论了 Web 开发的关键要求,我们将在下一节深入探讨如何使用 Flask 开发 Web 应用程序。

介绍 Flask 框架

Flask 是一个用于 Python 的微型 Web 开发框架。术语微型表明 Flask 的核心是轻量级的,但具有可扩展的灵活性。一个简单的例子是与数据库系统交互。Django 自带了与最常见数据库交互所需的库。另一方面,Flask 允许根据数据库类型或集成方法使用扩展来实现相同的目标。Flask 的另一个哲学是使用约定优于配置,这意味着如果我们遵循 Web 开发的行业标准,我们就需要做更少的配置。这使得 Flask 成为 Python 学习 Web 开发的最佳选择。我们选择 Flask 进行 Web 开发,不仅因为它易于使用,而且它允许我们以逐步的方式介绍不同的概念。

在本节中,我们将学习使用 Flask 开发 Web 应用程序的以下方面:

  • 使用路由构建基本 Web 应用程序

  • 处理不同 HTTP 方法类型的请求

  • 使用 Jinja2 渲染静态和动态内容

  • 从 HTTP 请求中提取参数

  • 与数据库系统交互

  • 处理错误和异常

在我们开始使用下一节中的代码示例之前,需要在我们的虚拟环境中安装 Flask 2.x。我们将从一个基本的 Web 应用程序开始,使用 Flask。

使用路由构建基本应用程序

我们在上一章“云端的 Python 编程”中已经使用 Flask 构建了一个用于 GCP App Engine 部署的示例应用程序。我们将回顾如何使用 Flask 开发一个简单的 Web 应用程序。我们将从一个代码示例开始,了解 Web 应用程序是如何构建的以及其路由是如何工作的。完整的代码示例如下:

#app1.py: routing in a Flask application
from flask import Flask
app = Flask(__name__)
@app.route('/')
def hello():
    return 'Hello World!'
@app.route('/greeting')
def greeting():
    return 'Greetings from Flask web app!'
if __name__ == '__main__':
    app.run()

让我们逐步分析这个代码示例:

  1. 在我们的例子中,app作为第一步。Web 服务器将通过一个称为app = Flask(__name__)的协议将所有来自客户端的请求传递给这个应用程序实例。

    将模块名称作为参数传递给Flask构造函数是很重要的。Flask 使用这个参数来学习应用程序的位置,这将成为确定其他文件位置(如静态资源、模板和图像)的输入。使用__name__是将参数传递给Flask构造函数的惯例(而不是配置),Flask 会处理其余部分。

  2. app实例,现在执行处理请求的特定代码的责任属于该实例。这段代码通常是一个 Python 函数,被称为handler。好消息是每个请求通常(并非总是)与一个单独的 URL 相关联,这使得定义 URL 和 Python 函数之间的映射成为可能。这种 URL 到 Python 函数的映射称为路由。在我们的代码示例中,我们通过使用route装饰器选择了一种简单的方法来定义这种映射。例如,/hello URL 映射到hello函数,/greeting URL 映射到greeting函数。如果我们希望在一个地方定义所有路由,我们可以使用add_url_rule与 app 实例进行所有路由定义。

  3. 处理函数:处理完请求后,处理函数必须向客户端发送响应。响应可以是一个简单的字符串,包含或不包含 HTML,也可以是一个复杂的网页,可以是静态的或基于模板的动态网页。在我们的代码示例中,为了说明目的,我们返回了一个简单的字符串。

  4. app.run()方法,或者在 shell 中使用flask run命令。当我们启动这个 Web 服务器时,它默认会查找app.pywsgi.py模块,并且如果我们的模块文件使用app.py名称,它将自动与服务器一起加载(再次强调,惯例胜于配置)。但如果我们为我们的模块使用不同的名称(这正是我们的情况),我们必须设置一个环境变量FLASK_APP = <module name>,该变量将被 Web 服务器用于加载模块。

    如果你使用 IDE(如http://localhost:5000/)创建了一个 Flask 项目,并且默认情况下它只能通过你的本地机器访问。如果我们想使用不同的主机名和端口启动服务器,我们可以使用以下命令(或等效的 Python 语句):

    Flask run --host <ip_address> --port <port_num>
    
  5. curl命令:

    curl -X GET http://localhost:5000/
    curl -X GET http://localhost:5000/greeting
    

现在我们已经完成了对 Flask 应用程序基础知识的讨论,我们将开始探索与处理请求和向客户端发送动态响应相关的话题。

处理不同 HTTP 方法类型的请求

HTTP 基于GETPOSTPUTDELETEHEADPATCHOPTIONSGETPOST是最常用的 HTTP 方法,因此我们将只涵盖这些方法来阐述我们的 Web 开发概念。

但在讨论这两种方法之前,了解 HTTP 的两个关键组件也很重要,即HTTP 请求HTTP 响应。HTTP 请求分为三个部分:

  • 请求行:这一行包括要使用的 HTTP 方法、请求的 URI 以及要使用的 HTTP 协议(版本):

    GET /home HTTP/1.1
    
  • :)

  • 在 HTTP 请求的正文中的 POST 请求。对于 REST API,我们可以在正文内发送 PUTPOST 请求的数据。

当我们向 Web 服务器发送 HTTP 请求时,我们将得到一个 HTTP 响应作为结果。HTTP 响应将具有与 HTTP 请求类似的部分:

  • 200200-299 范围内的代码表示成功。客户端错误代码在 400-499 范围内,服务器端错误代码在 500-599 范围内。

  • 头部:头部字段类似于 HTTP 请求头部字段。

  • 正文(可选):尽管是可选的,但这却是 HTTP 响应的关键部分。这可以包括 Web 应用程序的 HTML 页面或任何其他格式的数据。

GET 用于发送对 URL 中标识的资源进行请求,并可选择将 查询字符串 作为 URL 的一部分添加。在 URL 中添加 ? 可以将查询字符串与基本 URL 区分开来。例如,如果我们想在 Google 上搜索单词 Python,我们将在浏览器中看到一个如下所示的 URL:

www.google.com/search?q=Python

在这个 URL 中,q=Python 是一个查询字符串。查询字符串用于以键值对的形式携带数据。这种访问资源的方法因其简单性而受到欢迎,但也存在一些限制。查询字符串中的数据在 URL 中是可见的,这意味着我们不能将敏感信息,如用户名和密码,作为查询字符串发送。查询字符串的长度不能超过 255 个字符。然而,GET 方法因其简单性而被用于搜索网站,如 Google 和 YAHOO。在 POST 方法的情况下,数据通过 HTTP 请求正文发送,这消除了 GET 方法的限制。数据不会作为 URL 的一部分出现,并且我们可以发送到 HTTP 服务器的数据没有限制。使用 POST 方法支持的数据类型也没有限制。

Flask 提供了一些方便的方法来识别请求是使用 GETPOST 发送,还是使用其他任何方法。在我们的下一个代码示例中,我们将展示两种方法;第一种方法使用 route 装饰器,并指定期望的方法类型列表,第二种方法使用特定于 HTTP 方法类型的装饰器,例如 get 装饰器和 post 装饰器。这两种方法的使用将在下一个代码示例中展示,随后将进行详细分析:

#app2.py: map request with method type
from flask import Flask, request
app = Flask(__name__)
@app.route('/submit', methods=['GET'])
def req_with_get():
    return "Received a get request"
@app.post('/submit')
def req_with_post():
    return "Received a post request"
@app.route('/submit2', methods = ['GET', 'POST'])
def both_get_post():
    if request.method == 'POST':
        return "Received a post request 2"
    else:
        return "Received a get request 2"

让我们逐一讨论我们示例代码中的三个路由定义及其对应的功能:

  • 在第一个路由定义(@app.route('/submit', methods=['GET']))中,我们使用了route装饰器来映射一个 URL,将GET类型的请求映射到一个 Python 函数。通过这个装饰器的设置,我们的 Python 函数将仅处理/submit URL 的GET方法请求。

  • 在第二个路由定义(@app.post('/submit'))中,我们使用了post装饰器,并且只使用它指定请求 URL。这是将请求与POST方法映射到 Python 函数的简化版本。这个新设置与第一个路由定义等效,但以简化的形式包含了POST方法类型。我们可以通过使用get装饰器以相同的方式为GET方法实现这一点。

  • 在第三个路由定义(@app.route('/submit2', methods = ['GET', 'POST']))中,我们将一个 URL 的请求使用POSTGET两种方法映射到单个 Python 函数。当我们期望使用单个处理器(Python 函数)处理任何请求方法时,这是一个方便的方法。在 Python 函数内部,我们使用了请求对象的method属性来识别请求是GET类型还是POST类型。请注意,一旦我们将request包导入到我们的程序中,Web 服务器就会将请求对象提供给我们的 Flask 应用。这种方法为客户使用相同的 URL 提交两种方法之一的请求提供了灵活性,作为开发者,我们将它们映射到单个 Python 函数。

我们可以通过curl实用程序更方便地测试这个代码示例,因为它在未定义 HTML 表单的情况下提交POST请求将不会很容易。以下curl命令可以用来向我们的 Web 应用发送 HTTP 请求:

curl -X GET http://localhost:5000/submit
curl -X POST http://localhost:5000/submit
curl -X GET http://localhost:5000/submit2
curl -X POST http://localhost:5000/submit2

接下来,我们将讨论如何从静态页面和模板中渲染响应。

渲染静态和动态内容

静态内容对于 Web 应用来说非常重要,因为它们包括 CSS 和 JavaScript 文件。静态文件可以直接由 Web 服务器提供。如果我们在我们项目的目录中创建一个名为static的目录并将客户端重定向到静态文件位置,Flask 也可以实现这一点。

可以使用 Python 创建动态内容,但这很繁琐,并且需要相当大的努力来维护这样的 Python 代码。推荐的方法是使用模板引擎,如Jinja2。Flask 自带 Jinja2 库,因此无需安装任何额外的库,也无需进行任何额外的配置来设置 Jinja2。下面是一个包含两个函数的示例代码,一个处理静态内容的请求,另一个处理动态内容的请求:

#app3.py: rendering static and dynamic contents
from flask import Flask, render_template, url_for, redirect
app = Flask(__name__)
@app.route('/hello')
def hello():
    hello_url = url_for ('static', filename='app3_s.html')
    return redirect(hello_url)
@app.route('/greeting')
def greeting():
    msg = "Hello from Python"
    return render_template('app3_d.html', greeting=msg)

为了更好地理解这段示例代码,我们将突出关键点:

  • 我们从 Flask 中导入额外的模块,例如url_forredirectrender_template

  • 对于/hello路由,我们使用url_for函数,以static目录和 HTML 文件名为参数构建 URL。我们发送响应,这是一条指示浏览器将客户端重定向到静态文件位置 URL 的指令。重定向指令通过使用范围在300-399之间的状态码来指示 Web 浏览器,这是 Flask 在调用redirect函数时自动设置的。

  • 对于/gtreeting路由,我们使用render_template函数渲染 Jinja 模板app3_d.html。我们还传递了一个问候消息字符串作为变量的值给模板。问候变量将可用于 Jinja 模板,如下所示,这是从app3_d.html文件中的模板摘录:

    <!DOCTYPE html>
    <body>
    if statement enclosed by <% %>, and the Python variable is included by using the two curly brackets {{}} format. We will not go into the details regarding Jinja2 templates, but we highly recommended that you get familiar with Jinja2 templates through their online documentation (https://jinja.palletsprojects.com/). 
    

这个示例 Web 应用程序可以使用 Web 浏览器和curl实用程序访问。在下一节中,我们将讨论如何从不同类型的请求中提取参数。

从 HTTP 请求中提取参数

Web 应用程序与网站不同,因为它们与用户是交互式的,而没有客户端和服务器之间的数据交换这是不可能的。在本节中,我们将讨论如何从请求中提取数据。根据使用的 HTTP 方法类型,我们将采用不同的方法。我们将按以下方式涵盖以下三种类型的请求:

  • 参数作为请求 URL 的一部分

  • 使用GET请求作为查询字符串的参数

  • 使用POST请求作为 HTML 表单数据的参数

以下是一个包含三种不同路由的示例代码,以涵盖上述三种请求类型。我们渲染了一个 Jinja 模板(app4.html),它与app3_d.html文件相同,只是变量名是name而不是greeting

#app4.py: extracting parameters from different requests
from flask import Flask, request, render_template
app = Flask(__name__)
@app.route('/hello')
@app.route('/hello/<fname> <lname>')
def hello_user(fname=None, lastname=None):
    return render_template('app4.html', name=f"{fname}{lname}")
@app.get('/submit')
def process_get_request_data():
    fname = request.args['fname'] 
    lname = request.args.get('lname', '')
    return render_template('app4.html', name=f"{fname}{lname}")
@app.post('/submit')
def process_post_request_data():
    fname = request.form['fname']
    lname = request.form.get('lname','']
    return render_template('app4.html', name=f"{fname}{lname}")

接下来,我们将讨论每种情况的参数提取方法:

  • 对于第一组路由(app.route),我们定义路由的方式是,任何在/hello/之后的文本都被视为与请求一起的参数。我们可以设置零个、一个或两个参数,我们的 Python 函数能够处理任何组合,并将名称(可能为空)作为响应返回给模板。这种方法适用于简单地将参数传递给服务器程序的情况。在 REST API 开发中,这是一种流行的选择,用于访问单个资源实例。

  • 对于第二个路由(app.get),我们从args字典对象中提取查询字符串参数。我们可以通过使用其名称作为字典键或使用带有第二个参数作为默认值的GET方法来获取参数值。我们使用空字符串作为GET方法的默认值。我们展示了两种选项,但如果我们想在请求中不存在参数的情况下设置默认值,我们推荐使用GET方法。

  • 对于第三个路由(app.post),参数作为表单数据作为 HTTP 请求正文的一部分,我们将使用表单字典对象来提取这些参数。再次强调,我们使用参数名称作为字典键,并且为了说明目的,还使用了GET方法。

  • 为了测试这些场景,我们建议使用curl工具,特别是对于POST请求。我们使用以下命令测试了应用程序:

curl -X GET http://localhost:5000/hello
curl -X GET http://localhost:5000/hello/jo%20so
curl -X GET 'http://localhost:5000/submit?fname=jo&lname=so'
curl -d "fname=jo&lname=so" -X POST http://localhost:5000/submit

在下一节中,我们将讨论如何在 Python 中与数据库交互。

与数据库系统交互

一个全栈 Web 应用程序需要持久化结构化数据,因此了解和使用数据库的知识和经验是 Web 开发的先决条件。Python 和 Flask 可以与大多数 SQL 或非 SQL 数据库系统集成。Python 自带一个轻量级的 SQLite 数据库,模块名为sqlite3。我们将使用 SQLite,因为它不需要设置单独的数据库服务器,并且非常适合小型应用程序。对于生产环境,我们必须使用其他数据库系统,例如 MySQL 或 MariaDB,或者 PostgreSQL。为了访问和与数据库系统交互,我们将使用 Flask 扩展之一,Flask-SQLAlchemyFlask-SQLAlchemy扩展基于 Python 的SQLAlchemy库,并将库提供给我们的 Web 应用程序。SQLAlchemy库提供了一个SQLAlchemy或类似的库来与数据库系统交互。

要从我们的应用程序与任何数据库系统交互,我们需要像往常一样创建我们的 Flask 应用程序实例。下一步是使用我们的数据库位置 URL(对于 SQLite3 的情况是一个文件)配置应用程序实例。一旦创建了应用程序实例,我们将通过将其传递给应用程序实例来创建一个SQLAlchemy实例。当使用如 SQLite 这样的数据库时,我们只需要在第一次初始化数据库。它可以由 Python 程序启动,但我们将不倾向于这种方法,以避免每次启动应用程序时都重置数据库。建议只使用SQLAlchemy实例从命令行初始化数据库一次。我们将在代码示例之后讨论初始化数据库的确切步骤。

为了说明SQLAlchemy库与我们的 Web 应用程序的使用,我们将创建一个简单的应用程序来从数据库表中添加列出删除学生对象。以下是初始化 Flask 应用程序和数据库实例(一个SQLAlchemy实例)以及创建StudentModel对象的示例代码:

#app5.py (part1): interacting with db for create, delete   and list objects
from flask import Flask, request, render_template, redirect
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///student.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
class Student(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(80), nullable=False)
    grade = db.Column(db.String(20), nullable=True)

    def __repr__(self):
        return '<Student %r>' % self.name

一旦我们创建了SQLAlchemy实例db,我们就可以处理数据库对象。像SQLAlchemy这样的 ORM 库的优点在于,我们可以定义一个数据库模式,称为Student,它继承自基类db.Model。在这个模型类中,我们定义了idnamegrade属性,这些属性将对应于SQLite3数据库实例中的Student数据库表中的三列。对于每个属性,我们定义了其数据类型、最大长度、是否为主键以及是否可为空。这些额外的属性定义对于以优化方式配置数据库表非常重要。

在下面的代码片段中,我们将展示一个 Python 函数,list_students,用于从数据库中获取学生对象列表。此函数映射到我们示例 Web 应用的/list URL,并通过在query实例(db实例的一个属性)上使用all方法来返回数据库表中的所有Student对象。请注意,query实例及其方法都来自基类db.Model

#app5.py (part 2)
@app.get('/list')
def list_students():
    student_list = Student.query.all()
    return render_template('app5.html',         students=student_list)

在下一个代码片段中,我们将编写一个函数(add_student)用于将学生添加到数据库表中。此函数映射到/add URL,并期望通过GET方法使用请求参数传递学生的姓名和成绩。为了将新对象添加到数据库中,我们将创建一个具有必要属性值的Student类的新实例,然后使用db.Session实例通过add函数将其添加到 ORM 层。add函数本身不会将实例添加到数据库中。我们将使用commit方法将其推送到数据库表。一旦新学生被添加到我们的数据库表中,我们将控制权重定向到/list URL。我们之所以使用重定向到该 URL,是因为我们希望在添加新学生后返回最新的学生列表,并重用我们已实现的list_students函数。add_student函数的完整代码如下:

#app5.py(part 3)
@app.get('/add')
def add_student():
    fname = request.args['fname']
    lname = request.args.get('lname', '')
    grade = request.args.get('grade','')
    student = Student(name=f"{fname} {lname}", grade=grade)
    db.session.add(student)
    db.session.commit()
    return redirect("/list")

在此代码示例的最后部分,我们将编写一个 Python 函数(delete_student)用于从数据库表中删除学生。此函数映射到/delete<int:id> URL。请注意,我们期望客户端发送学生 ID(我们通过list请求发送学生列表)。要删除学生,首先,我们使用学生 ID 通过filter_by方法在query实例上查询确切的Student实例。一旦我们有了确切的Student实例,我们使用db.Session实例的delete方法,然后提交更改。与add_student函数一样,我们将客户端重定向到/list URL,以返回更新后的学生列表到我们的 Jinja 模板:

#app5.py (part 4)
@app.get('/delete/<int:id>')
def del_student(id):
    todelete = Student.query.filter_by(id=id).first()
    db.session.delete(todelete)
    db.session.commit()
    return redirect("/list")

为了在浏览器中显示学生列表,我们创建了一个简单的 Jinja 模板(app5.html)。app5.html模板文件将以表格格式提供学生列表。需要注意的是,我们使用 Jinja for循环动态构建 HTML 表格行,如下面的 Jinja 模板所示:

<!DOCTYPE html>
<body>
<h2>Students</h2>
    {% if students|length > 0 %}
        <table>
            <thead>
              <tr>
                <th scope="col">SNo</th>
                <th scope="col">name</th>
                <th scope="col">grade</th>
               </tr>
            </thead>
            <tbody>
              {% for student in students %}
                <tr>
                    <th scope="row">{{student.id}}</th>
                    <td>{{student.name}}</td>
                    <td>{{student.grade}}</td>
                </tr>
              {% endfor %}
            </tbody>
        </table>
    {% endif %}
</body>
</html>

在开始此应用程序之前,我们应该初始化数据库模式作为一次性步骤。这可以通过使用 Python 程序来完成,但我们必须确保代码只执行一次或仅在数据库尚未初始化时执行。一种推荐的方法是手动使用 Python shell 来完成此步骤。在 Python shell 中,我们可以从我们的应用程序模块导入db实例,然后使用db.create_all方法根据我们程序中定义的模型类初始化数据库。以下是用于我们应用程序数据库初始化的示例命令:

>>> from app5 import db
>>> db.create_all()

这些命令将在我们程序所在的同一目录中创建一个student.db文件。要重置数据库,我们可以删除student.db文件并重新运行初始化命令,或者我们可以在 Python shell 中使用db.drop_all方法。

我们可以使用curl实用程序或通过以下 URL 通过浏览器测试应用程序:

  • http://localhost:5000/list

  • http://localhost:5000/add?fname=John&Lee=asif&grade=9

  • http://localhost:5000/delete/<id>

接下来,我们将讨论如何在基于 Flask 的 Web 应用程序中处理错误。

处理 Web 应用程序中的错误和异常

在我们所有的代码示例中,我们没有关注当用户在浏览器中输入错误的 URL 或向我们的应用程序发送错误参数集时的处理情况。这并不是设计意图,而是首先关注 Web 应用程序的关键组件。Web 框架的美丽之处在于它们通常默认支持错误处理。如果发生任何错误,将自动返回适当的状态码。错误代码作为 HTTP 协议的一部分被很好地定义。例如,从400499的错误代码表示客户端请求的错误,而从500599的错误代码表示在执行请求时服务器的问题。以下是一些常见的错误总结:

表 10.1 – 常见观察到的 HTTP 错误

表 10.1 – 常见观察到的 HTTP 错误

HTTP 状态码和错误的完整列表可在httpstatuses.com/找到。

Flask 框架还附带了一个错误处理框架。在处理客户端请求时,如果我们的程序崩溃,默认会返回500 内部服务器错误。如果客户端请求一个未映射到任何 Python 函数的 URL,Flask 将向客户端返回404 未找到错误。这些不同的错误类型作为HTTPException类的子类实现,该类是 Flask 库的一部分。

如果我们想要用自定义行为或自定义消息来处理这些错误或异常,我们可以将我们的处理器注册到 Flask 应用程序中。请注意,错误处理器是 Flask 中仅在发生错误时才会触发的一个函数,我们可以将特定的错误或通用异常与我们的处理器关联。我们将构建一个示例代码来从高层次上说明这个概念。首先,我们将通过以下示例代码展示一个简单的 Web 应用程序,其中包含两个函数(hellogreeting)来处理两个 URL:

#app6.py(part 1): error and exception handling
import json
from flask import Flask, render_template, abort
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
@app.route('/')
def hello():
    return 'Hello World!'
@app.route('/greeting')
def greeting():
    x = 10/0
    return 'Greetings from Flask web app!'

为了处理错误,我们将使用 errorHandler 装饰器将我们的处理器注册到应用程序实例。对于我们的示例代码(如下所示),我们将 page_not_found 处理器注册到 Flask 应用程序的 404 错误代码。对于 500 错误代码,我们注册了一个 internal_error 函数作为错误处理器。最后,我们将 generic_handler 注册到 HTTPException 类。这个通用处理器将捕获除 404500 之外的错误或异常。下面展示了包含所有三个处理器的示例代码:

#app6.py(part 2)
@app.errorhandler(404)
def page_not_found(error):
    return render_template('error404.html'), 404
@app.errorhandler(500)
def internal_error(error):
    return render_template('error500.html'), 500
@app.errorhandler(HTTPException)
def generic_handler(error):
    error_detail = json.dumps({
        "code": error.code,
        "name": error.name,
        "description": error.description,
    })
    return render_template('error.html', 
        err_msg=error_detail), error.code

为了说明目的,我们还编写了带有自定义消息的基本 Jinja 模板;error404.htmlerror500.htmlerror.htmlerror404.htmlerror500.html 模板使用模板中硬编码的消息。然而,error.html 模板期望从 Web 服务器接收自定义消息。为了测试这些示例应用程序,我们将通过浏览器或 curl 工具请求以下内容:

  • GET http://localhost:5000/:在这种情况下,我们将期望一个正常响应。

  • GET http://localhost:5000/hello:我们将期望出现 404 错误,因为没有 Python 函数映射到这个 URL,Flask 应用程序将渲染一个 error404.html 模板。

  • GET http://localhost:5000/greeting:我们将期望出现 500 错误,因为我们尝试将一个数字除以零以引发 ZeroDivisionError 错误。由于这是一个服务器端错误(500),它将触发我们的 internal_error 处理器,该处理器渲染一个通用的 error500.html 模板。

  • POST http://localhost:5000/:为了模拟通用处理器的角色,我们将发送一个触发除 404500 之外错误代码的请求。这很容易通过发送一个期望 GET 请求的 URL 的 POST 请求来实现,在这种情况下,服务器将引发 405 错误(对于不支持的 HTTP 方法)。在我们的应用程序中,我们没有针对错误代码 405 的特定错误处理器,但我们已注册了一个带有 HTTPException 的通用处理器。这个通用处理器将处理这个错误并渲染一个通用的 error.html 模板。

这就结束了我们关于使用 Flask 框架进行 Web 应用程序开发的讨论。接下来,我们将探索使用 Flask 扩展构建 REST API。

构建 REST API

REST,或ReST,是表示状态转移的缩写,这是一种客户端机器请求关于远程机器上存在的资源信息的架构。API代表应用程序编程接口,它是一套规则和协议,用于与运行在不同机器上的应用程序软件进行交互。不同软件实体之间的交互不是新的需求。在过去的几十年里,已经提出了许多技术和发明,以使软件级交互无缝且方便。一些值得注意的技术包括远程过程调用RPC)、远程方法调用RMI)、CORBA 和基于 SOAP 的 Web 服务。这些技术在某些方面存在局限性,例如与特定的编程语言(例如 RMI)绑定或绑定到专有传输机制,或仅使用某种类型的数据格式。RESTful API(通常称为 REST API)几乎消除了这些限制。

HTTP 协议的灵活性和简单性使其成为作为 REST API 传输机制的合适候选者。使用 HTTP 的另一个优点是它允许使用多种数据格式进行数据交换(如文本、XML 和 JSON),并且不受限于单一格式,例如 XML 是 SOAP 基于 API 的唯一格式。REST API 不依赖于任何特定的语言,这使得它成为构建用于网络交互的 API 的事实选择。以下是一个从 REST 客户端到使用 HTTP 的 REST 服务器的 REST API 调用的架构视图:

图 10.1 – 客户端与服务器之间的 REST API 交互

图 10.1 – 客户端与服务器之间的 REST API 交互

REST API 依赖于 HTTP 请求并使用其原生方法,如GETPUTPOSTDELETE。使用 HTTP 方法简化了从 API 设计角度出发的客户端和服务器端软件的实现。REST API 的开发考虑到了 CRUD 操作的概念。GET用于读取POST用于创建PUT用于更新DELETE用于删除操作。

当使用 HTTP 方法构建 REST API 时,我们必须谨慎选择正确的方法,基于其幂等能力。在数学上,如果一个操作即使重复多次也会给出相同的结果,则认为该操作是幂等的。从 REST API 设计角度来看,POST方法不是幂等的,这意味着我们必须确保 API 客户端不会对同一组数据多次发起POST请求。GETPUTDELETE方法是幂等的,尽管如果我们尝试第二次删除同一资源,可能会得到404错误代码。然而,从幂等性的角度来看,这种行为是可以接受的。

使用 Flask 进行 REST API

在 Python 中,可以使用不同的库和框架来构建 REST API。构建 REST API 最受欢迎的框架是 Django、Flask(使用 Flask-RESTful 扩展)和 FastAPI。这些框架各有优缺点。如果网络应用也正在使用 Django 构建,那么 Django 是构建 REST API 的合适选择。然而,仅使用 Django 进行 API 开发可能有些过度。Flask-RESTful 扩展与 Flask 网络应用无缝协作。Django 和 Flask 都拥有强大的社区支持,这在选择库或框架时有时是一个重要因素。FastAPI 被认为是性能最好的,如果目标是只为你的应用构建 REST API,那么它是一个不错的选择。然而,FastAPI 的社区支持并不像 Django 和 Flask 那样强大。

我们选择了一个 Flask RESTful 扩展用于 REST API 开发,以继续我们之前开始的关于网络应用开发的讨论。请注意,我们只需使用 Flask 就可以构建一个简单的 Web API,我们已经在上一章中这样做过,当时我们开发了一个基于 Web 服务应用的示例,用于 Google Cloud 部署。在本节中,我们将专注于使用 REST 架构风格来构建 API。这意味着我们将使用 HTTP 方法对由 Python 对象表示的资源执行操作。

重要

Flask-RESTful 支持的独特之处在于它提供了一种方便的方式,将响应代码和响应头作为返回语句的一部分来设置。

要使用 Flask 和 Flask-RESTful 扩展,我们需要安装 Flask-RESTful 扩展。我们可以在虚拟环境中使用以下 pip 命令来安装它:

pip install Flask-RESTful

在讨论如何实现 REST API 之前,熟悉一些与 API 相关的术语和概念是有益的。

资源

资源是 REST API 的一个关键元素,这得益于 Flask-RESTful 扩展。资源对象是通过从 Flask-RESTful 扩展库中的基础 Resource 类扩展我们的类来定义的。基础 Resource 类提供了一些魔法函数来协助 API 开发,并自动将 HTTP 方法与我们在资源对象中定义的 Python 方法关联起来。

API 端点

API 端点是一个客户端软件和服务器软件之间建立通信的点。简单来说,API 端点是服务器或服务的 URL 的另一种说法,程序在这里监听 API 请求。使用 Flask-RESTful 扩展,我们通过将某个 URL(或多个 URL)与资源对象关联来定义 API 端点。在 Flask 实现中,我们通过从基础 Resource 类扩展来实现资源对象。

路由

API 的路由概念与 Flask 中的 Web 应用程序路由类似,唯一的区别在于在 API 的情况下,我们需要将一个 Resource 对象映射到一个或多个端点 URL。

参数解析

通过使用查询字符串或 HTML 表单编码的数据可以解析 API 的请求参数。然而,这不是一个推荐的方法,因为查询字符串或 HTML 表单都不适合或设计用于与 API 一起使用。推荐的方法是直接从 HTTP 请求中提取参数。为了方便这样做,Flask-RESTful 扩展提供了一个特殊的类,reqparse。这个 reqparse 类类似于 argparse,后者是解析命令行参数的一个流行选择。

接下来,我们将学习如何构建用于从数据库系统中访问数据的 REST API。

开发数据库访问的 REST API

为了说明如何使用 Flask 和 Flask-RESTful 扩展来构建 REST API,我们将修改我们的 Web 应用程序 (app5.py),并使用 REST 架构风格提供对 Student 对象(一个 Resource 对象)的访问。我们期望在请求体中发送的 PUTPOST 方法的参数,API 将以 JSON 格式发送响应。带有 REST API 接口的 app5.py 修改后的代码如下:

#api_app.py: REST API application for student resource
from flask_sqlalchemy import SQLAlchemy
from flask import Flask
from flask_restful import Resource, Api, reqparse
app = Flask(__name__)
api = Api(app)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///student.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)

在前面的代码片段中,我们从初始化 Flask 应用程序和数据库实例开始。作为下一步,我们使用 Flask 实例创建了一个 API 实例。我们通过 api = Api(app) 语句实现了这一点。这个 API 实例是开发其余 API 应用程序的关键,我们将使用它。

接下来,我们需要通过注册我们期望从 HTTP 请求中解析的参数来配置 reqparse 实例。在我们的代码示例中,我们注册了两个字符串类型的参数,namegrade,如下面的代码片段所示:

parser = reqparse.RequestParser()
parser.add_argument('name', type=str)
parser.add_argument('grade', type=str)

下一步是创建一个 Student 模型对象,这基本上与我们在 app5.py 中所做的一样,只不过我们将添加一个 serialize 方法来将我们的对象转换为 JSON 格式。这是在将 JSON 响应发送回 API 客户端之前序列化的重要步骤。还有其他一些解决方案可以实现相同的功能,但我们出于简单性的考虑选择了这个选项。创建 Student 对象的精确示例代码如下:

class Student(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(80), nullable=False)
    grade = db.Column(db.String(20), nullable=True)
    def serialize(self):
        return {
            'id': self.id,
            'name': self.name,
            'grade': self.grade
        }

接下来,我们创建了两个 Resource 类来访问学生数据库对象。这些是 StudentDaoStudentListDao。接下来将对其进行描述:

  • StudentDao 在单个资源实例上提供了 getdelete 等方法,这些方法映射到 HTTP 协议的 GETDELETE 方法。

  • StudentListDao提供了getpost等方法。GET方法被添加以使用GETHTTP 方法列出所有Student类型的资源,而POST方法被包含在内,用于使用POSTHTTP 方法添加新的资源对象。这是实现 Web 资源 CRUD 功能的一个典型设计模式。

关于为StudentDaoStudentListDao类实现的方法,我们在一个语句中返回了状态码和对象本身。这是 Flask-RESTful 扩展提供的一个便利功能。

下面的示例代码展示了StudentDao类的示例代码:

class StudentDao(Resource):
    def get(self, student_id):
        student = Student.query.filter_by(id=student_id).\
            first_or_404(description='Record with id={} is                 not available'.format(student_id))
        return student.serialize()
    def delete(self, student_id):
        student = Student.query.filter_by(id=student_id).\
            first_or_404(description='Record with id={} is                 not available'.format(student_id))
        db.session.delete(student)
        db.session.commit()
        return '', 204

StudentListDao类的示例代码如下:

class StudentListDao(Resource):
    def get(self):
        students = Student.query.all()
        return [Student.serialize(student) for student in             students]
    def post(self):
        args = parser.parse_args()
        name = args['name']
        grade = args['grade']
        student = Student(name=name, grade=grade)
        db.session.add(student)
        db.session.commit()
        return student, 200

对于StudentListDao类的post方法,我们使用了reqparse解析器从请求中提取名称和成绩参数。POST方法中的其余实现与我们在app5.py示例中执行的方式相同。

在我们的示例 API 应用程序的下两行中,我们将 URL 映射到我们的Resource对象。所有针对/students/<student_id>的请求都将重定向到StudentDao资源类。任何针对/students的请求都将重定向到StudentListDao资源类:

api.add_resource(StudentDao, '/students/<student_id>')
api.add_resource(StudentListDao, '/students')

注意,我们省略了StudentDao类的PUT方法实现,但它在本章提供的源代码中是可用的,以保持完整性。对于这个代码示例,我们没有添加错误和异常处理,以使代码简洁,便于讨论,但强烈建议在最终实现中包含这一部分。

在本节中,我们介绍了开发 REST API 的基础概念和实现原则,这些 API 对于任何想要开始构建 REST API 的人来说都是足够的。在下一节中,我们将扩展我们的知识,以基于 REST API 构建一个完整的 Web 应用程序。

案例研究 – 使用 REST API 构建 Web 应用程序

在本章中,我们学习了如何使用 Flask 构建简单的 Web 应用程序,以及如何使用 Flask 扩展将 REST API 添加到业务逻辑层。在现实世界中,Web 应用程序通常是三层:Web 层业务逻辑层数据访问层。随着移动应用的普及,架构已经发展到将 REST API 作为业务层的构建块。这为使用相同业务逻辑层构建 Web 应用程序和移动应用程序提供了自由。此外,相同的 API 可以用于与其他供应商的 B2B 交互。这种类型的架构在图 10.2中有所体现:

图 10.2 – Web/移动应用程序架构

图 10.2 – Web/移动应用程序架构

在我们的案例研究中,我们将在之前代码示例中为Student模型对象开发的 REST API 应用程序之上开发一个 Web 应用程序。从高层次来看,我们的应用程序将包含以下组件:

![图 10.3 – 带有 REST API 后端引擎的示例 Web 应用程序图 10.3

图 10.3 – 带有 REST API 后端引擎的示例 Web 应用程序

我们已经开发了一个业务逻辑层和数据访问层(ORM),并通过两个 API 端点公开了功能。这将在 使用 Flask 构建 REST API 部分中讨论。我们将开发一个用于网络访问的 Web 应用程序部分,并使用业务逻辑提供的 API。

webapp.py Web 应用程序将基于 Flask。webapp.py 应用程序(以下称为 webapp)在意义上将是独立的,即两个应用程序将作为两个 Flask 实例分别运行,理想情况下在两台不同的机器上。但如果我们在同一台机器上运行这两个 Flask 实例进行测试,我们必须使用不同的端口,并使用本地机器 IP 地址作为主机。Flask 使用 127.0.0.1 地址作为主机来运行其内置的 Web 服务器,这可能不允许运行两个实例。两个应用程序将通过 REST API 互相通信。此外,我们还将开发一些 Jinja 模板来提交创建、更新和删除操作的请求。我们将直接重用 api_py.py 应用程序代码,但我们将开发具有列出学生、添加新学生、删除学生和更新学生数据等功能的 webapp.py 应用程序。我们将为每个功能逐一添加 Python 函数:

  1. 我们将开始初始化 Flask 实例,就像我们在之前的代码示例中所做的那样。示例代码如下:

    #webapp.py: interacting with business latyer via REST API 
    # for create, delete and list objects
    from flask import Flask, render_template, redirect,   request
    import requests, json
    app = Flask(__name__)
    
  2. 接下来,我们将添加一个 list 函数来处理以 / URL 为请求的请求:

    requests library to send a REST API request to the apiapp application that is hosted on the same machine in our test environment. 
    
  3. 接下来,我们将实现一个 add 函数来处理将新学生添加到数据库的请求。只有使用 POST 方法类型的请求才会映射到这个 Python 函数。示例代码如下:

    apiapp application, we built the payload object and passed it as a data attribute to the POST method of the requests module.
    
  4. 接下来,我们将添加一个 DELETE 函数来处理删除现有学生的请求。映射到这个方法的请求类型预期将提供学生 ID 作为 URL 的一部分。

    @app.get('/delete/<int:id>')
    def delete(id):
        response = requests.delete('http://localhost:8080         /students/'+str(id))
        return redirect("/")
    
  5. 接下来,我们将添加两个函数来处理更新功能。一个函数(update)用于以与 post 函数相同的方式更新学生的数据。但在触发 update 函数之前,我们的 webapp 应用程序将向用户提供一个表单,其中包含 student 对象的当前数据。第二个函数(load_student_for_update)将获取一个 student 对象并将其发送到 Jinja 模板,供用户编辑。两个函数的代码如下:

    @app.post('/update/<int:id>')
    def update(id):
        fname = request.form['fname']
        lname = request.form['lname']
        grade = request.form['grade']
        payload = {'name' : f"{fname} {lname}",'grade':grade}
        respone = requests.put('http://localhost:8080         /students/' + str(id), data = payload)
        return redirect("/")
    @app.get('/update/<int:id>')
    def load_student_for_update(id):
        response = requests.get('http://localhost:8080         /students/'+str(id))
        student = json.loads(response.text)
        fname = student['name'].split()[0]
        lname = student['name'].split()[1]
        return render_template('update.html', fname=fname,         lname=lname, student= student)
    

这些函数内部的代码与我们之前讨论的没有不同。因此,我们不会深入到代码的每一行,但我们将突出显示这个 Web 应用程序及其与 REST API 应用程序交互的关键点:

  • 对于我们的 Web 应用程序,我们使用两个 Jinja 模板(main.htmlupdate.html)。我们还使用了一个模板(我们称之为base.html),它是两个模板共有的。base.html模板主要使用 bootstrap UI 框架构建。我们不会讨论 Jinja 模板和 bootstrap 的细节,但我们将鼓励您使用本章末尾提供的参考熟悉这两个。本章源代码中提供了带有 bootstrap 代码的示例 Jinja 模板。

  • 我们 Web 应用程序的根/ URL 将启动主页(main.html),这允许我们添加一个新的学生,同时也提供了一个现有学生的列表。以下截图显示了主页面,它将使用我们的main.html模板进行渲染:

![图 10.4 – webapp 应用程序的主页]

![img/B17189_10_04.jpg]

图 10.4 – webapp 应用程序的主页

  • 如果我们添加一个学生的姓名和成绩,并点击包含这三个输入字段数据的POST请求。我们的 webapp 应用程序将委托此请求到add函数。add函数将使用apiapp应用程序的相应 REST API 添加一个新的学生,并将add函数再次渲染带有更新学生列表(包括新学生)的主页。

  • 在主 Web 应用程序页面(main.html)上,我们添加了两个按钮(带有/delete/<id> URL 的GET请求。此请求将被委托到delete函数。delete函数将使用apiapp应用程序的 REST API 从SQLite3数据库中删除学生,并将再次渲染带有更新学生列表的主页。

  • 点击带有/update/<id> URL 的GET请求。此请求将被委托到load_student_for_update函数。此函数将首先使用apiapp应用程序的 REST API 加载学生数据,设置响应中的数据,并渲染update.html模板。update.html模板将向用户显示一个填写有学生数据的 HTML 表单,以允许编辑。我们为更新场景开发的表单如下所示:

![图 10.5 – 更新学生信息的示例表单]

![img/B17189_10_05.jpg]

图 10.5 – 更新学生信息的示例表单

在更改后,如果用户通过点击带有/update/<id> URL 的POST请求提交表单。我们已为此请求注册了update函数。update函数将从请求中提取数据并将其传递给apiapp应用程序的 REST API。一旦学生信息更新,我们将再次渲染带有更新学生列表的main.html页面。

在本章中,我们省略了纯 Web 技术如 HTML、Jinja、CSS 以及通用 UI 框架的细节。Web 框架的美丽之处在于,它们允许任何 Web 技术用于客户界面,尤其是如果我们使用 REST API 构建我们的应用程序时。

这就结束了我们关于使用 Flask 及其扩展构建 Web 应用程序和开发 REST API 的讨论。Web 开发不仅限于一种语言或一种框架。核心原则和架构在所有 Web 框架和语言中都是相同的。在这里学到的 Web 开发原则将帮助您理解任何其他 Python 或任何其他语言的 Web 框架。

摘要

在本章中,我们讨论了如何使用 Python 和 Web 框架(如 Flask)开发 Web 应用程序和 REST API。我们通过分析 Web 开发的必要条件开始本章,这些条件包括 Web 框架、UI 框架、Web 服务器、数据库系统、API 支持、安全性和文档。随后,我们介绍了如何使用 Flask 框架通过几个代码示例构建 Web 应用程序。我们讨论了不同 HTTP 方法下的不同请求类型,以及如何使用相关代码示例解析请求数据。我们还学习了如何使用 Flask 通过 ORM 库(如 SQLAlchemy)与数据库系统交互。在章节的后半部分,我们介绍了 Web API 在 Web 应用程序、移动应用程序和业务对业务应用程序中的作用。我们通过使用示例 API 应用程序进行了详细分析,研究了用于开发 REST API 的 Flask 扩展。在最后一部分,我们讨论了开发学生Web 应用程序的案例研究。该 Web 应用程序使用两个独立的应用程序构建,这两个应用程序都作为 Flask 应用程序运行。一个应用程序在数据库系统之上提供业务逻辑层的 REST API。另一个应用程序为用户提供 Web 界面,并消费第一个应用程序的 REST API 接口,以提供对学生资源对象的访问。

本章提供了使用 Flask 构建 Web 应用程序和 REST API 的丰富实战知识。本章包含的代码示例将使您能够开始创建 Web 应用程序并编写 REST API。这些知识对于寻求在 Web 开发领域发展并参与构建 REST API 的人来说至关重要。

在下一章中,我们将探讨如何使用 Python 开发微服务,这是软件开发的一种新范式。

问题

  1. TLS 的目的是什么?

  2. 在什么情况下,Flask 框架比 Django 框架更优越?

  3. 常用的 HTTP 方法有哪些?

  4. CRUD 是什么?它与 REST API 有何关联?

  5. REST API 是否仅使用 JSON 作为数据格式?

进一步阅读

  • 《Flask Web 开发》,作者:米格尔·格林伯格

  • 《Python 3 编程高级指南》,作者:约翰·亨特

  • 《使用 Flask 和 Python 构建 REST API》,作者:杰森·迈尔斯和里克·科普兰德

  • 《Essential SQLAlchemy,第 2 版》,作者:豪塞·萨尔瓦蒂埃拉

  • 《Bootstrap 4 快速入门》,作者:雅各布·莱特

  • Jinja 在线文档,可在jinja.palletsprojects.com/找到

答案

  1. TLS 的主要目的是提供在互联网上两个系统之间交换数据的加密。

  2. Flask 是中小型应用的更好选择,尤其是在项目需求预期会频繁变化的情况下。

  3. GETPOST

  4. GETPOSTPUTDELETE

  5. REST API 可以支持任何基于字符串的格式,例如 JSON、XML 或 HTML。数据格式支持更多地与 HTTP 作为 HTTP 主体元素传输数据的能力相关。

第十一章:第十一章:使用 Python 进行微服务开发

多年来,单体应用程序作为单层软件构建,一直是开发应用程序的流行选择。然而,在云平台上部署单体应用程序在资源预留和利用方面并不高效。这在物理机上部署大规模单体应用程序的情况下也是如此。这类应用程序的维护和开发成本始终很高。多级应用程序通过将应用程序分解为几个层级,在一定程度上解决了 Web 应用程序的这个问题。

为了满足动态资源需求并降低开发/维护成本,真正的救星是微服务架构。这种新架构鼓励在松散耦合的服务上构建应用程序,并部署在如容器等动态可扩展平台上。像亚马逊、Netflix 和 Facebook 这样的组织已经从单体模型迁移到了基于微服务的架构。没有这种改变,这些组织无法服务大量客户。

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

  • 介绍微服务

  • 学习微服务的最佳实践

  • 构建基于微服务的应用程序

完成本章学习后,您将了解微服务,并能够基于微服务构建应用程序。

技术要求

以下为本章的技术要求:

  • 您需要在您的计算机上安装 Python 3.7 或更高版本。

  • 在 Python 3.7 或更高版本上安装了 RESTful 扩展的 Python Flask 库。

  • 在 Python 3.7 或更高版本上,使用 Django Rest Framework 库构建的 Python Django。

  • 您需要在您的机器上拥有 Docker 注册表账户,并安装 Docker Engine 和 Docker Compose。

  • 要在 GCP Cloud Run 中部署微服务,您需要一个 GCP 账户(免费试用版即可)。

本章的示例代码可以在github.com/PacktPublishing/Python-for-Geeks/tree/master/Chapter11找到。

我们将首先从微服务的介绍开始讨论。

介绍微服务

微服务是一个独立的软件实体,必须具备以下特征:

  • 松散耦合与其他服务,且不依赖于其他软件组件

  • 易于开发维护,小型团队无需依赖其他团队

  • 独立安装作为一个单独的实体,最好在容器中

  • 提供使用同步协议(如 REST API)或异步协议(如KafkaRabbitMQ)的易于消费的接口

软件被称为微服务的关键字是独立部署、松散耦合和易于维护。每个微服务都可以有自己的数据库服务器,以避免与其他微服务共享资源。这将确保消除微服务之间的依赖性。

微服务架构风格是一种软件开发范式,用于使用纯微服务开发应用程序。这种架构甚至包括应用程序的主要接口实体,例如网络应用程序。以下是一个基于微服务的应用程序的示例:

图 11.1 – 一个示例的微服务架构风格应用程序

图 11.1 – 一个示例的微服务架构风格应用程序

在这个示例应用程序中,我们有如授权服务产品目录服务产品库存服务等单个微服务。我们构建了一个网络应用程序,也是一个微服务,它通过 REST API 使用这三个单个微服务。对于移动客户端,可以通过 API 网关使用相同的单个微服务构建一个移动应用程序。我们可以看到微服务架构的一个直接优势,那就是可重用性。使用微服务的其他一些优势如下:

  • 在选择任何适合任何单个微服务需求的技术和编程语言时,我们非常灵活。如果我们能够通过 API 接口暴露它,我们甚至可以重用任何语言编写的遗留代码。

  • 我们可以通过小型独立团队开发、测试和维护单个微服务。对于大规模应用程序的开发,拥有独立和自主的小型团队至关重要。

  • 单体应用程序的一个挑战是管理冲突的库版本,我们被迫包含这些版本,因为它们包含在单个应用程序的不同功能中。使用微服务,这些库版本冲突的可能性最小化。

  • 我们可以独立部署和修补单个微服务。这使得我们能够为复杂的应用程序使用持续集成/持续交付CI/CD)。当我们需要应用补丁或升级应用程序的一个功能时,这也非常重要。对于单体应用程序,我们将重新部署整个应用程序,这意味着有可能破坏应用程序的其他部分。使用微服务,只有一两个服务会被重新部署,而不会在其他微服务中造成破坏的风险。

  • 我们可以在微服务级别而不是应用程序级别隔离故障和失败。如果一个服务出现故障或失败,我们可以调试它、修复它、打补丁或停止它进行维护,而不会影响应用程序的其他功能。在单体应用程序的情况下,一个组件的问题可能会使整个应用程序崩溃。

尽管有多个优点,但使用微服务架构风格也有一些缺点:

  • 首先是创建基于微服务的应用程序的复杂性增加。这种复杂性主要源于每个微服务都必须暴露一个 API,并且消费者服务或程序必须使用 API 与微服务进行交互。基于每个微服务的安全性也是复杂性的另一个贡献者。

  • 第二个缺点是与单体应用程序相比,资源需求增加。每个微服务都需要额外的内存来独立托管在容器或虚拟机中,即使它是一个Java 虚拟机JVM)。

  • 第三个缺点是,在可能部署在不同容器或系统中的不同微服务之间调试和解决问题需要额外的努力。

接下来,我们将研究构建微服务的最佳实践。

学习微服务的最佳实践

当开始一个新的应用程序时,我们首先应该问自己的问题是微服务架构是否合适。这始于对应用程序需求的分析以及将需求划分为单独和独立组件的能力。如果你看到你的组件经常相互依赖,这是一个指标,表明组件的隔离可能需要重新设计,或者这个应用程序可能不适合微服务架构。

在应用程序的早期阶段就决定是否使用微服务非常重要。有一种观点认为,最好一开始就使用单体架构来避免微服务的额外成本。然而,这不是一个可取的方法。一旦我们构建了一个单体应用程序,就很难将其转换为微服务架构,尤其是如果应用程序已经在生产中。像亚马逊和 Netflix 这样的公司已经做到了,但他们是在其技术演化的过程中做的,而且他们当然有可供利用的人力和技术资源来完成这种转型。

一旦我们决定使用微服务来构建我们的下一个应用程序,以下最佳实践将指导你在设计和部署决策中:

  • 独立且松散耦合:这些要求是微服务定义的一部分。每个微服务都应该独立于其他微服务构建,并且尽可能地以松散耦合的方式构建。

  • 领域驱动设计DDD):微服务架构的目的不是尽可能多地拥有小型微服务。我们需要始终记住,每个微服务都有其开销成本。我们应该构建业务或领域所需的微服务数量。我们建议考虑领域驱动设计(DDD),这是由埃里克·埃文斯在 2004 年提出的。

    如果我们尝试将领域驱动设计(DDD)应用于微服务,它建议首先进行战略设计,通过结合相关的业务领域及其子领域来定义不同的上下文。战略设计可以由战术设计跟随,战术设计专注于将核心领域分解成细粒度的构建块和实体。这种分解将为将需求映射到可能的微服务提供明确的指导。

  • 通信接口:我们应该使用定义良好的微服务接口,最好是 REST API 或事件驱动 API,用于通信。微服务应避免直接相互调用。

  • 使用 API 网关:微服务和它们的消费者应用应该通过 API 网关与单个微服务进行交互。API 网关可以开箱即用地处理安全方面的问题,例如身份验证和负载均衡。此外,当我们有一个微服务的新版本时,我们可以使用 API 网关将客户端请求重定向到新版本,而不会影响客户端软件。

  • 限制技术栈:尽管微服务架构允许在服务级别使用任何编程语言和框架,但在没有业务或重用性原因的情况下,不建议使用不同的技术来开发微服务。多样化的技术栈可能从学术角度来看很有吸引力,但它将在维护和解决问题时带来运营复杂性。

  • 部署模型:在容器中部署微服务不是强制性的,但这是推荐的。容器带来了许多内置功能,例如自动化部署、跨平台支持和互操作性。此外,通过使用容器,我们可以根据服务的要求分配资源,并确保不同微服务之间资源的公平分配。

  • 版本控制:我们应该为每个微服务使用独立的版本控制系统。

  • 团队组织:微服务架构提供了一个按每个微服务分配专用团队的机会。在组织大规模项目团队时,我们应该牢记这一原则。团队的大小应根据两个披萨原则来确定,即我们应该组建一个可以由两个大披萨喂养的工程师团队。一个团队可以拥有一个或多个微服务,这取决于它们的复杂性。

  • 集中式日志/监控:如前所述,在微服务架构风格的应用程序中解决问题可能很耗时,尤其是如果微服务在容器中运行。我们应该使用开源或专业工具来监控和解决问题,以减少此类运营成本。此类工具的几个例子包括SplunkGrafanaElkApp Dynamics

现在我们已经介绍了微服务的概念以及最佳实践,接下来,我们将深入学习如何使用微服务构建应用程序。

构建基于微服务的应用程序

在深入了解微服务的实现细节之前,分析几个微服务框架和部署选项是非常重要的。我们将从 Python 中可用的一个微服务框架开始。

学习 Python 中的微服务开发选项

在 Python 中,我们有大量的框架和库可用于微服务开发。我们无法列举所有可用的选项,但值得突出显示最受欢迎的以及那些具有一些不同功能集的选项。以下是对这些选项的描述:

  • Flask:这是一个轻量级的框架,可以用来构建基于Web 服务网关接口WSGI)的微服务。请注意,WSGI 基于请求-响应的同步设计模式。我们已经在第十章,“使用 Python 进行 Web 开发和 REST API”中使用了 Flask 及其 RESTful 扩展来构建 REST API 应用程序。由于 Flask 是一个流行的 Web 和 API 开发框架,因此对于已经使用 Flask 的开发者来说,它是一个易于采纳的选择。

  • Django:Django 是另一个拥有庞大开发者社区的流行 Web 框架。通过其Django Rest FrameworkDRF),我们可以使用 REST API 接口构建微服务。Django 提供基于 WSGI 和异步服务网关接口ASGI)的微服务。ASGI 被认为是 WSGI 接口的继任者。如果你对基于Asyncio(我们在第七章,“多进程、多线程和异步编程”中详细讨论的主题)开发应用程序感兴趣,ASGI 是一个很好的选择。

  • Falcon:这也是在 Flask 和 Django 之后的一个流行的 Web 框架。它没有内置 Web 服务器,但针对微服务进行了很好的优化。像 Django 一样,它支持 ASGI 和 WSGI。

  • Nameko:这个框架专门为 Python 中的微服务开发设计,并且不是一个 Web 应用程序框架。Nameko 内置了对远程过程调用RPC)、异步事件和基于 WebSocket 的 RPC 的支持。如果你的应用程序需要这些通信接口中的任何一个,你应该考虑使用 Nameko。

  • Bottle:这是一个基于 WSGI 的超级轻量级微服务框架。整个框架基于一个文件,并且仅使用 Python 标准库进行操作。

  • Tornado:它基于非阻塞网络 I/O。Tornado 可以以极低的开销处理高流量。这也适用于长轮询和基于 WebSocket 的连接。

为了我们样本微服务的开发,我们可以使用前面提到的任何框架。但我们将使用 Flask 和 Django,原因有两个。首先,这两个框架在开发 Web 应用和微服务方面是最受欢迎的。其次,我们将重用我们在上一章中开发的示例 API 应用。一个新的微服务将使用 Django 开发,它将展示如何使用 Django 进行 Web 和 API 开发。

接下来,我们将讨论微服务的部署选项。

介绍微服务的部署选项

一旦我们编写了微服务,下一个问题就是如何将它们作为独立和独立的实体进行部署。为了讨论方便,我们假设微服务是用 HTTP/REST 接口构建的。我们可以将所有微服务部署在同一台 Web 服务器上作为不同的 Web 应用,或者为每个微服务托管一台 Web 服务器。一个单独的 Web 服务器上的微服务可以部署在单台机器(物理或虚拟)上,或者在不同的机器上,甚至在不同的容器中。我们已经在以下图表中总结了所有这些不同的部署模型:

![Figure 11.2 – 微服务部署模型](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/py-gk/img/Figure 11.2 – 微服务部署模型)

img/B17189_11_02.jpg

图 11.2 – 微服务部署模型

图 11.2 中显示的四种部署模型描述如下:

  • 模型 A:在这个模型中,我们在同一台 Web 服务器上部署了四个不同的微服务。在这种情况下,微服务很可能共享库,位于单个 Web 服务器上。这可能会导致库冲突,因此不推荐使用这种模型来部署微服务。

  • 模型 B:对于这个模型,我们在一台机器上部署了四个微服务,但每个微服务使用一个 Web 服务器来使它们独立。这种模型在开发环境中是可行的,但在生产规模上可能不太合适。

  • 模型 C:这个模型使用四台虚拟机来托管四个不同的微服务。每台机器只托管一个微服务和一个 Web 服务器。如果无法使用容器,这种模型适合生产环境。这个模型的主要缺点是由于每个虚拟机都会带来额外的资源开销,从而增加了成本。

  • 模型 D:在这个模型中,我们将每个微服务作为容器部署在单台机器上或跨多台机器。这不仅成本低,而且提供了一个简单的方法来符合微服务规范。只要可行,这是推荐使用的模型。

我们分析了不同的部署模型,以了解哪个选项比其他选项更合适。对于我们的示例基于微服务的应用程序开发,我们将使用基于容器和仅托管在 Web 服务器上的微服务的混合模式。这种混合模式说明我们可以从技术上使用任何选项,尽管基于容器的部署是一个推荐的选择。稍后,我们将把我们的一个微服务带到云端,以展示微服务的可移植性。

在讨论了微服务的开发和部署选项之后,现在是时候在下一节开始使用两个微服务构建应用程序了。

开发一个基于微服务的示例应用程序

对于这个示例应用程序,我们将使用 Flask 和 Django 框架开发两个微服务和一个 Web 应用程序。我们的示例应用程序将是上一章作为案例研究开发的学生Web 应用程序的扩展。应用程序架构将如图所示:

![图 11.3 – C 示例应用程序的基于微服务的架构]

![图片 B17189_11_03.jpg]

图 11.3 – C 示例应用程序的基于微服务的架构

为了开发这个示例应用程序,我们将开发以下组件:

  • 在 Students 微服务下构建一个新的Student模型。

  • 重新使用上一章中的apiapp应用程序。在这个示例应用程序中,它被命名为Students微服务。这个应用程序/模块的代码将不会发生变化。

  • 我们将更新上一章案例研究中的webapp应用程序,以使用Grades微服务,并为每个Student对象添加额外的Grade属性。这还需要对 Jinja 模板进行一些小的更新。

我们将首先使用 Django 构建Grades微服务。

创建成绩微服务

要使用 Django 开发微服务,我们将使用Django Rest FrameworkDRF)。Django 使用其 Web 框架的多个组件来构建 REST API 和微服务。因此,这个开发练习也将给您一个关于使用 Django 开发 Web 应用程序的高级概念。

由于我们是从 Flask 开始的,并且已经熟悉了 Web 开发的核心概念,因此对我们来说,开始使用 Django 将是一个方便的过渡。现在让我们了解涉及的步骤:

  1. 首先,我们将创建一个项目目录,或者在最喜欢的 IDE 中创建一个新的项目并使用虚拟环境。如果您没有使用 IDE,您可以在项目目录下使用以下命令创建和激活一个虚拟环境:

    python -m venv myenv
    source myenv/bin/activate
    

    对于任何 Web 应用程序,为每个应用程序创建一个虚拟环境是至关重要的。使用全局环境来管理库依赖可能会导致难以调试的错误。

  2. 要构建一个 Django 应用程序,我们需要至少两个可以使用以下pip命令安装的库:

    pip install django 
    pip install django-rest-framework
    
  3. 一旦我们安装了 Django,我们就可以使用 django-admin 命令行工具来创建一个 Django 项目。下面的命令将创建一个用于我们的微服务的 Django grades 项目:

    admin web app under the grades directory and add a manage.py file to our project. The admin web app includes built-in web server launch scripts, a settings file, and a URL routing file. manage.py is also a command-line utility, like django-admin, and offers similar features but in the context of a Django project. The file structure of the project directory will look as follows when we create a new Django project: ![Figure 11.4 – File structure of a new Django project     ](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/py-gk/img/B17189_11_04.jpg)Figure 11.4 – File structure of a new Django project As shown in *Figure 11.4*, the `settings.py` file contains a project-level setting including a list of apps to deploy with the web server. The `urls.py` file contains routing information for different deployed applications. Currently, only the `admin` app is included in this file. `asgi.py` and `wsgi.py` are available to launch the ASGI or WSGI web server, and the option of which one to use is set in the `settings.py` file. 
    
  4. 下一步是创建一个新的 Django 应用(我们的 Grades 微服务),在主 grades 项目目录下使用以下命令:

    grades_svc. This will also create a default SQLite3 database instance. The option of using the default SQLite3 database is available in the settings.py file, but it can be changed if we decide to use any other database. 
    
  5. 除了在 grades_svc 目录中自动创建的文件外,我们还将添加两个额外的文件 – urls.pyserializers.py。带有两个额外文件的完整项目目录结构在 图 11.5 中显示。与我们的项目相关的不同文件的作用也在这个图中进行了详细说明:![Figure 11.5 – Full directory structure with the grades_svc app img/B17189_11_05.jpg

    图 11.5 – 包含 grades_svc 应用的完整目录结构

  6. 接下来,我们将逐个添加这些文件中我们微服务所需的必要代码。我们将首先通过扩展 Django 数据库 models 包中的 Model 类来定义我们的 Grade 模型类。models.py 文件的完整代码如下:

    from django.db import models
    class Grade(models.Model):
        grade_id = models.CharField(max_length=20)
        building = models.CharField(max_length=200)
        teacher = models.CharField(max_length=200)
        def __str__(self):
            return self.grade_id
    
  7. 为了使我们的模型在 admin 应用仪表板中可见,我们需要在 admin.py 文件中注册我们的 Grade 类模型,如下所示:

    from django.contrib import admin
    from .models import Grade
    admin.site.register(Grade)
    
  8. 接下来,我们将实现一个从数据库中检索 Grade 对象列表的方法。我们将在 views.py 文件中通过扩展 ViewSet 来添加一个 GradeViewSet 类,如下所示:

    from rest_framework import viewsets, status
    from rest_framework.response import Response
    from .models import Grade
    from .serializers import GradeSerializer
    class GradeViewSet(Grade object and for getting a Grade object according to its ID in actual implementation for the completeness of our microservice. We are showing only the list method because this is the only method relevant for our sample application. It is also important to highlight that the view objects should be implemented as classes and we should avoid putting application logic in the view objects.
    

一旦我们在 grades_svc 应用程序下实现了核心方法,我们将添加我们的应用程序到 Django 项目中进行部署,并在应用以及 API 级别添加路由:

  1. 首先,我们将在 settings.py 文件中将我们的 grades_svc 应用和 rest-framework 添加到 INSTALLED_APPS 列表中,如下所示:

    INSTALLED_APPS = [
        'django.contrib.admin',
        'django.contrib.auth',
        'django.contrib.contenttypes',
        'django.contrib.sessions',
        'django.contrib.messages',
        'django.contrib.staticfiles',
        'grades_svc',
        'rest_framework',
    ]
    

    开发者常犯的一个错误是将新组件不断添加到单个设置文件中,这对于大型项目来说很难维护。最佳实践是将文件拆分为多个文件,并在主设置文件中加载它们。

  2. 这也将确保我们的应用程序在 admin 应用中可见。下一步是在 admin 应用级别以及应用级别添加 URL 配置。首先,我们将在 admin 应用的 urls.py 文件中添加我们应用程序的 URL,如下所示:

    urlpatterns = [
        path('admin/', admin.site.urls),
        urls.py file of the admin app, we are redirecting every request to our microservice, except the one that comes with the admin/ URL. 
    
  3. 下一步是在我们的应用程序中根据不同的 HTTP 方法设置路由。这需要我们在 grades_svc 目录中添加 urls.py 文件,并包含以下路由定义:

    from django.urls import path
    from .views import GradeViewSet
    urlpatterns = [
        path(grades/', GradeViewSet.as_view({
            GET and POST methods of HTTP requests with the grades/ URL to the list and create methods of the GradeViewSet class that we implemented in the views.py file earlier. Similarly, we attached the GET request with the grades/<str:id> URL to the retrieve method of the GradeViewSet class. By using this file, we can add additional URL mapping to the Python functions/methods. 
    

这就完成了我们的 Grades 微服务的实现。下一步是在 Django 网络服务器上运行此服务以进行验证。但在运行服务之前,我们将确保模型对象已转移到数据库中。这在 Flask 的情况下相当于初始化数据库。在 Django 的情况下,我们运行以下两个命令来准备更改,然后执行它们:

python3 manage.py makemigrations
python3 manage.py migrate

通常,开发者会错过这个重要的步骤,在尝试启动应用程序时遇到错误。因此,在通过以下命令启动 Web 服务器之前,请确保所有更改都已执行:

python3 manage.py runserver

这将在我们的本地主机机器上的默认端口8000启动一个 Web 服务器。请注意,包括数据库和具有主机和端口属性的 Web 服务器在内的默认设置可以在settings.py文件中更改。此外,我们建议使用以下命令设置admin应用程序的用户账户:

python3 manage.py createsuperuser

此命令将提示您选择管理员账户的用户名、电子邮件地址和密码。一旦我们的微服务按预期执行功能,就是时候将其打包到容器中,并以容器应用程序的形式运行它。这将在下一节中解释。

微服务容器化

容器化是一种操作系统虚拟化类型,其中应用程序在其单独的用户空间中运行,但共享相同的操作系统。这个单独的用户空间被称为容器。Docker 是创建、管理和运行容器应用程序最流行的平台。Docker 仍然占据超过 80%的市场份额,但还有其他容器运行时,如CoreOS rktMesoslxccontainerd。在用 Docker 对我们的微服务进行容器化之前,我们将快速回顾 Docker 平台的主要组件:

  • Docker Engine:这是 Docker 的核心应用程序,用于构建、打包和运行基于容器的应用程序。

  • Docker 镜像:Docker 镜像是一个文件,用于在容器环境中运行应用程序。使用 Docker Engine 开发的程序存储为 Docker 镜像,这些镜像是一组应用程序代码、库、资源文件以及应用程序执行所需的任何其他依赖项。

  • Docker Hub:这是一个在线仓库,用于在您的团队和社区内共享 Docker 镜像。Docker Registry是在同一上下文中使用的另一个术语。Docker Hub 是管理 Docker 镜像仓库的 Docker 注册表的官方名称。

  • Docker Compose:这是一个使用基于 YAML 的文件构建和运行容器应用程序的工具,而不是使用 Docker Engine 的 CLI 命令。Docker Compose 提供了一种简单的方式来部署和运行多个容器,包括配置属性和依赖项。因此,我们建议使用 Docker Compose 或类似技术来构建和运行您的容器。

要使用 Docker Engine 和 Docker Compose,您需要在 Docker 注册表中有一个账户。此外,在开始以下步骤之前,您必须在您的机器上下载并安装 Docker Engine 和 Docker Compose。

  1. 作为第一步,我们将使用pip freeze命令文件创建我们项目的依赖列表,如下所示:

    requirements.txt file. This file will be used by Docker Engine to download these libraries inside the container on top of a Python interpreter. The contents of this file in our project are as follows: 
    
    

    asgiref==3.4.1

    Django==3.2.5

    django-rest-framework==0.1.0

    djangorestframework==3.12.4

    pytz==2021.1

    sqlparse==0.4.1

    
    
  2. 在下一步中,我们将构建 Dockerfile。此文件也将被 Docker Engine 用于创建容器的新镜像。在我们的情况下,我们将向此文件添加以下行:

    FROM python:3.8-slim
    ENV PYTHONUNBUFFERED 1
    WORKDIR /app
    COPY requirements.txt /app/requirements.txt
    RUN pip install -r requirements.txt
    COPY . /app
    CMD python manage.py runserver 0.0.0.0:8000
    

    文件的第一行设置了此容器的基镜像,我们将其设置为 Python:3.8-slim,这个镜像已经在 Docker 仓库中可用。文件的第二行设置了环境变量以实现更好的日志记录。其余的行都是自解释的,因为它们大多是 Unix 命令。

  3. 作为下一步,我们将创建一个 Docker Compose 文件(docker-compose.yml),如下所示:

    version: '3.7'
    services:
      gradesms:
        build:
          context: gradesms service. Note that build is pointing to Dockerfile we just created and assuming it is in the same directory as this docker-compose.yml file. The container port 8000 is mapped to the web server port 8000. This is an important step in allowing traffic from the container to your application inside the container.
    
  4. 作为最后一步,我们将当前目录(.)挂载到容器内的 /app 目录。这将允许系统上的更改反映在容器中,反之亦然。如果您在开发周期中创建容器,这一步非常重要。

  5. 我们可以使用以下 Docker Compose 命令来启动我们的容器:

    docker-compose up
    

    第一次,它将构建一个新的容器镜像,并且需要互联网访问来从 Docker 仓库下载基础容器镜像。在创建容器镜像后,它将自动启动容器。

Docker Engine 和 Docker Compose 的工作细节超出了本书的范围,但我们建议您通过他们的在线文档(docs.docker.com/)熟悉容器技术,如 Docker。

重复使用我们的 Students API 应用

我们将重复使用我们在上一章中开发的 Students API 应用。此应用将使用其内置服务器启动,我们将它命名为我们的示例应用的 Students 微服务。此应用将不会有任何变化。

更新我们的 Students Web 应用程序

我们在上一章案例研究中开发的 webapp 应用程序仅通过 REST API 使用 apiapp。在修订版的这个 Web 应用程序中,我们将使用 Grades 微服务和 Students 微服务来获取 Grade 对象列表和 Student 对象列表。我们 Web 应用程序中的 list 函数将结合这两个对象列表,为 Web 客户端提供更多信息。webapp.py 文件中更新的 list 函数如下:

STUDENTS_MS = http://localhost:8080/students
GRADES_MS   = "http://localhost:8000/grades"
@app.get('/')
def list():
   student_svc_resp = requests.get(STUDENTS_MS)
   students = json.loads(student_svc_resp.text)
   grades_svc_resp = requests.get(GRADES_MS)
   grades_list = json.loads(grades_svc_resp.text)
   grades_dict = {cls_item['grade']:
                    cls_item for cls_item in grades_list}
  for student in students:
    student['building'] =         grades_dict[student['grade']]['building']
    student['teacher'] =         grades_dict[student['grade']]['teacher']
   return render_template('main.html', students=students)

在这个修订的代码中,我们使用字典推导从 Grades 对象列表中创建了一个 grades 字典。这个字典将被用来在将它们发送到 Jinja 模板进行渲染之前,将成绩属性插入到 Student 对象中。在我们的主 Jinja 模板(main.html)中,我们向 Students 表格中添加了两个额外的列,BuildingTeacher,如下所示:

![图 11.6 – 更新后的主页面,包含建筑和教师数据

![img/B17189_11_06.jpg]

图 11.6 – 更新后的主页面,包含建筑和教师数据

在本节中,我们介绍了创建微服务、将其作为 Docker 容器以及 Web 服务器上的 Web 应用程序部署,并将两个微服务的结果结合到一个 Web 应用程序中。

将 Students 微服务部署到 GCP Cloud Run

到目前为止,我们已使用 Students 微服务作为具有 REST API 的 Web 应用程序,该 API 在 Flask 开发服务器上托管。现在是时候将其容器化并部署到 Google Cloud PlatformGCP)了。GCP 有一个运行时引擎(Cloud Run),用于部署容器并将它们作为服务(微服务)运行。以下是涉及的步骤:

  1. 要将我们的 Students 微服务的应用程序代码打包到容器中,我们首先确定一个依赖项列表并将它们导出到 requirements.txt 文件中。我们将从 Students 微服务项目的虚拟环境中运行以下命令:

    pip freeze -> requirements.txt
    
  2. 下一步是在项目的根目录中构建 Dockerfile,就像我们为我们的 Grades 微服务准备的那样。Dockerfile 的内容如下:

    FROM python:3.8-slim
    ENV PYTHONUNBUFFERED True
    WORKDIR /app
    COPY . ./
    #Install production dependencies.
    RUN pip install -r requirements.txt
    RUN pip install Flask gunicorn
    # Run the web service on container startup. we will use 
    # gunicorn and bind our api_app as the main application
    CMD exec gunicorn --bind:$PORT --workers 1 --threads 8 api_app:app
    

    要在 GCP Cloud Run 上部署我们的应用程序,Dockerfile 就足够了。但首先,我们需要使用 GCP Cloud SDK 构建容器镜像。这需要我们使用 Cloud SDK 或 GCP 控制台创建一个 GCP 项目。我们在前面的章节中解释了创建 GCP 项目并将其与计费账户关联的步骤。我们假设您已经在 GCP 上创建了一个名为 students-run 的项目。

  3. 一旦项目准备就绪,我们可以使用以下命令构建 Students API 应用程序的容器镜像:

    gcr stands for Google Container Registry.
    
  4. 要创建一个镜像,我们必须在以下格式中提供 tag 属性:

    <hostname>/<Project ID>/<Image name>
    
  5. 在我们的案例中,主机名是 gcr.io,它位于美国。我们也可以使用本地创建的镜像,但我们必须首先按照上述格式设置 tag 属性,然后将它推送到 Google 仓库。这可以通过以下 Docker 命令实现:

    docker tag gcloud build command can achieve two steps in one command.
    
  6. 下一步是运行上传的镜像。我们可以通过以下 Cloud SDK 命令来运行我们的容器镜像:

    gcloud run deploy --image gcr.io/students-run/students
    

    我们镜像的执行也可以从 GCP 控制台触发。一旦容器成功部署并运行,此命令的输出(或 GCP 控制台)将包括我们的微服务的 URL。

要从 GCP Cloud Run 消费这个新的 Students 微服务版本,我们需要更新我们的 Web 应用程序以切换到使用 GCP Cloud Run 中新部署服务的 URL。如果我们使用本地部署的 Grades 微服务和远程部署的 Students 微服务测试我们的 Web 应用程序,我们将得到与前面 图 11.6 中所示相同的成果,并且可以执行所有操作,就像 Students 微服务本地部署时一样。

这结束了我们关于使用不同的 Python 框架构建微服务、在本地以及云中部署它们,并从 Web 应用程序中消费它们的讨论。

摘要

在本章中,我们介绍了微服务架构,并讨论了其优点和缺点。我们涵盖了构建、部署和运营微服务的几个最佳实践。我们还分析了在 Python 中构建微服务的开发选项,包括 Flask、Django、Falcon、Nameko、Bottle 和 Tornado。我们选择了 Flask 和 Django 来构建示例微服务。为了实现一个新的微服务,我们使用了 Django 及其 REST 框架(DRF)。这个微服务实现还向您介绍了 Django 框架的一般工作方式。稍后,我们提供了如何使用 Docker Engine 和 Docker Compose 容器化新创建的微服务的详细说明。最后,我们将我们的 Students API 应用程序转换为 Docker 镜像,并在 GCP Cloud Run 上部署。我们还更新了 Students 网络应用程序,使其能够消费部署在世界不同地区的两个微服务。

本章包含的代码示例将为您提供在构建和部署不同环境中的微服务时的实践经验。这些知识对任何希望在下一个项目中构建微服务的人来说都是有益的。在下一章中,我们将探讨如何使用 Python 开发无服务器函数,这是云软件开发的新范式。

问题

  1. 我们能否在不使用容器的情况下部署微服务?

  2. 两个微服务是否应该共享一个数据库,但使用不同的模式?

  3. Docker Compose 是什么,它是如何帮助部署微服务的?

  4. REST 是否是微服务数据交换的唯一格式?

进一步阅读

  • 《动手实践:使用 Python 的 Docker 微服务》,作者:Jaime Buelta

  • 《Python 微服务开发》,作者:Tarek Ziade

  • 《领域驱动设计:软件核心的复杂性处理》,作者:Eric Evans

  • 《Google Cloud Run 快速入门教程》用于构建和部署微服务,可在 cloud.google.com/run/docs/quickstarts/ 找到

答案

  1. 是的,但建议将其部署在容器中。

  2. 从技术上讲,这是可行的,但并不是最佳实践。数据库故障将导致所有微服务都崩溃。

  3. Docker Compose 是一个使用 YAML 文件部署和运行容器应用的工具。它提供了一个简单的格式来定义不同的服务(容器)及其部署和运行时属性。

  4. REST API 是微服务之间数据交换最流行的接口,但并非唯一。微服务还可以使用 RPC 和基于事件的协议进行数据交换。

第十二章:第十二章:使用 Python 构建无服务器函数

无服务器计算是一种新的云计算模式,它将物理或虚拟服务器的管理以及数据库系统等基础设施级软件的管理与应用程序本身分离。这种模式允许开发者专注于应用程序开发,并使其他人能够管理底层的基础设施资源。云服务提供商是采用这种模式的最优选择。容器不仅适用于复杂的部署,而且在无服务器计算时代也是一种突破性技术。除了容器之外,还有一种无服务器计算的形式,被称为函数即服务FaaS)。在这种新范式下,云服务提供商提供了一个平台来开发和运行应用程序函数或无服务器函数,通常是对这些函数的直接调用或响应某个事件。所有公共云服务提供商,如亚马逊、谷歌、微软、IBM 和甲骨文,都提供这项服务。本章的重点将在于理解和构建使用 Python 的无服务器函数。

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

  • 介绍无服务器函数

  • 理解无服务器函数的部署选项

  • 通过案例研究学习如何构建无服务器函数

完成本章学习后,你应该对无服务器函数在云计算中的作用以及如何使用 Python 构建它们有一个清晰的理解。

技术要求

以下是为本章列出的技术要求:

  • 你需要在你的计算机上安装 Python 3.7 或更高版本。

  • 要在谷歌云平台GCP)的 Cloud Functions 中部署无服务器函数,你需要一个 GCP 账户(免费试用版即可)。

  • 你需要一个SendGrid账户(即免费账户)来发送电子邮件。

本章的示例代码可以在github.com/PacktPublishing/Python-for-Geeks/tree/master/Chapter12找到。

让我们从无服务器函数的介绍开始。

介绍无服务器函数

无服务器函数是一种模型,可以用来开发和执行软件组件或模块,而无需了解或担心底层托管平台。这些软件模块或组件在公共云服务提供商的产品中被称为Lambda 函数云函数。亚马逊是第一个在其 AWS 平台上提供这种无服务器函数的供应商,该函数被称为AWS Lambda。随后是谷歌和微软,分别提供Google Cloud FunctionsAzure Functions

通常,一个无服务器函数有四个组件,如下所示:

图 12.1 – 无服务器函数的组件

图 12.1 – 无服务器函数的组件

接下来将描述以下四个组件:

  • 函数式代码:这是一个执行特定任务的编程单元,根据函数的业务或功能目标。例如,我们可以编写一个无服务器函数来处理输入数据流,或者编写一个计划活动来检查某些数据资源以进行监控目的。

  • 事件:无服务器函数不是用来像微服务一样使用的。相反,它们是基于可以由 pub/sub 系统中的事件触发的触发器来使用的,或者它们可以根据外部事件(如来自现场传感器的事件)作为 HTTP 调用出现。

  • 结果:当无服务器函数被触发执行任务时,函数会有输出,这可以是简单的对调用者的响应,或者触发后续操作以减轻事件的影响。无服务器函数的一个结果示例是触发另一个云服务,如数据库服务,或向订阅方发送电子邮件。

  • 资源:有时,函数式代码需要使用额外的资源来完成其工作,例如数据库服务或云存储来访问或推送文件。

优点

无服务器函数带来了无服务器计算的所有好处,如下所示:

  • 开发简便:无服务器函数将基础设施复杂性从开发者手中移除,使他们能够专注于程序的函数方面。

  • 内置可伸缩性:无服务器函数提供内置的可伸缩性,以处理任何时间点的任何流量增长。

  • 成本效益:无服务器函数不仅降低了开发成本,还提供了优化的部署和操作模式。通常,这是一个按使用付费的模式,这意味着您只需为函数执行的时间付费。

  • 技术无关性:无服务器函数是技术无关的。这意味着您可以使用多种不同的云资源,在许多编程语言中构建它们。

注意,无服务器函数有一些限制;例如,在构建此类函数时,我们将拥有较少的系统级控制,并且没有系统级访问的情况下,故障排除可能很棘手。

用例

无服务器函数有几种可能的用途。例如,如果我们收到云存储中文件上传的事件或通过实时流有可用数据,我们可以使用此类函数进行数据处理。特别是,无服务器函数可以与物联网IoT)传感器集成。通常,物联网传感器数量达到数千。无服务器函数具有以可扩展的方式处理如此大量传感器请求的能力。一个移动应用程序可以使用这些函数作为后端服务来执行某些任务或处理数据,而不会损害移动设备资源。无服务器函数在现实生活中的一个实际用途是Amazon Alexa产品。不可能将每个技能或智能都放入 Alexa 设备本身。相反,它使用 Amazon Lambda 函数来实现这些技能。Alexa 使用 Amazon Lambda 函数的另一个原因是它们可以根据需求进行扩展。例如,某些函数可能比其他函数更常用,如天气查询。

在下一节中,我们将探讨实现和执行无服务器函数的各种部署选项。

理解无服务器函数的部署选项

在公共云上使用虚拟机或其他运行时资源来处理偶尔访问的应用程序可能不是一个具有商业吸引力的解决方案。在这种情况下,无服务器函数就派上用场了。在这里,云提供商为您的应用程序提供动态管理的资源,并且仅在您的应用程序响应某个事件时才向您收费。换句话说,无服务器函数是一种仅在公共云上提供的按需和按使用付费的后端计算方法。我们将介绍在公共云中部署无服务器函数的一些选项,如下:

  • AWS Lambda:这被认为是任何公共云提供商推出的第一个服务之一。AWS Lambda 函数可以使用 Python、Node.js、Java、PowerShell、Ruby、Java、C#和 Go 编写。AWS Lambda 函数可以在响应事件时执行,例如将文件上传到Amazon S3、来自Amazon SNS的通知或直接 API 调用。AWS Lambda 函数是无状态的。

  • Azure Functions:微软在 AWS Lambda 函数推出近两年后推出了 Azure Functions。这些函数可以附加到云基础设施中的事件上。微软提供支持,使用 Visual Studio、Visual Studio Code、IntelliJ 和 Eclipse 来构建和调试这些函数。Azure Functions 可以使用 C#、F#、Node.js、PowerShell、PHP 和 Python 编写。此外,微软还提供了Durable Functions,允许我们在无服务器环境中编写有状态的函数。

  • 谷歌云函数:GCP 提供谷歌云函数作为无服务器函数。谷歌云函数可以用 Python、Node.js、Go、.NET、Ruby 和 PHP 编写。像其竞争对手 AWS Lambda 和 Azure Functions 一样,谷歌云函数可以通过 HTTP 请求或谷歌云基础设施的事件触发。谷歌允许你使用 Cloud Build 来自动测试和部署云函数。

除了前三大公共云提供商之外,还有一些其他云提供商的更多服务。例如,IBM 提供基于开源项目Apache OpenWhisk的云函数。Oracle 提供基于开源项目Fn的无服务器计算平台。使用这些开源项目的美妙之处在于,你可以在本地开发和测试你的代码。此外,这些项目允许你将代码从一个云平台迁移到另一个云平台,甚至迁移到本地环境部署,而无需任何更改。

值得一提的是,在无服务器计算领域广为人知的另一个框架,称为无服务器框架。这不是一个部署平台,而是一个可以在本地使用的软件工具,可以用来构建和打包你的代码以进行无服务器部署,然后可以用来将包部署到你的首选公共云之一。无服务器框架支持多种编程语言,如 Python、Java、Node.js、Go、C#、Ruby 和 PHP。

在下一节中,我们将使用 Python 构建几个无服务器函数。

学习如何构建无服务器函数

在本节中,我们将探讨如何为公共云提供商之一构建无服务器函数。尽管亚马逊 AWS 在 2014 年通过提供 AWS Lambda 函数率先推出了无服务器函数,但我们将使用谷歌云函数平台作为我们的示例函数。这样做的原因是,我们在前面的章节中已经详细介绍了 GCP,并且你可以利用相同的 GCP 账户来部署这些示例函数。然而,我们强烈建议你使用其他平台,特别是如果你计划将来使用它们的无服务器函数的话。在各个云平台上构建和部署这些函数的核心原则是相同的。

GCP 云函数提供了多种开发和无服务器函数部署的方式(在 GCP 的上下文中,我们将它们称为云函数)。在我们的示例云函数中,我们将探索两种类型的事件,描述如下:

  • 第一个云函数将从头到尾使用 GCP 控制台构建和部署。这个云函数将基于 HTTP 调用(或事件)触发。

  • 第二个 Cloud Function 将是构建一个应用程序的一部分,该应用程序可以监听云基础设施中的事件,并对该事件采取行动,例如发送电子邮件作为对此事件的响应。在这个案例研究中使用的 Cloud Function 将使用 Cloud 软件开发工具包SDK)构建和部署。

我们将首先使用 GCP 控制台构建一个 Cloud Function。

使用 GCP 控制台构建基于 HTTP 的 Cloud Function

让我们从 Google Cloud Function 的开发过程开始。我们将构建一个非常简单的 Cloud Function,它为 HTTP 触发器提供今天的日期和当前时间。请注意,HTTP 触发器是 Cloud Function 被调用的最简单方式。首先,我们需要一个 GCP 项目。您可以使用 GCP 控制台为这个 Cloud Function 创建一个新的 GCP 项目,或者使用现有的 GCP 项目。有关如何创建 GCP 项目并将其与计费账户关联的步骤,请参阅第九章云端的 Python 编程。一旦您准备好了 GCP 项目,构建一个新的 Cloud Function 是一个三步的过程。我们将在以下小节中解释这些步骤。

配置 Cloud Function 属性

当我们从 GCP 控制台启动 创建函数 工作流程时,系统将提示我们提供 Cloud Function 定义,如下所示:

图 12.2 – 使用 GCP 控制台创建新 Cloud Function 的步骤(1/2)

图 12.2 – 使用 GCP 控制台创建新 Cloud Function 的步骤(1/2)

Cloud Function 定义的高级总结如下:

  1. 我们提供 my-datetime) 并选择托管此函数的 GCP 区域

  2. 我们将 HTTP 作为函数的 触发类型。为您的函数选择触发器是最重要的步骤。还有其他触发器可供选择,例如 Cloud Pub/SubCloud Storage。在撰写本书时,GCP 已添加了一些用于评估目的的更多触发器。

  3. 为了简化,我们将允许我们的函数进行未认证访问。

点击 保存 按钮,系统将提示我们输入 运行时、构建和连接设置,如下面的截图所示:

图 12.3 – 使用 GCP 控制台创建新 Cloud Function 的步骤(2/2)

图 12.3 – 使用 GCP 控制台创建新 Cloud Function 的步骤(2/2)

我们可以提供以下 运行时、构建和连接设置

  1. 我们可以保留运行时属性在默认设置,但我们将函数的 内存分配 减少到 128 MiB。我们已将默认服务账户作为 运行时服务账户 与此函数关联。我们将保留 自动扩展 在默认设置,但可以将此设置为函数的最大实例数。

  2. 如果我们有这样的需求,我们可以在运行时选项卡下添加运行时环境变量。我们不会为我们的云函数添加任何环境变量。

  3. 构建选项卡下,有一个选项可以添加构建环境变量。我们不会为我们的云函数添加任何变量。

  4. 连接选项卡下,我们可以保留默认设置,允许所有流量访问我们的云函数。

在设置云函数的运行时、构建和连接设置后,下一步将是添加此云函数的实现代码。

将 Python 代码添加到云函数中

在点击如图 12.3 所示的下一步按钮后,GCP 控制台将提供一个视图来定义或添加函数实现细节,如下面的截图所示:

图 12.4 – 使用 GCP 控制台实现云函数的步骤

img/B17189_12_04.jpg

图 12.4 – 使用 GCP 控制台实现云函数的步骤

可用于添加我们的 Python 代码的选项如下:

  • 我们可以选择多个运行时选项,例如 Java、PHP、Node.js 或各种 Python 版本。我们选择了Python 3.8作为云函数的运行时

  • 入口点属性必须是我们的代码中函数的名称。Google Cloud Function 将根据此入口点属性调用我们的代码中的函数。

  • 可以使用右侧的内联编辑器将 Python 源代码内联添加;或者,可以使用从本地机器或云存储的 ZIP 文件上传。我们还可以提供 GCP 云源存储库位置以存储源代码。在这里,我们选择了使用内联编辑器工具来实现我们的函数。

  • 对于 Python,GCP 云函数平台会自动创建两个文件:main.pyrequirements.txtmain.py文件将包含我们的代码实现,而requirements.txt文件应包含我们对第三方库的依赖。

    一个示例代码,该代码在 HTTP 请求中的requester属性内显示或未显示。根据requester属性值,我们将发送包含今天日期和时间的欢迎信息。我们在第九章,“云中的 Python 编程”中,使用 Flask Web 应用程序实现了类似的代码示例,以展示 GCP App Engine 的功能。

在我们对 Python 代码满意后,我们将在 Google Cloud Functions 平台上部署该函数。

部署云函数

下一步是使用屏幕底部的 部署 按钮部署此函数,如图 图 12.4 所示。GCP 将立即开始部署函数,这可能需要几分钟才能完成此活动。重要的是要理解 Google Cloud Functions 与 GCP Cloud Run 上的微服务一样,使用容器进行部署。它们的主要区别在于可以使用不同类型的事件进行调用,并且使用按使用付费的定价模式。

函数部署后,我们可以复制它、测试它或从 云函数 列表中删除它,如图下所示截图:

![图 12.5 – Google Cloud Functions 的主视图]

![图片 B17189_12_05.jpg]

图 12.5 – Google Cloud Functions 的主视图

现在,我们将快速向您展示如何方便地使用 GCP 控制台测试和调试我们的云函数。一旦我们选择了 JSON 格式的 requester 属性,如下所示:

{"requester":"John"} 

在互联网上的任何地方点击 CURL 工具。然而,我们必须确保我们的云函数包含 allUsers 作为其成员,并具有 Cloud Functions Invoker 的角色。这可以在 权限 选项卡下设置。然而,我们不推荐在不为您的云函数设置身份验证机制的情况下这样做:

![图 12.6 – 使用 GCP 控制台测试您的云函数]

![图片 B17189_12_06.jpg]

图 12.6 – 使用 GCP 控制台测试您的云函数

使用 GCP 控制台构建简单的云函数是一个简单的过程。接下来,我们将探讨云函数在现实世界中的应用案例研究。

案例研究 – 为云存储事件构建通知应用程序

在本案例研究中,我们将开发一个云函数,该函数在 Google Storage 存储桶 上的事件触发时执行。在接收到此类事件后,我们的云函数将向预定义的电子邮件地址列表发送电子邮件作为通知。此通知应用程序与云函数的流程如下所示:

![图 12.7 – 监听 Google 存储桶事件的云函数]

![图片 B17189_12_07.jpg]

图 12.7 – 监听 Google 存储桶事件的云函数

注意,我们可以将我们的云函数设置为监听一个或多个 Google Storage 事件。Google Cloud Functions 支持以下 Google Storage 事件:

  • 完成:当在存储桶中添加或替换新文件时创建此事件。

  • 删除:此事件表示从存储桶中删除文件。这适用于非版本化存储桶。请注意,实际上文件并未被删除,但如果存储桶设置为使用版本控制,则文件会被存档。

  • 存档:当文件存档时引发此事件。存档操作是在文件被删除或覆盖时触发的,适用于具有版本控制的存储桶。

  • 元数据更新:如果文件元数据有任何更新,将创建此事件。

在收到来自 Google Storage 存储桶的事件后,Cloud Function 将从作为参数传递给我们的 Cloud Function 的上下文和事件对象中提取属性。然后,云函数将使用第三方电子邮件服务(例如来自 TwilioSendGrid)来发送通知。

作为先决条件,你必须创建一个 SendGrid 的免费账户(sendgrid.com/)。创建账户后,你需要在 SendGrid 账户内创建至少一个发送用户。此外,你还需要在 SendGrid 账户内设置一个秘密 API 密钥,该密钥可以与 Cloud Function 一起使用来发送电子邮件。Twilio SendGrid 提供每天 100 封免费电子邮件,这对于测试目的来说已经足够好了。

对于这个案例研究,我们将本地编写 Cloud Function 的 Python 代码,然后使用 Cloud SDK 将其部署到 Google Cloud Functions 平台。我们将逐步实现这个通知应用,如下所示:

  1. 我们将创建一个存储桶来附加到我们的 Cloud Function,并从这个存储桶上传或删除文件来模拟 Cloud Function 的事件。我们可以使用以下 Cloud SDK 命令来创建一个新的存储桶:

    gsutil mb gs://<bucket name>
    gsutil mb gs://muasif-testcloudfn    #Example bucket   created
    

    为了简化这些事件的生成,我们将使用以下 Cloud SDK 命令关闭我们的存储桶的版本控制:

    gsutil versioning set off gs://muasif-testcloudfn
    
  2. 一旦存储桶准备就绪,我们将创建一个本地项目目录,并使用以下命令设置虚拟环境:

    python -m venv myenv
    source myenv/bin/activate
    
  3. 接下来,我们将使用 pip 工具安装 sendgrid Python 包,如下所示:

    pip install sendgrid
    
  4. 一旦我们的第三方库已经安装,我们需要创建一个 requirements.txt 依赖文件,如下所示:

    pip freeze -> requirements.txt
    
  5. 接下来,我们将创建一个新的 Python 文件(main.py),在其中包含一个 handle_storage_event 函数。这个函数将是我们的 Cloud Function 的入口点。这个入口点函数的示例代码如下:

    #handle_storage_event function is expected to receive event and context objects as input arguments. An event object is a dictionary that contains the data of the event. We can access the event data from this object using keys such as bucket (that is, the bucket name), name (that is, the filename), and timeCreated (that is, the creation time). The context object provides the context of the event such as event_id and event_type. Additionally, we use the sendgrid library to prepare the email contents and then send the email with the event information to the target email list.
    
  6. 一旦我们准备好了我们的 Python 代码文件(在我们的例子中,这是 main.py)和 requirements.txt 文件,我们就可以使用以下 Cloud SDK 命令来触发部署操作:

    gcloud functions deploy handle_storage_create, and the entry-point attribute is set to the handle_storage_event function in the Python code. We set trigger-event to the finalize event. By using set-env-vars, we set SENDGRID_API_KEY for the SendGrid service.The `deploy` command will package the Python code from the current directory, prepare the target platform as per the `requirements.txt` file, and then deploy our Python code to the GCP Cloud Functions platform. In our case, we can create a `.gcloudignore` file to exclude the files and directories so that they can be ignored by the Cloud SDK `deploy` command.
    
  7. 一旦我们部署了 Cloud Function,我们可以通过使用 Cloud SDK 命令将本地文件上传到我们的存储桶来测试它,如下所示:

    gsutil finalize event will trigger the execution of our Cloud Function. As a result, we will receive an email with the event details. We can also check the logs of the Cloud Functions by using the following command:
    
    

    gcloud functions logs read --limit 50

    
    

对于这个通知应用,我们只将其 Cloud Function 附加到 Finalize 事件。然而,如果我们还想附加另一个事件类型,比如 Delete 事件呢?嗯,一个 Cloud Function 只能附加到一个触发事件。但是等等,Cloud Function 是一个部署实体,而不是实际的程序代码。这意味着我们不需要编写或复制我们的 Python 代码来处理另一种类型的事件。我们可以创建一个新的 Cloud Function,使用相同的 Python 代码,但针对 Delete 事件,如下所示:

gcloud functions deploy handle_storage_delete \
--entry-point handle_storage_event --runtime python38 \
--trigger-resource gs://muasif-testcloudfn/ \
--trigger-event google.storage.object.delete
--set-env-vars SENDGRID_API_KEY=<Your SEND-GRID KEY>

如果你注意到这个版本的deploy命令,我们所做的唯一更改是云函数的名称和触发事件的类型。这个deploy命令将创建一个新的云函数,并且将与早期创建的云函数并行工作,但将基于不同的事件触发(在这种情况下,这是delete)。

要测试我们新添加的云函数的delete事件,我们可以使用以下 Cloud SDK 命令从我们的存储桶中删除已上传的文件(或任何文件):

gsutil rm gs://muasif-testcloudfn/test1.txt

我们可以使用相同的 Python 代码为其他存储事件创建更多的云函数。这标志着我们使用 Cloud SDK 构建存储事件云函数讨论的结束。使用 Cloud SDK 讨论的所有步骤也可以使用 GCP 控制台实现。

摘要

在本章中,我们介绍了无服务器计算和 FaaS,随后分析了无服务器函数的主要成分。接下来,我们讨论了无服务器函数的关键优势和潜在问题。此外,我们还分析了可用于构建和部署无服务器函数的几种部署选项,包括 AWS Lambda、Azure Functions、Google Cloud Functions、Oracle Fn 和 IBM Cloud Functions。在本章的最后部分,我们基于 HTTP 触发器使用 GCP 控制台构建了一个简单的 Google Cloud Function。然后,我们使用 Cloud SDK 构建了一个基于 Google 存储事件的提醒应用和 Google Cloud Function。这些无服务器函数使用 Google Cloud Functions 平台进行部署。

本章包含的代码示例应能为您提供一些使用 GCP 控制台和 Cloud SDK 构建和部署云函数的经验。这种实践经验对于希望从事无服务器计算职业的人来说是有益的。

在下一章中,我们将探讨如何使用 Python 进行机器学习。

问题

  1. 无服务器函数与微服务有何不同?

  2. 无服务器函数在现实世界中的实际用途是什么?

  3. 什么是持久化函数,谁提供它们?

  4. 一个云函数可以附加到多个触发器。这是真的还是假的?

进一步阅读

  • 《使用 Google Cloud 进行无服务器计算》由 Richard Rose 所著

  • 《精通 AWS Lambda》由 Yohan Wadia 所著

  • 《精通 Azure 无服务器计算》由 Lorenzo Barbieri 和 Massimo Bonanni 所著

  • 《Google Cloud Functions 快速入门教程》用于构建和部署云函数,可在cloud.google.com/functions/docs/quickstarts找到

答案

  1. 这两种都是无服务器计算的不同提供方式。通常,无服务器函数由事件触发,并基于按使用付费的模式。相比之下,微服务通常通过 API 调用进行消费,并且不基于按使用付费的模式。

  2. 亚马逊 Alexa 使用 AWS Lambda 函数为用户提供智能和其他技能。

  3. 可持久函数是微软 Azure Functions 的扩展,它在一个无服务器环境中提供了有状态的功能。

  4. 错误。一个云函数只能附加到一个触发器。

第十三章:第十三章:Python 与机器学习

机器学习ML)是人工智能AI)的一个分支,它基于从数据中学习模式并构建模型,然后使用这些模型进行预测。它是帮助人类以及企业在许多方面最受欢迎的人工智能技术之一。例如,它被用于医疗诊断、图像处理、语音识别、预测威胁、数据挖掘、分类以及许多其他场景。我们都理解机器学习在我们生活中的重要性和实用性。Python 作为一种简洁但强大的语言,被广泛用于实现机器学习模型。Python 使用 NumPy、pandas 和 PySpark 等库处理和准备数据的能力,使其成为开发人员构建和训练 ML 模型的首选选择。

在本章中,我们将讨论以优化方式使用 Python 进行机器学习任务。这尤其重要,因为训练 ML 模型是一个计算密集型任务,当使用 Python 进行机器学习时,优化代码是基础。

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

  • 介绍机器学习

  • 使用 Python 进行机器学习

  • 测试和评估机器学习模型

  • 在云中部署机器学习模型

完成本章后,你将了解如何使用 Python 构建、训练和评估机器学习模型,以及如何在云中部署它们并使用它们进行预测。

技术要求

本章的技术要求如下:

  • 你需要在你的计算机上安装 Python 3.7 或更高版本。

  • 你需要安装额外的机器学习库,如 SciPy、NumPy、pandas 和 scikit-learn。

  • 要在 GCP 的 AI 平台上部署机器学习模型,你需要一个 GCP 账户(免费试用版也可以)。

本章的示例代码可以在github.com/PacktPublishing/Python-for-Geeks/tree/master/Chapter13找到。

我们将从一个机器学习的介绍开始我们的讨论。

介绍机器学习

在传统的编程中,我们向程序提供数据和一些规则作为输入,以获得期望的输出。机器学习是一种根本不同的编程方法,其中数据和期望的输出被作为输入来生成一组规则。这在机器学习的术语中被称为模型。这一概念在以下图中得到了说明:

图 13.1 – 传统编程与机器学习编程

图 13.1 – 传统编程与机器学习编程的比较

要了解机器学习是如何工作的,我们需要熟悉其核心组件或元素:

  • 数据集:没有好的数据集,机器学习就毫无意义。好的数据是机器学习的真正力量。它必须从不同的环境中收集,涵盖各种情况,以使模型接近现实世界的过程或系统。数据集的另一个要求是它必须很大,而我们所说的“大”是指数千条记录。此外,数据应该尽可能准确,并包含有意义的信息。数据用于训练系统,也用于评估其准确性。我们可以从许多来源收集数据,但大多数情况下,数据是以原始格式存在的。我们可以利用如 pandas 等库来使用数据处理技术,正如我们在前面的章节中讨论的那样。

  • 特征提取:在使用任何数据构建模型之前,我们需要了解我们有什么类型的数据以及它的结构。一旦我们理解了这一点,我们就可以选择哪些数据特征可以被机器学习算法用来构建模型。我们还可以根据原始特征集计算额外的特征。例如,如果我们有以像素形式存在的原始图像数据,它本身可能对训练模型没有用,我们可以使用图像内部形状的长度或宽度作为特征来为我们的模型建立规则。

  • 算法:这是一个用于从可用数据构建机器学习模型的程序。从数学的角度来看,机器学习算法试图学习一个目标函数 f(X),该函数可以将输入数据 X 映射到输出 y,如下所示:

    图片

    由于没有一种算法可以解决所有问题,因此针对不同类型的问题和情况有几种不同的算法可用。一些流行的算法包括线性回归分类和回归树以及支持向量分类器SVC)。这些算法如何工作的数学细节超出了本书的范围。我们建议查看进一步阅读部分提供的附加链接,以获取有关这些算法的详细信息。

  • 模型:在机器学习中,我们经常听到“模型”这个术语。模型是对我们日常生活中发生的过程的数学或计算表示。从机器学习的角度来看,它是将机器学习算法应用于我们的数据集时的输出。这个输出(模型)可以是一组规则或一些特定的数据结构,当用于任何实际数据时,可以用来进行预测。

  • 训练:这不是机器学习中的一个新组件或步骤。当我们说训练一个模型时,这意味着将机器学习算法应用于数据集以产生一个机器学习模型。我们得到的输出模型被称为在某个数据集上训练过的。训练模型有三种不同的方法:

    a) 监督学习: 这包括提供所需输出以及我们的数据记录。这里的目的是学习如何使用可用数据将输入(X)映射到输出(Y)。这种学习方法用于分类和回归问题。图像分类和预测房价(回归)是监督学习的几个现实世界例子。在图像处理的情况下,我们可以训练一个模型来识别图像中的动物类型,例如猫或狗,基于图像的形状、长度和宽度。为了训练我们的图像分类模型,我们将训练数据集中的每个图像用动物的名字标记。为了预测房价,我们必须提供关于我们关注的地区房屋的数据,例如它们所在的区域、房间数量和浴室数量等。

    b) 无监督学习: 在这种情况下,我们在不知道所需输出的情况下训练模型。无监督学习通常应用于聚类和关联用例。这种类型的学习主要基于观察,并找到具有相似特征的数据点的组或聚类。这种学习方法在在线零售店如亚马逊中得到广泛应用,以根据他们的购物行为找到不同的客户组(聚类),并向他们提供他们感兴趣的商品。在线商店还试图找到不同购买之间的关联,例如,购买物品 A 的人有多大的可能性也会购买物品 B。

    c) 强化学习: 在强化学习的情况下,模型在特定情况下做出适当决策时会得到奖励。在这种情况下,根本不存在训练数据,但模型必须从经验中学习。自动驾驶汽车是强化学习的流行例子。

  • 测试: 我们需要在没有用于训练模型的数据库集上测试我们的模型。一种传统的方法是使用数据集的三分之二来训练模型,并使用剩余的三分之一来测试模型。

除了我们讨论的三个学习方法之外,我们还有深度学习。这是一种基于人类大脑如何使用神经网络算法获得某种类型知识的先进机器学习方法。在本章中,我们将使用监督学习来构建我们的示例模型。

在下一节中,我们将探讨 Python 机器学习中可用的选项。

使用 Python 进行机器学习

Python 在数据科学家社区中很受欢迎,因为它简单、跨平台兼容性良好,并且通过其库提供了丰富的数据分析和数据处理支持。机器学习中的一个关键步骤是为构建机器学习模型准备数据,Python 在这方面是一个自然的选择。使用 Python 的唯一挑战是它是一种解释型语言,因此与 C 语言等语言相比,执行代码的速度较慢。但这个问题并不严重,因为有一些库可以通过并行使用中央处理器CPUs)或图形处理器GPUs)的多个核心来最大化 Python 的速度。

在下一个子节中,我们将介绍一些用于机器学习的 Python 库。

介绍 Python 中的机器学习库

Python 附带了几种机器学习库。我们已提到支持库,如 NumPy、SciPy 和 pandas,这些库对于数据精炼、数据分析和数据处理是基础性的。在本节中,我们将简要讨论构建机器学习模型最流行的 Python 库。

scikit-learn

这个库是一个流行的选择,因为它拥有大量内置的机器学习算法和评估这些算法性能的工具。这些算法包括用于监督学习的分类和回归算法,以及用于无监督学习的聚类和关联算法。scikit-learn 主要用 Python 编写,并依赖于 NumPy 库进行许多操作。对于初学者,我们建议从 scikit-learn 库开始,然后过渡到更高层次的库,例如 TensorFlow。我们将使用 scikit-learn 来展示构建、训练和评估机器学习模型的概念。

scikit-learn 还提供了梯度提升算法。这些算法基于梯度的数学概念,即函数的斜率。在机器学习语境中,它衡量错误的变化。基于梯度的算法的思路是通过迭代地微调参数来找到函数的局部最小值(最小化机器学习模型的错误)。梯度提升算法使用相同的策略通过考虑先前模型的性能,通过微调新模型的参数,并通过设置目标以接受新模型(如果它比先前模型更少地最小化错误)来迭代地改进模型。

XGBoost

XGBoost,或极端梯度提升,是一个基于梯度提升决策树的算法库。这个库因其极端快速和与其他梯度提升算法实现以及传统机器学习算法相比的最佳性能而受到欢迎。scikit-learn 也提供了梯度提升算法,其本质与 XGBoost 相同,尽管 XGBoost 速度更快。主要原因是充分利用单台机器的不同核心或分布式节点集群的并行性。XGBoost 还可以正则化决策树,以避免模型过度拟合数据。XGBoost 不是一个完整的机器学习框架,但主要提供算法(模型)。要使用 XGBoost,我们必须使用 scikit-learn 来处理其余的实用函数和工具,例如数据分析和数据准备。

TensorFlow

TensorFlow 是另一个非常流行的开源机器学习库,由 Google Brain 团队开发,用于高性能计算。TensorFlow 特别适用于训练和运行深度神经网络,是深度学习领域的热门选择。

Keras

这是一个用于 Python 中神经网络深度学习的开源 API。Keras 更像是 TensorFlow 之上的高级 API。对于开发者来说,使用 Keras 比直接使用 TensorFlow 更方便,因此如果你是刚开始用 Python 开发深度学习模型,建议使用 Keras。Keras 可以与 CPU 和 GPU 一起工作。

PyTorch

PyTorch 是另一个开源机器学习库,它是 C 语言中流行的Torch库的 Python 实现。

在下一节中,我们将简要讨论使用 Python 进行机器学习的最佳实践。

使用 Python 进行训练数据的最佳实践

我们已经强调了在训练机器学习模型时数据的重要性。在本节中,我们将强调在准备和使用数据来训练您的机器学习模型时的几个最佳实践和建议。具体如下:

  • 正如我们之前提到的,收集大量数据至关重要(几千条数据记录或至少数百条)。数据量越大,机器学习模型将越准确。

  • 在开始任何训练之前,请清理和精炼您的数据。这意味着数据中不应有任何缺失字段或误导性字段。pandas 等 Python 库在执行此类任务时非常方便。

  • 在不损害数据隐私和安全性的情况下使用数据集很重要。您需要确保您没有未经适当批准使用其他组织的某些数据。

  • GPU 与数据密集型应用程序配合良好。我们鼓励您使用 GPU 来训练算法,以获得更快的成果。XGBoost、TensorFlow 和 Keras 等库因使用 GPU 进行训练而闻名。

  • 当处理大量训练数据时,有效地利用系统内存非常重要。我们应该分块加载数据到内存中,或者利用分布式集群来处理数据。我们鼓励您尽可能多地使用生成器函数。

  • 在处理数据密集型任务(例如,在训练模型时)期间,监控内存使用情况也是一个好习惯。通过强制垃圾回收释放未引用的对象,定期释放内存。

现在我们已经介绍了可用的 Python 库以及使用 Python 进行机器学习的最佳实践,现在是时候开始使用真实的代码示例进行工作了。

构建和评估机器学习模型

在我们开始编写 Python 程序之前,我们将评估构建机器学习模型的过程。

了解 ML 模型构建过程

在“介绍机器学习”部分,我们讨论了机器学习的不同组件。机器学习过程使用这些元素作为输入来训练模型。这个过程遵循一个有三个主要阶段的过程,每个阶段都有几个步骤。这些阶段如下所示:

![图 13.2 – 使用经典学习方法构建 ML 模型的步骤img/B17189_13_02.jpg

图 13.2 – 使用经典学习方法构建 ML 模型的步骤

每个阶段及其详细步骤在这里都有描述:

  • 数据分析:在这个阶段,我们收集原始数据并将其转换为可以分析和用于训练和测试模型的形式。我们可能会丢弃一些数据,例如包含空值的记录。通过数据分析,我们试图选择可用于识别数据中模式的特征(属性)。提取特征是一个非常关键的步骤,在构建成功的模型时,这些特征起着至关重要的作用。在许多情况下,我们必须在测试阶段之后调整特征,以确保我们有适合数据的正确特征集。通常,我们将数据分为两部分;一部分用于建模阶段训练模型,另一部分用于测试阶段测试训练好的模型以验证其准确性。如果我们使用其他方法(如交叉验证)评估模型,则可以跳过测试阶段。我们建议在您的 ML 构建过程中设置一个测试阶段,并保留一些数据(对模型来说是未知的)用于测试阶段,如图中所示。

  • 建模:这个阶段是关于根据我们在前一阶段提取的训练数据和特征来训练我们的模型。在传统的机器学习方法中,我们可以直接使用训练数据来训练我们的模型。但为了确保我们的模型有更好的准确性,我们可以使用以下附加技术:

    a) 我们可以将我们的训练数据分割成块,并使用其中一个块来评估我们的模型,其余的块用于训练模型。我们重复这个过程,使用不同的训练块和评估块的组合。这种评估方法被称为交叉验证。

    b) 机器学习算法带有几个参数,可以用来微调模型以最佳地拟合数据。在建模阶段,通常与交叉验证一起进行这些参数(也称为超参数)的微调。

    数据集中的特征值可能使用不同的测量尺度,这使得使用这些特征的组合来构建规则变得困难。在这种情况下,我们可以将数据(特征值)转换到公共尺度或归一化尺度(例如 0 到 1)。这一步称为数据缩放或归一化。所有这些缩放和评估步骤(或其中一些)都可以添加到管道(如 Apache Beam 管道)中,并可以一起执行以评估不同的组合,以选择最佳模型。这一阶段的输出是一个候选机器学习模型,如前图所示。

  • 测试:在测试阶段,我们使用之前阶段预留的数据来测试我们构建的候选机器学习模型的准确性。这一阶段的输出可以用来添加或删除一些特征,并微调模型,直到我们得到一个可接受的准确性。

一旦我们对模型的准确性满意,我们就可以将其应用于基于现实世界数据的预测。

构建一个示例机器学习模型

在本节中,我们将使用 Python 构建一个示例机器学习模型,该模型将识别三种类型的 Iris 植物。为了构建这个模型,我们将使用一个包含四个特征(萼片和花瓣的长度和宽度)和三种 Iris 植物类型的常用数据集。

对于这个代码练习,我们将使用以下组件:

  • 我们将使用由UC Irvine 机器学习存储库提供的 Iris 数据集(http://archive.ics.uci.edu/ml/)。这个数据集包含 150 条记录和三个预期模式以识别。这是一个经过精炼的数据集,其中已经识别了必要的特征。

  • 我们将使用以下各种 Python 库:

    a) 用于数据分析的 pandas 和 matplotlib 库

    b) 用于训练和测试我们的机器学习模型的 scikit-learn 库

首先,我们将编写一个 Python 程序来分析 Iris 数据集。

分析 Iris 数据集

为了编程的方便,我们从archive.ics.uci.edu/ml/machine-learning-databases/iris/下载了 Iris 数据集的两个文件(iris.datairis.names)。

我们可以直接通过 Python 从这个仓库访问数据文件。但在我们的示例程序中,我们将使用文件的本地副本。scikit-learn 库也提供了几个数据集作为库的一部分,可以直接用于评估目的。我们决定使用实际文件,因为这更接近现实场景,即你自行收集数据,然后在程序中使用它。

Iris 数据文件包含 150 条记录,这些记录是根据预期输出排序的。在数据文件中,提供了四个不同特征的值。这四个特征在iris.names文件中描述为sepal-length(萼片长度)、sepal-width(萼片宽度)、petal-length(花瓣长度)和petal-width(花瓣宽度)。根据数据文件,Iris 植物的预期输出类型是Iris-setosa(伊丽莎白)、Iris-versicolor(维吉尼亚鸢尾)和Iris-virginica(弗吉尼亚鸢尾)。我们将数据加载到 pandas DataFrame 中,然后分析其不同感兴趣属性。以下是一些分析 Iris 数据的示例代码:

#iris_data_analysis.py
from pandas import read_csv
from matplotlib import pyplot
data_file = "iris/iris.data"
iris_names = ['sepal-length', 'sepal-width', 'petal-  length', 'petal-width', 'class']
df = read_csv(data_file, names=iris_names)
print(df.shape)
print(df.head(20))
print(df.describe())
print(df.groupby('class').size())
# box and whisker plots
df.plot(kind='box', subplots=True, layout=(3,3),   sharex=False, sharey=False)
pyplot.show()
# check the histograms
df.hist()
pyplot.show()

在数据分析的第一部分,我们使用 pandas 库函数检查了一些关于数据的指标,如下所示:

  • 我们使用了shape方法来获取 DataFrame 的维度。对于 Iris 数据集,这应该是[150, 5],因为我们有 150 条记录和五个列(四个用于特征,一个用于预期输出)。这一步确保所有数据都正确地加载到我们的 DataFrame 中。

  • 我们使用headtail方法检查了实际数据。这只是为了直观地查看数据,特别是如果我们还没有看到数据文件内部的内容。

  • describe方法为我们提供了数据的不同统计 KPIs。此方法的结果如下:

       sepal-length  sepal-width  petal-length  petal-width
count    150.000000   150.000000    150.000000   150.000000
mean       5.843333     3.054000      3.758667     1.198667
std        0.828066     0.433594      1.764420     0.763161
min        4.300000     2.000000      1.000000     0.100000
25%        5.100000     2.800000      1.600000     0.300000
50%        5.800000     3.000000      4.350000     1.300000
75%        6.400000     3.300000      5.100000     1.800000
max        7.900000     4.400000      6.900000     2.500000

这些关键绩效指标(KPIs)可以帮助我们选择适合数据集的正确算法。

  • 使用了groupby方法来识别每个class(预期输出的列名)的记录数量。输出将表明每种 Iris 植物类型有 50 条记录:
Iris-setosa        50
Iris-versicolor    50
Iris-virginica     50

在分析的第二部分,我们尝试使用describe方法(最小值、第一四分位数、第二四分位数(中位数)、第三四分位数和最大值)。这个图将告诉你你的数据是否是对称分布的,或者是否在某个范围内分组,或者你的数据有多大的偏斜倾向分布的一侧。对于我们的 Iris 数据集,我们将得到以下四个特征的箱线图:

图 13.3 – Iris 数据集特征的箱线和须线图

图 13.3 – Iris 数据集特征的箱线和须线图

从这些图中,我们可以看到花瓣长度花瓣宽度的数据在第一四分位数和第三四分位数之间有最多的分组。我们可以通过使用直方图分析数据分布来确认这一点,如下所示:

图 13.4 – Iris 数据集特征的直方图

图 13.4 – Iris 数据集特征的直方图

在分析数据和选择合适的算法(模型)类型之后,我们将继续下一步,即训练我们的模型。

训练和测试一个样本机器学习模型

要训练和测试一个机器学习算法(模型),我们必须遵循以下步骤:

  1. 作为第一步,我们将原始数据集分成两组:训练数据和测试数据。这种数据分割的方法被称为train_test_split函数,以便于进行分割:

    #train_test_split function, we split the full dataset into a features dataset (typically called X, which should be uppercase in machine learning nomenclature) and the expected output dataset (called y, which should be lowercase in machine learning nomenclature). These two datasets (X and y) are split by the train_test_split function as per our test_size (20%, in our case). We also allow the data to be shuffled before splitting it. The output of this operation will give us four datasets (X_train, y_train, X_test, and y_test) for training and testing purposes. 
    
  2. 在下一步中,我们将创建一个模型,并提供训练数据(X_trainy_train)来训练这个模型。对于这个练习来说,机器学习算法的选择并不那么重要。对于 Iris 数据集,我们将使用默认参数的 SVC 算法。以下是一些示例 Python 代码:

    fit method. In the next statement, we made predictions based on the testing data (X_test). These predictions will be used to evaluate the performance of our trained model. 
    
  3. 最后,将使用测试数据(y_test)中的预期结果,通过 scikit-learn 库的accuracy_scoreclassification_report函数来评估预测,如下所示:

    #iris_build_svm_model.py (#3)
    # predictions evaluation
    print(accuracy_score(y_test, predictions))
    print(classification_report(y_test, predictions))
    

该程序的控制台输出如下:

0.9666666
    Iris-setosa       1.00      1.00      1.00        11
Iris-versicolor       1.00      0.92      0.96        13
 Iris-virginica       0.86      1.00      0.92         6
       accuracy                           0.97        30
      macro avg       0.95      0.97      0.96        30
   weighted avg       0.97      0.97      0.97        30

准确率范围非常高(0.966),这表明模型可以以近 96%的准确率预测测试数据中的 Iris 植物。模型在Iris-setosaIris-versicolor方面表现优秀,但在Iris-virginica的情况下仅表现尚可(86%精确)。有几种方法可以提高我们模型的表现,所有这些方法我们将在下一节中讨论。

使用交叉验证和微调超参数评估模型

对于之前的样本模型,我们为了学习构建机器学习模型的核心步骤,保持了训练过程的简单性。对于生产部署,我们不能依赖于只包含 150 条记录的数据集。此外,我们必须使用以下技术等来评估模型以获得最佳预测:

  • k 折交叉验证:在之前的模型中,我们在使用保留法将数据分割成训练和测试数据集之前对数据进行了洗牌。因此,模型每次训练时都可以给出不同的结果,从而导致模型不稳定。从包含 150 条记录的小数据集中选择训练数据并不简单,因为在这种情况下,它确实可以代表真实世界系统或环境的数据。为了使我们的先前的模型在小数据集上更加稳定,k 折交叉验证是推荐的方法。这种方法基于将我们的数据集分成k个折或切片。想法是使用k-1个切片进行训练,并使用kth个切片进行评估或测试。这个过程会重复进行,直到我们使用每个数据切片进行测试。这相当于重复使用不同的数据切片进行测试的保留法k次。

    为了进一步阐述,我们必须将我们的整个数据集或训练数据集分成五个切片,比如说 k=5,进行五折交叉验证。在第一次迭代中,我们可以使用第一个切片(20%)进行测试,剩余的四个切片(80%)用于训练。在第二次迭代中,我们可以使用第二个切片进行测试,剩余的四个切片用于训练,依此类推。我们可以评估所有五个可能的训练数据集,并在最后选择最佳模型。数据用于训练和测试的选择方案如下:

图 13.5 – 五份数据切片的交叉验证方案

图 13.5 – 五份数据切片的交叉验证方案

交叉验证准确率是通过计算每个迭代中每个模型根据 k 值的平均准确率来计算的。

  • 优化超参数:在前面的代码示例中,我们使用了具有默认参数的机器学习算法。每个机器学习算法都带有许多可以微调以定制模型、根据数据集进行调整的超参数。统计学家可能通过分析数据分布手动设置一些参数,但分析这些参数组合的影响是繁琐的。我们需要通过使用这些超参数的不同值来评估我们的模型,这有助于我们在最后选择最佳的超参数组合。这种技术被称为微调或优化超参数。

交叉验证和微调超参数的实现过程相当繁琐,即使通过编程也是如此。好消息是,scikit-learn 库提供了一些工具,可以在几行 Python 代码中实现这些评估。scikit-learn 库为此评估提供了两种类型的工具:GridSearchCVRandomizedSearchCV。我们将在下面讨论这些工具。

GridSearchCV

GridSearchCV 工具通过交叉验证方法评估给定模型的所有可能超参数值的组合。每个超参数值的组合将通过在数据集切片上使用交叉验证来评估。

在下面的代码示例中,我们将使用来自 scikit-learn 库的GridSearchCV类来评估Cgamma参数组合的 SVC 模型。C参数是一个正则化参数,它管理着低训练错误与低测试错误之间的权衡。C的值越高,我们能够接受的错误数量就越多。我们将使用 0.001、0.01、1、5、10 和 100 作为C的值。gamma参数用于定义分类的非线性超平面或非线性线。gamma的值越高,模型可以通过添加更多的曲率或曲线到超平面或线来尝试拟合更多的数据。我们也将使用 0.001、0.01、1、5、10 和 100 作为gamma的值。GridSearchCV的完整代码如下:

#iris_eval_svc_model.py (part 1 of 2)
from sklearn.model_selection import train_test_split
from sklearn.model_selection import   GridSearchCV,RandomizedSearchCV
from sklearn.datasets import load_iris
from sklearn.svm import SVC
iris= load_iris()
X = iris.data
y = iris.target
X_train, X_test, y_train, y_test=train_test_split   (X,y,test_size=0.2)
params = {"C":[0.001, 0.01, 1, 5, 10, 100],              "gamma": [0.001, 0.01, 0.1, 1, 10, 100]}
model=SVC()
grid_cv=GridSearchCV(model, params,  cv=5)
grid_cv.fit(X_train,y_train)
print(f"GridSearch- best parameter:{grid_cv.best_params_}")
print(f"GridSearch- accuracy: {grid_cv.best_score_}")
print(classification_report(y_test,   grid_cv.best_estimator_.predict( X_test)))

在这个代码示例中,以下几点需要强调:

  • 为了说明目的,我们直接从 scikit-learn 库加载数据。您也可以使用前面的代码从本地文件加载数据。

  • 定义params字典以微调超参数作为第一步是很重要的。我们在该字典中设置了Cgamma参数的值。

  • 我们将cv设置为 5。这将通过五个切片进行交叉验证来评估每个参数组合。

该程序的输出将给我们提供Cgamma的最佳组合以及带有交叉验证的模型准确率。最佳参数和最佳模型的控制台输出如下:

GridSearch- best parameter: {'C': 5, 'gamma': 0.1}
GridSearch- accuracy: 0.9833333333333334

通过评估不同参数组合并使用GridSearchCV进行交叉验证,模型的总体准确率从 96%提高到了 98%,与未进行交叉验证和超参数微调的结果相比。分类报告(未在程序输出中显示)显示,对于三种植物类型,我们的测试数据的精确度为 100%。然而,当数据集很大且参数值很多时,这个工具并不实用。

RandomizedSearchCV

RandomizedSearchCV工具的情况下,我们只评估随机选择的超参数值对应的模型,而不是所有不同的组合。我们可以提供参数值和要执行的随机迭代次数作为输入。RandomizedSearchCV工具将根据提供的迭代次数随机选择参数组合。当处理大型数据集且存在许多参数/值组合时,这个工具非常有用。评估大型数据集的所有可能组合可能是一个非常耗时的过程,需要大量的计算资源。

使用RandomizedSearchCV的 Python 代码与GridSearchCV工具相同,除了以下额外的代码行:

#iris_eval_svc_model.py (part 2 of 2)
rand_cv=RandomizedSearchCV(model, params, n_iter = 5, cv=5)
rand_cv.fit(x_train,y_train)
print(f" RandomizedSearch - best parameter:   {rand_cv.best_params_}")
print(f" RandomizedSearch - accuracy:   {rand_cv.best_score_}")

由于我们定义了n_iter=5RandomizedSearchCV将只选择五个Cgamma参数的组合,并相应地评估模型。

当程序的这个部分执行时,我们将得到类似于以下输出的结果:

RandomizedSearch- best parameter: {'gamma': 10, 'C': 5}
RandomizedSearch- accuracy: 0.9333333333333333

注意,你可能得到不同的输出,因为此工具可能会为评估选择不同的参数值。如果我们增加RandomizedSearchCV对象的迭代次数(n_iter),我们将观察到输出中更高的准确性。如果我们不设置n_iter,我们将对所有组合进行评估,这意味着我们将得到与GridSearchCV提供相同的输出。

如我们所见,由GridSearchCV工具选择的最佳参数组合与由RandomizedSearchCV工具选择的组合不同。这是预期的,因为我们为这两个工具运行了不同次数的迭代。

这就结束了我们关于使用 scikit-learn 库构建示例机器学习模型的讨论。我们涵盖了构建和评估此类模型所需的核心步骤和概念。在实践中,我们还对数据进行缩放以进行归一化。这种缩放可以通过使用 scikit-learn 库中的内置缩放器类,如StandardScaler,或通过构建我们自己的缩放器类来实现。缩放操作是一种数据转换操作,可以在单个管道下与模型训练任务结合。scikit-learn 支持使用Pipeline类将多个操作或任务作为管道结合。Pipeline类也可以直接与RandomizedSearchCVGridSearchCV工具一起使用。你可以通过阅读 scikit-learn 库的在线文档来了解更多关于如何使用 scikit-learn 库中的缩放器和管道的信息(scikit-learn.org/stable/user_guide.html)。

在下一节中,我们将讨论如何将模型保存到文件中,以及如何从文件中恢复模型。

将机器学习模型保存到文件

当我们评估了一个模型并选择了一个根据我们的数据集的最佳模型后,下一步就是实现这个模型以用于未来的预测。这个模型可以作为任何 Python 应用程序的一部分实现,例如 Web 应用程序、Flask 或 Django,或者它可以作为一个微服务甚至云函数使用。真正的问题是,如何将模型对象从一个程序转移到另一个程序。有几个库,如picklejoblib,可以用来将模型序列化到文件中。然后,该文件可以在任何应用程序中使用,以在 Python 中再次加载模型并使用模型对象的predict方法进行预测。

为了说明这个概念,我们将把我们在之前的代码示例中创建的 ML 模型(例如,iris_build_svm_model.py 程序中的 model 对象)保存到一个名为 model.pkl 的文件中。在下一步中,我们将使用 pickle 库从这个文件中加载模型,并使用新数据进行预测,以模拟在任何应用程序中使用模型。完整的示例代码如下:

#iris_save_load_predict_model.py 
#model is creating using the code in #iris_build_svm_model.py
#saving the model to a file
with open("model.pkl", 'wb') as file:
   pickle.dump(model, file)
#loading the model from a file (in another application
with open("model.pkl", 'rb') as file:
   loaded_model = pickle.load(file)
   x_new = [[5.6, 2.6, 3.9, 1.2]]
   y_new = loaded_model.predict(x_new)
   print("X=%s, Predicted=%s" % (x_new[0], y_new[0]))

使用 joblib 库比 pickle 库更简单,但如果它尚未作为 scikit-learn 的依赖项安装,则可能需要您安装此库。以下示例代码展示了如何使用 joblib 库保存我们最佳模型,根据我们在上一节中使用的 GridSearchCV 工具进行评估,然后从文件中加载模型:

#iris_save_load_predict_gridmodel.py 
#grid_cv is created and trained using the code in the 
  #iris_eval_svm_model.py
joblib.dump(grid_cv.best_estimator_, "model.joblib")
loaded_model = joblib.load("model.joblib")
x_new = [[5.6, 2.5, 3.9, 1.1]]
y_new = loaded_model.predict(x_new)
print("X=%s, Predicted=%s" % (x_new[0], y_new[0]))

joblib 库的代码简洁简单。示例代码中的预测部分与上一节 pickle 库的示例代码相同。

现在我们已经学会了如何将模型保存到文件中,我们可以将模型部署到任何应用程序,甚至部署到云平台,如 GCP AI 平台。我们将在下一节中讨论如何在 GCP 平台上部署我们的 ML 模型。

在 GCP 云上部署和预测 ML 模型

公共云提供商正在提供多个 AI 平台,用于训练内置模型以及您的自定义模型,以便部署模型进行预测。Google 为 ML 用例提供 Vertex AI 平台,而 Amazon 和 Azure 分别提供 Amazon SageMakerAzure ML 服务。我们选择 Google,因为我们假设您已经设置了 GCP 账户,并且您已经熟悉 GCP 的核心概念。GCP 提供其 AI 平台,这是 Vertex AI 平台的一部分,用于大规模训练和部署您的 ML 模型。GCP AI 平台支持 scikit-learn、TensorFlow 和 XGBoost 等库。在本节中,我们将探讨如何在 GCP 上部署我们已训练的模型,然后根据该模型预测结果。

Google AI 平台通过全局端点(ml.googleapis.com)或区域端点(<region>-ml.googleapis.com)提供其预测服务器(计算节点)。全局 API 端点建议用于批量预测,TensorFlow 在 Google AI 平台上提供批量预测。区域端点提供对其他区域故障的额外保护。我们将使用区域端点来部署我们的示例 ML 模型。

在我们开始在 GCP 中部署模型之前,我们需要有一个 GCP 项目。我们可以创建一个新的 GCP 项目或使用我们为之前的练习创建的现有 GCP 项目。创建 GCP 项目并将其与计费账户关联的步骤在 第九章云端的 Python 编程 中讨论过。一旦我们准备好了 GCP 项目,我们就可以部署我们在上一节中创建的 model.joblib 模型。部署我们的模型的步骤如下:

  1. 作为第一步,我们将创建一个存储桶,我们将在这里存储我们的模型文件。我们可以使用以下 Cloud SDK 命令来创建一个新的桶:

    gsutil mb gs://<bucket name>
    gsutil mb gs://muasif-svc-model #Example bucket created
    
  2. 一旦我们的桶准备好,我们可以使用以下 Cloud SDK 命令将我们的模型文件(model.joblib)上传到这个存储桶:

    gsutil model with an extension such as pkl, joblib, or bst, depending on the library we used to package the model.We can now initiate a workflow to create a model object on the AI Platform by using the following command. Note that the name of the model must include only alphanumeric and underscore characters:
    
    

    gcloud ai-platform models create my_iris_model –  region=us-central1

    
    
  3. 现在,我们可以使用以下命令为我们的模型创建一个版本:

    gcloud ai-platform versions model attribute will point to the name of the model we created in the previous step. b) The `origin` attribute will point to the storage bucket location where the model file is residing. We will only provide the directory's location, not the path to the file.c) The `framework` attribute is used to select which ML library to use. GCP offers scikit-learn, TensorFlow, and XGBoost.d) `runtime-version` is for the scikit-learn library in our case.e) `python-version` is selected as 3.7, which is the highest version offered that's by GCP AI Platform at the time of writing this book.f) The `region` attribute is set as per the region that was selected for the model.g) The `machine-type` attribute is optional and is used to indicate what type of compute node to use for model deployment. If not provided, the `n1-standard-2` machine type is used.The `versions create` command may take a few minutes to deploy a new version. Once it is done, we will get an output similar to the following:
    
    

    使用端点 [https://us-central1-  ml.googleapis.com/]

    创建版本(这可能需要几分钟)......完成。

    
    
  4. 要检查我们的模型和版本是否已正确部署,我们可以使用 versions 上下文下的 describe 命令,如下所示:

    gcloud ai-platform versions describe v1 –  model=my_iris_model
    
  5. 一旦我们的模型及其版本已经部署,我们可以使用新数据通过我们在 Google AI 平台上部署的模型来预测结果。为了测试,我们在一个 JSON 文件(input.json)中添加了几条与原始数据集不同的数据记录,如下所示:

    [5.6, 2.5, 3.9, 1.1]
    [3.2, 1.4, 3.0, 1.8]
    

    我们可以使用以下 Cloud SDK 命令根据 input.json 文件内的记录预测结果,如下所示:

    gcloud ai-platform predict --model my_iris_model --version v1 --json-instances input.json
    

    控制台输出将显示每个记录的预测类别,以及以下内容:

    Using endpoint [https://us-central1-ml.googleapis.com/]
    ['Iris-versicolor', 'Iris-virginica']
    

要在我们的应用程序(本地或云)中使用已部署的模型,我们可以使用 Cloud SDKCloud Shell,但推荐的方法是使用 Google AI API 进行任何预测。

有了这些,我们已经涵盖了使用 Google AI 平台部署我们的机器学习模型以及预测选项。然而,您也可以将您的机器学习模型部署到其他平台,例如 Amazon SageMaker 和 Azure ML。您可以在 https://docs.aws.amazon.com/sagemaker/ 找到有关 Amazon SageMaker 平台的更多详细信息,以及有关 Azure ML 的更多详细信息可以在 docs.microsoft.com/en-us/azure/machine-learning/ 找到。

摘要

在本章中,我们介绍了机器学习及其主要组成部分,例如数据集、算法和模型,以及模型的训练和测试。介绍之后,我们讨论了适用于 Python 的流行机器学习框架和库,包括 scikit-learn、TensorFlow、PyTorch 和 BGBoost。我们还讨论了为训练机器学习模型优化和管理数据的最佳实践。为了熟悉 scikit-learn 库,我们使用 SVC 算法构建了一个示例机器学习模型。我们训练了该模型,并使用诸如 k 折交叉验证和微调超参数等技术对其进行了评估。我们还学习了如何将训练好的模型存储到文件中,然后将其加载到任何程序中进行预测。最后,我们展示了如何使用几个 GCP Cloud SDK 命令在 Google AI 平台上部署我们的机器学习模型并预测结果。

本章中包含的概念和动手练习足以帮助构建使用 Python 进行机器学习项目的基石。这种理论和实践知识对那些希望开始使用 Python 进行机器学习的人来说是有益的。

在下一章中,我们将探讨如何使用 Python 进行网络自动化。

问题

  1. 什么是监督学习和无监督学习?

  2. 什么是 k 折交叉验证以及它是如何用于评估模型的?

  3. 什么是RandomizedSearchCV以及它与GridSearchCV有何不同?

  4. 我们可以使用哪些库将模型保存到文件中?

  5. 为什么区域端点比全局端点是 Google AI 平台的优选选项?

进一步阅读

答案

  1. 在监督学习中,我们使用训练数据提供所需的输出。在无监督学习中,所需的输出不是训练数据的一部分。

  2. 交叉验证是一种用于衡量机器学习模型性能的统计技术。在 k 折交叉验证中,我们将数据分为k个折叠或切片。我们使用数据集的k-1个切片来训练我们的模型,并使用kth个切片来测试模型的准确性。我们重复此过程,直到每个kth个切片都用作测试数据。模型的交叉验证准确性是通过取所有通过k次迭代构建的模型的准确性的平均值来计算的。

  3. RandomizedSearchCV 是一个与 scikit-learn 一起提供的工具,用于将交叉验证功能应用于随机选择的超参数的机器学习模型。GridSearchCV 提供与 RandomizedSearchCV 相似的功能,但不同之处在于它验证了提供给它的所有超参数值的组合。

  4. Pickle 和 Joblib。

  5. 区域端点提供了对其他区域任何故障的额外保护,并且计算资源对于区域端点比全球端点更为可用。

第十四章:第十四章:使用 Python 进行网络自动化

传统上,网络是由网络专家构建和运营的,这在电信行业仍然是一个趋势。然而,这种管理和运营网络的手动方法速度较慢,有时由于人为错误而导致昂贵的网络中断。此外,为了获得一项新服务(如互联网服务),客户在提交新服务请求后需要等待数天,直到服务准备就绪。基于智能手机和移动应用的经验,您只需点击一下按钮即可启用新服务和应用,客户期望网络服务在几分钟内(如果不是几秒钟内)就绪。使用当前的网络管理方法是不可能的。传统的做法有时也会成为电信服务提供商引入新产品和服务的障碍。

网络自动化可以通过提供用于自动化网络管理和操作方面的软件来改善这些情况。网络自动化有助于消除配置网络设备中的人为错误,并通过自动化重复性任务显著降低运营成本。网络自动化有助于加速服务交付,并使电信服务提供商能够引入新服务。

Python 是网络自动化的流行选择。在本章中,我们将探讨 Python 在网络自动化方面的功能。Python 提供了ParamikoNetmikoNAPALM等库,可用于与网络设备交互。如果网络设备由网络管理系统NMS)或网络控制器/编排器管理,Python 可以使用RESTRESTCONF协议与这些平台交互。没有监听网络中发生的实时事件,就无法实现端到端网络自动化。这些实时网络事件或实时流数据通常通过Apache Kafka等系统提供。我们还将探讨使用 Python 与事件驱动系统交互。

本章将涵盖以下主题:

  • 介绍网络自动化

  • 与网络设备交互

  • 与网络管理系统集成

  • 与基于事件的系统协同工作

完成本章后,您将了解如何使用 Python 库从网络设备获取数据,并将配置数据推送到这些设备。这些是任何网络自动化过程的基础步骤。

技术要求

本章的技术要求如下:

  • 您需要在您的计算机上安装 Python 3.7 或更高版本。

  • 您需要在 Python 上安装 Paramiko、Netmiko、NAPALM、ncclient 和 requests 库。

  • 您需要能够访问一个或多个支持 SSH 协议的网络设备。

  • 您需要能够访问诺基亚开发者实验室,以便能够访问诺基亚的 NMS(称为网络服务平台NSP))。

本章的示例代码可以在github.com/PacktPublishing/Python-for-Geeks/tree/master/Chapter14找到。

重要提示

在本章中,您需要访问物理或虚拟网络设备和网络管理系统来执行代码示例。这可能对每个人来说都不可能。您可以使用任何具有类似功能的网络设备。我们将更多地关注实现的 Python 方面,并使其方便地重用代码以用于任何其他设备或管理系统。

我们将首先通过提供网络自动化的介绍来开始我们的讨论。

介绍网络自动化

网络自动化是利用技术和软件来自动化管理和运营网络的过程。网络自动化的关键词是自动化一个过程,这意味着它不仅涉及部署和配置网络,还包括必须遵循的步骤以实现网络自动化。例如,有时自动化步骤包括在配置推送到网络之前从不同的利益相关者那里获得批准。自动化这样的批准步骤是网络自动化的一部分。因此,网络自动化过程可能因组织而异,取决于每个组织遵循的内部流程。这使得构建一个可以为许多客户开箱即用地执行自动化的单一平台具有挑战性。

有许多正在进行中的努力,旨在从网络设备供应商那里提供必要的平台,以帮助以最小的努力构建定制自动化。这些平台的几个例子包括思科的网络服务编排器NSO)、Juniper Networks 的Paragon Automation平台和诺基亚的NSP

这些自动化平台的一个挑战是它们通常是供应商锁定。这意味着供应商声称他们的平台可以管理和自动化其他供应商的网络设备,但实现多供应商自动化的过程既繁琐又昂贵。因此,电信服务提供商正在寻找超越供应商平台以实现自动化的方法。PythonAnsible是电信行业中用于自动化的两种流行编程语言。在我们深入探讨 Python 如何实现网络自动化之前,让我们探讨一下网络自动化的优点和挑战。

网络自动化的优点和挑战

我们已经强调了网络自动化的一些优点。我们可以总结关键优点如下:

  • 加速服务交付:更快地向新客户提供服务,使您能够尽早开始服务计费,并拥有更多满意的客户。

  • 降低运营成本:通过自动化重复性任务并通过工具和闭环自动化平台监控网络,可以降低网络的运营成本。

  • 消除幽默错误:大多数网络中断都是由于人为错误造成的。网络自动化可以通过使用标准模板配置网络来消除这一原因。这些模板在生产投入之前都经过了深入评估和测试。

  • 一致的网络设置:当人类配置网络时,不可能遵循一致的模板和命名约定,这对运营团队管理网络非常重要。网络自动化通过每次使用相同的脚本或模板配置网络,带来了设置网络的统一性。

  • 网络可见性:通过网络自动化工具和平台,我们可以访问性能监控能力,并可以从端到端可视化我们的网络。通过在它们造成网络流量瓶颈之前检测到流量峰值和资源的高利用率,我们可以进行主动式网络管理。

网络自动化是实现数字化转型的一个必要条件,但实现它有一些成本和挑战。这些挑战如下:

  • 成本:在构建或定制网络自动化软件时,总是会有成本。网络自动化是一个过程,每年都必须为其设定成本预算。

  • 人的抵触情绪:在许多组织中,人力资源认为网络自动化是对他们工作的威胁,因此他们抵制采用网络自动化,尤其是在运营团队中。

  • 组织结构:当网络自动化被用于不同的网络层和网络域,如 IT 和网络域时,它确实可以带来真正的投资回报率(ROI)。在许多组织中存在的挑战是,这些域由不同的部门拥有,每个部门都有自己的自动化策略和关于自动化平台的偏好。

  • 选择自动化平台/工具:从思科或诺基亚等网络设备供应商中选择自动化平台,或者与惠普或埃森哲等第三方自动化平台合作,并不是一个容易的决定。在许多情况下,电信服务提供商最终会拥有多个供应商来构建他们的网络自动化,这给让这些供应商协同工作带来了一系列新的挑战。

  • 维护:维护自动化工具和脚本与构建它们一样重要。这需要要么从自动化供应商那里购买必要的维护合同,要么设立一个内部团队来为这些自动化平台提供维护。

接下来,我们来看一下用例。

用例

可以使用 Python 或其他工具自动执行与网络管理相关的许多单调任务。但真正的益处是自动化那些如果手动执行会重复、易出错或令人厌烦的任务。从电信服务提供商的角度来看,以下是一些网络自动化应用的主要方面:

  • 我们可以自动化网络设备的日常配置,例如创建新的 IP 接口和网络连接服务。手动执行这些任务非常耗时。

  • 我们可以配置防火墙规则和政策来节省时间。创建防火墙规则配置是一项繁琐的活动,任何错误都可能导致在解决通信挑战时浪费时间。

  • 当我们在网络中有成千上万的设备时,升级它们的软件是一个巨大的挑战,有时,这需要 1 到 2 年的时间才能完成。网络自动化可以加速这一活动,并方便地进行升级前后的检查,以确保无缝升级。

  • 我们可以使用网络自动化将新的网络设备加入网络。如果设备要安装在客户的场所,我们可以通过自动化设备的加入过程来节省一次现场服务。这个过程也被称为零接触配置ZTP)。

现在我们已经介绍了网络自动化,让我们来探讨如何使用不同的协议与网络设备进行交互。

与网络设备交互

Python 是网络自动化中一个流行的选择,因为它易于学习,可以直接与网络设备集成,也可以通过 NMS 集成。实际上,许多厂商,如诺基亚和思科,在其网络设备上支持 Python 运行时。在单个设备上下文中自动化任务和活动的设备 Python 运行时选项非常有用。在本节中,我们将重点关注设备外 Python 运行时选项。这个选项将使我们能够同时处理多个设备。

重要提示

在本节提供的所有代码示例中,我们将使用来自思科的虚拟网络设备(IOS XR 版本 7.1.2)。为了与 NMS 集成,我们将使用诺基亚 NSP 系统。

在使用 Python 与网络设备交互之前,我们将讨论可用于与网络设备通信的协议。

与网络设备交互的协议

当涉及到直接与网络设备通信时,我们可以使用几种协议,例如 安全外壳协议SSH)、简单网络管理协议SNMP)和 网络配置NETCONF)。其中一些协议是建立在其他协议之上的。接下来将描述最常用的协议。

SSH

SSH 是一种网络协议,用于在任意两个设备或计算机之间安全地通信。在信息发送到传输通道之前,两个实体之间的所有信息都将被加密。我们通常使用 SSH 客户端通过 ssh 命令连接到网络设备。SSH 客户端使用 ssh 命令的已登录操作系统用户的 用户名

ssh <server ip or hostname>

要使用除已登录用户以外的其他用户,我们可以指定用户名,如下所示:

ssh username@<server IP or hostname>

一旦建立了 SSH 连接,我们可以发送 CLI 命令,要么从设备检索配置或操作信息,要么配置设备。SSH 版本 2SSHv2)是用于与设备进行网络管理和甚至自动化目的的流行选择。

使用基于 SSH 的协议与网络设备交互这一部分,我们将讨论如何使用 Python 库如 Paramiko、Netmiko 和 NAPALM 来使用 SSH 协议。SSH 也是许多高级网络管理协议的基础传输协议,如 NETCONF。

SNMP

该协议已经成为了 30 多年来的网络管理事实上的标准,并且仍然被大量用于网络管理。然而,它正在被更先进和可扩展的协议如 NETCONF 和 gNMI 所取代。SNMP 可用于网络配置和网络监控,但它更常用于网络监控。在当今世界,它被认为是一种在 20 世纪 80 年代末引入的遗留协议,纯粹用于网络管理。

SNMP 协议依赖于管理信息库MIB),这是一个设备模型。该模型是使用一种称为管理信息结构SMI)的数据建模语言构建的。

NETCONF

互联网工程任务组IETF)引入的 NETCONF 协议被认为是 SNMP 的继任者。NETCONF 主要用于配置网络设备,并预期所有新的网络设备都将支持它。NETCONF 基于以下四层:

  • 内容:这是一个依赖于 YANG 建模的数据层。每个设备都为其提供的各种模块提供几个 YANG 模型。这些模型可以在github.com/YangModels/yang上探索。

  • getget-configedit-configdelete-config

  • 消息:这些是在 NETCONF 客户端和 NETCONF 代理之间交换的远程过程调用RPC)消息。编码为 XML 的 NETCONF 操作和数据被封装在 RPC 消息中。

  • 传输:这一层在客户端和服务器之间提供通信路径。NETCONF 消息可以使用 NETCONF over SSH 或使用 SSL 证书选项的 NETCONF over TLS。

NETCONF 协议基于通过 SSH 协议交换的 XML 消息,默认端口为830。网络设备通常管理两种类型的配置数据库。第一种类型称为运行数据库,它表示设备上的活动配置,包括操作数据。这是每个设备的强制数据库。第二种类型称为候选数据库,它表示在推送到运行数据库之前可以使用的候选配置。当存在候选数据库时,不允许直接对运行数据库进行配置更改。

我们将在使用 NETCONF 与网络设备交互部分讨论如何使用 Python 与 NETCONF 一起工作。

RESTCONF

RESTCONF 是另一个IETF标准,它通过 RESTful 接口提供 NETCONF 功能的一个子集。与使用 XML 编码的 NETCONF RPC 调用不同,RESTCONF 提供基于 HTTP/HTTPS 的 REST 调用,可以选择使用 XML 或 JSON 消息。如果网络设备提供 RESTCONF 接口,我们可以使用 HTTP 方法(GETPATCHPUTPOSTDELETE)进行网络管理。当使用 RESTCONF 进行网络自动化时,我们必须理解它通过 HTTP/HTTPS 提供有限的 NETCONF 功能。NETCONF 操作,如提交、回滚和配置锁定,不支持通过 RESTCONF 进行。

gRPC/gNMI

gNMI 是一个用于网络管理和遥测应用的 gRPC网络管理接口NMI)。gRPC 是由 Google 开发的一种远程过程调用,用于低延迟和高性能的数据检索。gRPC 协议最初是为希望与具有严格延迟要求的云服务器通信的移动客户端开发的。gRPC 协议在通过协议缓冲区Protobufs)传输结构化数据方面非常高效,这是该协议的关键组件。通过使用 Protobufs,数据以二进制格式打包,而不是 JSON 或 XML 等文本格式。这种格式不仅减少了数据的大小,而且与 JSON 或 XML 相比,在序列化和反序列化数据方面非常高效。此外,数据使用 HTTP/2 而不是 HTTP 1.1 进行传输。HTTP/2 提供了请求-响应模型和双向通信模型。这种双向通信模型使得客户端能够打开长连接,从而显著加快数据传输过程。这两种技术使得 gRPC 协议比 REST API 快 7 到 10 倍。

gNMI 是 gRPC 协议在网络管理和遥测应用中的特定实现。它也是一个 YANG 模型驱动的协议,与 NETCONF 类似,但与 NETCONF 相比,提供的操作非常少。这些操作包括GetSetSubscribe。gNMI 在遥测数据收集方面比在网络管理方面更受欢迎。主要原因在于它不像 NETCONG 那样为网络配置提供足够的灵活性,但在从远程系统收集数据,尤其是在实时或近实时时,它是一个优化的协议。

接下来,我们将讨论用于与网络设备交互的 Python 库。

使用基于 SSH 的 Python 库与网络设备交互

有几个 Python 库可用于使用 SSH 与网络设备交互。Paramiko、Netmiko 和 NAPALM 是三个可用的流行库,我们将在下一节中探讨它们。我们将从 Paramiko 开始。

Paramiko

Paramiko 库是 Python 中 SSH v2 协议的抽象,包括服务器端和客户端功能。在这里,我们将只关注 Paramiko 库的客户端功能。

当我们与网络设备交互时,我们要么尝试获取配置数据,要么为某些对象推送新的配置。前者通过设备操作系统的show类型 CLI 命令实现,而后者可能需要执行配置 CLI 命令的特殊模式。这两种类型的命令在通过 Python 库工作时处理方式不同。

获取设备配置

要使用 Paramiko 库连接到网络设备(作为 SSH 服务器),我们必须使用paramiko.SSHClient类的实例或直接使用低级的paramiko.Transport类。Transport类提供了低级方法,可以提供基于套接字的通信的直接控制。SSHClient类是一个包装类,在底层使用Transport类来管理会话,并在网络设备上实现 SSH 服务器。

我们可以使用 Paramiko 库与网络设备(在我们的例子中是 Cisco IOS XR)建立连接,并运行 show 命令(在我们的例子中是show ip int brief)如下:

#show_cisco_int_pmk.py
import paramiko
host='HOST_ID'
port=22
username='xxx'
password='xxxxxx'
#cisco ios command to get a list of IP interfaces
cmd= 'show ip int brief \n'
def main():
    try:
        ssh = paramiko.SSHClient()
        ssh.set_missing_host_key_policy(paramiko.          AutoAddPolicy())
        ssh.connect(host, port, username, password)
        stdin, stdout, stderr = ssh.exec_command(cmd)
        output_lines = stdout.readlines()
        response = ''.join(output_lines)
        print(response)
    finally:
        ssh.close()
if __name__ == '__main__':
    main()

本代码示例的关键点如下:

  • 我们创建了一个SSHClient实例,并与 SSH 服务器建立了连接。

  • 由于我们不是使用主机密钥进行我们的 SSH 连接,所以我们应用了set_missing_host_key_policy方法以避免任何警告或错误。

  • 一旦建立了 SSH 连接,我们就使用 SSH 传输向主机机器发送了我们的 show 命令show ip int brief,并接收了命令的输出作为 SSH 回复。

  • 该程序的输出是一个包含stdinstdoutstderr对象的元组。如果我们的命令执行成功,我们将从stdout对象中检索输出。

当在 Cisco IOS XR 设备上执行此程序时,输出如下:

Mon Jul 19 12:03:41.631 UTC
Interface                   IP-Address      Status    Protocol 
Loopback0                   10.180.180.10   Up        Up
GigabitEthernet0/0/0/0      10.1.10.2       Up        Up
GigabitEthernet0/0/0/0.100  unassigned      Up        Down
GigabitEthernet0/0/0/1      unassigned      Up        Up
GigabitEthernet0/0/0/1.100  150.150.150.1   Up        Up
GigabitEthernet0/0/0/2      unassigned      Shutdown  Down 

如果你在这个程序上运行其他设备类型的程序,你必须根据你的设备类型更改已设置为cmd变量的命令。

Paramiko 库提供了对网络通信的低级控制,但由于许多网络设备对 SSH 协议的非标准或不完整实现,它有时可能会出现一些奇怪的问题。如果你在使用 Paramiko 与某些网络设备时遇到挑战,问题不是你或 Paramiko,而是设备期望你与之通信的方式。低级传输通道可以解决这些问题,但这需要一些复杂的编程。Netmiko 在这里提供了帮助。

Netmiko

Netmiko 是一个基于 Paramiko 库构建的网络管理抽象库。它通过将每个网络设备视为不同类型来消除 Paramiko 的挑战。Netmiko 在底层使用 Paramiko,并隐藏了许多设备级通信细节。Netmiko 支持来自不同厂商的多种设备,例如 Cisco、Arista、Juniper 和 Nokia。

获取设备配置

要使用show类型的 CLI 命令连接到网络设备,我们必须设置一个device_type定义,该定义用于连接到目标网络设备。这个device_type定义是一个字典,必须包括设备的类型、主机 IP 或设备的22端口。以下代码可以用来执行我们使用 Paramiko 库执行的相同show命令:

#show_cisco_int_nmk.py
from netmiko import ConnectHandler
cisco_rtr = {
    "device_type": "cisco_ios",
    "host": "HOST_ID",
    "username": "xxx",
    "password": "xxxxxxx",
    #"global_delay_factor": 2,
}
def main():
    command = "show ip int brief"
    with ConnectHandler(**cisco_rtr) as net_connect:
        print(net_connect.find_prompt())
        print(net_connect.enable())
        output = net_connect.send_command(command)
    print(output)

本示例代码的关键点如下:

  • 我们使用ConnectHandler类和上下文管理器创建了一个网络连接。上下文管理器将管理连接的生命周期。

  • Netmiko 提供了一个名为find_prompt的简单方法,用于获取目标设备的提示符,这对于解析许多网络设备的输出非常有用。对于Cisco IOS XR网络设备,这并不是必需的,但我们将其作为最佳实践。

  • Netmiko 还允许我们通过使用enable方法进入启用模式(这是一个命令行提示符,#),用于 Cisco IOS 设备。再次强调,对于本示例来说,这不是必需的,但将其作为最佳实践使用,尤其是在我们作为同一编程脚本的一部分推送 CLI 配置命令的情况下。

  • 我们使用send_command方法执行了show ip int brief命令,并得到了与show_cisco_int_pmk.py程序相同的输出。

基于我们分享的相同show命令的代码示例,我们可以得出结论,与 Paramiko 相比,使用 Netmiko 要方便得多。

重要提示

设置正确的设备类型对于获得一致的结果非常重要,即使您使用的是同一厂商的设备也是如此。当使用配置设备的命令时,这一点尤为重要。错误的设备类型可能导致不一致的错误。

有时,我们执行的命令需要比正常的show命令更多的时间来完成。例如,我们可能想要将设备上的文件从一个位置复制到另一个位置,我们知道对于大文件来说,这可能需要几百秒。默认情况下,Netmiko 等待命令完成的几乎为100秒。我们可以通过添加如下类似的行作为设备定义的一部分来添加全局延迟因子:

"global_delay_factor": 2

这将使该设备的所有命令的等待时间增加 2 倍。或者,我们可以通过send_command方法传递以下参数来为单个命令设置延迟因子:

delay_factor=2 

当我们预期有显著的执行时间时,我们应该添加一个延迟因子。当我们需要添加延迟因子时,我们还应该在send_command方法中添加另一个作为参数的属性,这样如果我们看到命令提示符(例如,Cisco IOS 设备的#),就可以提前中断等待周期。这可以通过以下属性设置:

expect_string=r'#'

配置网络设备

在以下代码示例中,我们将提供一些用于配置目的的示例代码。使用 Netmiko 配置设备类似于执行show命令,因为 Netmiko 将负责启用配置终端(如果需要,根据设备类型)并优雅地退出配置终端。

对于我们的代码示例,我们将使用以下程序使用 Netmiko 设置接口的description

#config_cisco_int_nmk.py
from netmiko import ConnectHandler
cisco_rtr = {
    "device_type": "cisco_ios",
    "host": "HOST_ID",
    "username": "xxx",
    "password": "xxxxxx",
}
def main():
    commands = ["int Lo0 "description custom_description",       "commit"]
    with ConnectHandler(**cisco_rtr) as net_connect:
        output = net_connect.send_config_set(commands)
    print(output)
    print()

这个代码示例的关键点如下:

  • 对于这个程序,我们创建了一个包含三个命令的列表(int <interface id>description <new description>commit)。前两个命令也可以作为一个单独的命令发送,但我们为了说明目的而将它们分开。commit命令用于保存更改。

  • 当我们向设备发送配置命令时,我们使用 Netmiko 库中的send_config_set方法来设置配置目的的连接。成功执行此步骤取决于设备类型的正确设置。这是因为配置命令的行为因设备而异。

  • 这组三个命令将为指定的接口添加或更新description属性。

除了设备配置提示符显示我们的命令外,这个程序不会期望有特殊的输出。控制台输出将如下所示:

Mon Jul 19 13:21:16.904 UTC
RP/0/RP0/CPU0:cisco(config)#int Lo0
RP/0/RP0/CPU0:cisco(config-if)#description custom_description
RP/0/RP0/CPU0:cisco(config-if)#commit
Mon Jul 19 13:21:17.332 UTC
RP/0/RP0/CPU0:cisco(config-if)#

Netmiko 提供了更多功能,但我们将其留给你通过阅读其官方文档(pypi.org/project/netmiko/)来探索。本节中讨论的代码示例已在 Cisco 网络设备上测试过,但如果你使用的设备由 Netmiko 支持,可以通过更改设备类型和命令来使用相同的程序。

Netmiko 简化了网络设备交互的代码,但我们仍然在运行 CLI 命令以获取设备配置或将配置推送到设备。使用 Netmiko 进行编程并不容易,但另一个名为 NAPALM 的库可以帮助我们。

NAPALM

NAPALMNetwork Automation and Programmability Abstraction Layer with Multivendor 的缩写。这个库在 Netmiko 之上提供了更高层次的抽象,通过提供一组函数作为统一的 API 来与多个网络设备交互。它支持的设备数量不如 Netmiko 多。对于 NAPALM 的第三个版本,核心驱动程序适用于 Arista EOSCisco IOSCisco IOS-XRCisco NX-OSJuniper JunOS 网络设备。然而,还有几个社区构建的驱动程序可用于与许多其他设备通信,例如 Nokia SROSAruba AOS-CXCiena SAOS

与 Netmiko 一样,我们将为与网络设备交互构建 NAPALM 示例。在第一个示例中,我们将获取 IP 接口列表,而在第二个示例中,我们将为 IP 接口添加或更新 description 属性。这两个代码示例将执行我们使用 Paramiko 和 Netmiko 库执行的操作。

获取设备配置

要获取设备配置,我们必须设置与我们的网络设备的连接。我们将在两个代码示例中都这样做。设置连接是一个三步过程,如下所述:

  1. 要设置连接,我们必须根据支持的设备类型获取设备驱动程序类。这可以通过使用 NAPALM 库的 get_network_driver 函数来实现。

  2. 一旦我们有了设备驱动程序类,我们可以通过向驱动程序类的构造函数提供例如 host idusernamepassword 等参数来创建设备对象。

  3. 下一步是使用设备对象的 open 方法连接到设备。所有这些步骤都可以像下面这样实现为 Python 代码:

    from napalm import get_network_driver 
    driver = get_network_driver('iosxr')
    device = driver('HOST_ID', 'xxxx', 'xxxx')
    device.open()
    

一旦设备的连接可用,我们可以调用 get_interfaces_ip(相当于 show interfaces CLI 命令)或 get_facts(相当于 show version CLI 命令)等方法。使用这两个方法的完整代码如下:

#show_cisco_int_npm.py
from napalm import get_network_driver
import json
def main():
    driver = get_network_driver('iosxr')
    device = driver('HOST_ID', 'root', 'rootroot')
    try:
        device.open()
        print(json.dumps(device.get_interfaces_ip(), indent=2))
        #print(json.dumps(device.get_facts(), indent=2))
    finally:
        device.close()

最有趣的事实是,这个程序的输出默认是 JSON 格式。NAPALM 默认将 CLI 命令的输出转换为 Python 中易于消费的字典。以下是之前代码示例输出的一部分:

{
  "Loopback0": {
    "ipv4": {
      "10.180.180.180": {
        "prefix_length": 32
      }
    }
  },
  "MgmtEth0/RP0/CPU0/0": {
    "ipv4": {
      "172.16.2.12": {
        "prefix_length": 24
      }
    }
  }
}

配置网络设备

在以下代码示例中,我们使用 NAPALM 库为现有的 IP 接口添加或更新 description 属性:

#config_cisco_int_npm.py
from napalm import get_network_driver
import json
def main():
    driver = get_network_driver('iosxr')
    device = driver('HOST_ID', 'xxx', 'xxxx')
    try:
        device.open()
        device.load_merge_candidate(config='interface Lo0 \n            description napalm_desc \n end\n')
        print(device.compare_config())
        device.commit_config()
    finally:
        device.close()

此代码示例的关键点如下:

  • 要配置 IP 接口,我们必须使用 load_merge_candidate 方法,并将与 Netmiko 接口配置相同的 CLI 命令集传递给此方法。

  • 接下来,我们使用 compare_config 方法比较了命令前后配置的差异。这表明了添加了哪些新配置以及删除了哪些配置。

  • 我们使用 commit_config 方法提交了所有更改。

对于这个示例代码,输出将显示变化的差异,如下所示:

--- 
+++ 
@@ -47,7 +47,7 @@
  !
 !
 interface Loopback0
- description my custom description
+ description napalm added new desc 
  ipv4 address 10.180.180.180 255.255.255.255
 !
 interface MgmtEth0/RP0/CPU0/0

在这里,以-开头的行是要删除的配置;任何以+开头的行是要添加的新配置。

通过这两个代码示例,我们已经向你展示了一个设备类型的基本 NAPALM 功能集。这个库可以同时配置多个设备,并且可以与不同的配置集一起工作。

在下一节中,我们将讨论使用 NETCONF 协议与网络设备交互。

使用 NETCONF 与网络设备交互

NETCONF 是为了模型(对象)驱动的网络管理而创建的,特别是为了网络配置。在使用 NETCONF 与网络设备一起工作时,了解设备以下两个功能是很重要的:

  • 你可以理解你所拥有的设备的 YANG 模型。如果你希望以正确的格式发送消息,拥有这些知识是很重要的。以下是从各种供应商那里获取 YANG 模型的优秀来源:github.com/YangModels/yang

  • 你可以为你的网络设备上的 NETCONF 和 SSH 端口启用 NETCONF 协议。在我们的案例中,我们将使用 Cisco IOS XR 的虚拟设备,正如我们在之前的代码示例中所做的那样。

在开始任何网络管理相关活动之前,我们必须检查设备的 NETCONF 功能以及 NETCONF 数据源配置的详细信息。在本节的所有代码示例中,我们将使用一个名为ncclient的 Python NETCONF 客户端库。这个库提供了发送 NETCONF RPC 请求的便捷方法。我们可以使用ncclient库编写一个示例 Python 程序,以获取设备的功能和设备的完整配置,如下所示:

#check_cisco_device.py
from ncclient import manager
with manager.connect(host='device_ip, username=xxxx,   password=xxxxxx, hostkey_verify=False) as conn:
   capabilities = []
   for capability in conn.server_capabilities:
      capabilities.append(capability)
   capabilities = sorted(capabilities)
   for cap in capabilities:
     print(cap)
   result = conn.get_config(source="running")
   print (result)

ncclient库中的manager对象用于通过 SSH 连接到设备,但使用 NETCONF 端口830(默认)。首先,我们通过连接实例获取服务器功能列表,然后以排序格式打印它们,以便于阅读。在代码示例的下一部分,我们通过manager类库的get_config方法启动了一个get-config NETCONF 操作。这个程序的输出非常长,显示了所有功能和设备配置。我们将其留给你去探索,并熟悉你设备的特性。

重要的是要理解本节的范围不是解释 NETCONF,而是学习如何使用 Python 和ncclient与 NETCONF 一起工作。为了实现这一目标,我们将编写两个代码示例:一个用于获取设备接口的配置,另一个是如何更新接口的描述,这与我们之前为 Python 库所做的是相同的。

通过 NETCONF 获取接口

在前面的章节中,我们了解到我们的设备(Cisco IOS XR)通过OpenConfig实现支持接口,该实现可在openconfig.net/yang/interfaces?module=openconfig-interfaces找到。

我们还可以检查我们接口配置的 XML 格式,这是我们通过get_config方法获得的输出。在这个代码示例中,我们将简单地将一个带有接口配置的 XML 过滤器作为参数传递给get_config方法,如下所示:

#show_all_interfaces.py
from ncclient import manager
with manager.connect(host='device_ip', username=xxx,                password='xxxx', hostkey_verify=False) as conn:
    result = conn.get_config("running", filter=('subtree', 
    '<interfaces xmlns= "http://openconfig.net/yang/      interfaces"/>'))
    print (result)

这个程序的输出是一个接口列表。为了说明目的,我们在这里只展示输出的一部分:

<rpc-reply message-id="urn:uuid:f4553429-ede6-4c79-aeea-5739993cacf4" xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
 <data>
  <interfaces xmlns="http://openconfig.net/yang/interfaces">
   <interface>
    <name>Loopback0</name>
    <config>
     <name>Loopback0</name>
     <description>Configured by NETCONF</description>
    </config>
<!—rest of the output is skipped -->

为了获取一组选择性的接口,我们将使用基于接口 YANG 模型的扩展版 XML 过滤器。对于下面的代码示例,我们将定义一个带有接口name属性的 XML 过滤器作为我们的过滤标准。由于这个 XML 过滤器是多行的,我们将单独将其定义为字符串对象。以下是带有 XML 过滤器的示例代码:

#show_int_config.py
from ncclient import manager
# Create filter template for an interface
filter_temp = """
<filter>
    <interfaces xmlns="http://openconfig.net/yang/interfaces">
        <interface>
            <name>{int_name}</name>
        </interface>
    </interfaces>
</filter>"""
with manager.connect(host='device_ip', username=xxx,                password='xxxx', hostkey_verify=False) as conn:
    filter = filter_temp.format(int_name = "MgmtEth0/RP0/      CPU0/0")
    result = m.get_config("running", filter)
    print (result)

这个程序的输出将是一个单独的接口(根据我们设备的配置),如下所示:

<?xml version="1.0"?>
<rpc-reply message-id="urn:uuid:c61588b3-1bfb-4aa4-a9de-2a98727e1e15" xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
 <data>
  <interfaces xmlns="http://openconfig.net/yang/interfaces">
   <interface>
    <name>MgmtEth0/RP0/CPU0/0</name>
    <config>
     <name>MgmtEth0/RP0/CPU0/0</name>
    </config>
    <ethernet xmlns="http://openconfig.net/yang/interfaces/      ethernet">
     <config>
      <auto-negotiate>false</auto-negotiate>
     </config>
    </ethernet>
    <subinterfaces>
     <@!— ommitted sub interfaces details to save space -->
    </subinterfaces>
   </interface>
  </interfaces>
 </data>
</rpc-reply>

我们也可以在 XML 文件中定义 XML 过滤器,然后在 Python 程序中将文件内容读入字符串对象。如果我们计划广泛使用过滤器,另一个选项是使用Jinja模板。

接下来,我们将讨论如何更新接口的描述。

更新接口的描述

要配置接口属性,如description,我们必须使用在cisco.com/ns/yang/Cisco-IOS-XR-ifmgr-cfg可用的 YANG 模型。

此外,配置接口的 XML 块与我们用于获取接口配置的 XML 块不同。为了更新接口,我们必须使用以下模板,我们已在单独的文件中定义:

<!--config-template.xml-->
<config xmlns:xc="urn:ietf:params:xml:ns:netconf:base:1.0">
 <interface-configurations xmlns="http://cisco.com/ns/yang/  Cisco-IOS-XR-ifmgr-cfg">
   <interface-configuration>
    <active>act</active>
    <interface-name>{int_name}</interface-name>
    <description>{int_desc}</description>
   </interface-configuration>
 </interface-configurations>
</config>

在这个模板中,我们设置了接口的namedescription属性的占位符。接下来,我们将编写一个 Python 程序,该程序将读取这个模板并通过使用ncclient库的edit_config方法调用 NETCONF 的edit-config操作,将模板推送到设备的候选数据库:

#config_cisco_int_desc.py
from ncclient import manager
nc_template = open("config-template.xml").read()
nc_payload = nc_template.format(int_name='Loopback0',                          int_desc="Configured by NETCONF")
with manager.connect(host='device_ip, username=xxxx,                     password=xxx, hostkey_verify=False) as nc:
    netconf_reply = nc.edit_config(nc_payload,       target="candidate")
    print(netconf_reply)
    reply = nc.commit()
    print(reply)

在这里,有两点很重要。首先,Cisco IOS XR 设备已被配置为仅通过候选数据库接受新的配置。如果我们尝试将target属性设置为running,它将失败。其次,我们必须在相同会话中在edit-config操作之后调用commit方法,以使新的配置生效。这个程序的输出将是 NETCONF 服务器两个 OK 回复,如下所示:

<?xml version="1.0"?>
<rpc-reply message-id="urn:uuid:6d70d758-6a8e-407d-8cb8-10f500e9f297" xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
 <ok/>
</rpc-reply>
<?xml version="1.0"?>
<rpc-reply message-id="urn:uuid:2a97916b-db5f-427d-9553-de1b56417d89" xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
 <ok/>
</rpc-reply>

这就结束了我们使用 Python 进行 NETCONF 操作的讨论。我们使用ncclient库介绍了 NETCONF 的两个主要操作(get-configedit-config)。

在下一节中,我们将探讨如何使用 Python 与网络管理系统集成。

与网络管理系统集成

网络管理系统或网络控制器是提供具有图形用户界面GUIs)的网络管理应用程序的系统。这些系统包括网络库存、网络配置、故障管理和与网络设备的调解等应用程序。这些系统使用 SSH/NETCONF(用于网络配置)、SNMP(用于警报和设备监控)和 gRPC(用于遥测数据收集)等通信协议的组合与网络设备通信。这些系统还通过其脚本和工作流引擎提供自动化功能。

这些系统的最有价值之处在于,它们将网络设备的各项功能聚合到一个单一系统中(即自身),然后通过其北向接口NBIs),通常是 REST 或 RESTCONF 接口提供。这些系统还通过基于事件系统的通知,如 Apache Kafka,提供实时事件(如警报)的通知。在本节中,我们将讨论使用 NMS 的 REST API 的几个示例。在与事件驱动系统集成部分,我们将探讨如何使用 Python 与 Apache Kafka 集成。

要与 NMS 一起工作,我们将使用诺基亚在线开发者门户提供的共享实验室(network.developer.nokia.com/)。这个实验室有几台诺基亚 IP 路由器和一台 NSP。这个共享实验室在撰写本书时免费提供(每天 3 小时)。您需要免费在开发者门户中创建一个账户。当您预订实验室使用时,您将收到一封电子邮件,其中包含如何连接到实验室的说明,以及必要的 VPN 详细信息。如果您是网络工程师并且可以访问任何其他 NMS 或控制器,您可以通过进行适当的调整使用该系统来完成本节中的练习。

要从诺基亚 NSP 消费 REST API,我们需要与 REST API 网关交互,该网关管理诺基亚 NSP 的多个 API 端点。我们可以通过使用位置服务开始与 REST API 网关一起工作,如以下所述。

使用位置服务端点

要了解可用的 API 端点,诺基亚 NSP 提供了一个位置服务端点,提供所有 API 端点的列表。在本节中,我们将使用 Python 的requests库来消费任何 REST API。requests库因其使用 HTTP 协议向服务器发送 HTML 请求而闻名,我们已在之前的章节中使用过它。要从诺基亚 NSP 系统中获取 API 端点列表,我们将使用以下 Python 代码调用位置服务 API:

#location_services1.py
import requests
payload = {}
headers = {}
url = "https://<NSP URL>/rest-gateway/rest/api/v1/location/  services"
resp = requests.request("GET", url, headers=headers,   data=payload)
print(resp.text)

此 API 响应将为您提供几十个 API 端点,以 JSON 格式。您可以在诺基亚 NSP 的在线文档network.developer.nokia.com/api-documentation/中查看,了解每个 API 是如何工作的。如果我们正在寻找特定的 API 端点,我们可以在上述代码示例中更改url变量的值,如下所示:

url = "https://<NSP URL>/rest-gateway/rest/api/v1/ location/services/endpoints?endPoint=/v1/auth/token

通过使用这个新的 API URL,我们试图找到一个用于授权令牌的 API 端点(/v1/auth/token)。使用这个新 URL 的代码示例输出如下:

{ 
 "response": { 
  "status": 0, 
  "startRow": 0, 
  "endRow": 0, 
  "totalRows": 1, 
  "data": { 
   "endpoints": [ 
    { 
    "docUrl":"https://<NSP_URL>/rest-gateway/api-docs#!/      authent..", 
    "effectiveUrl": "https://<NSP_URL>/rest-gateway/rest/api", 
    "operation": "[POST]" 
    } 
   ] 
  }, 
  "errors": null 
 } 
}

注意,使用位置服务 API 不需要身份验证。但是,我们需要一个身份验证令牌来调用任何其他 API。在下一节中,我们将学习如何获取身份验证令牌。

获取身份验证令牌

作为下一步,我们将使用前一个代码示例的输出中的effectiveUrl来获取身份验证令牌。此 API 要求我们将usernamepasswordbase64编码作为 HTTP 头中的Authorization属性传递。调用此身份验证 API 的 Python 代码如下:

#get_token.py
import requests
from base64 import b64encode
import json
#getting base64 encoding 
message = 'username'+ ':' +'password'
message_bytes = message.encode('UTF-8')
basic_token = b64encode(message_bytes)
payload = json.dumps({
  "grant_type": "client_credentials"
})
headers = {
  'Content-Type': 'application/json',
  'Authorization': 'Basic {}'.format(str(basic_token,'UTF-8'))
}
url = "https://<NSP SERVER URL>/rest-gateway/rest/api/v1/auth/  token"
resp = requests.request("POST", url, headers=headers,   data=payload)
token = resp.json()["access_token"]
print(resp)
When executing this Python code, we will get a token for one   hour to be used for any NSP API. 
{
  "access_token": "VEtOLVNBTXFhZDQ3MzE5ZjQtNWUxZjQ0YjNl",
  "refresh_token": "UkVUS04tU0FNcWF5ZlMTmQ0ZTA5MDNlOTY=",
  "token_type": "Bearer",
  "expires_in": 3600
}

此外,还有一个刷新令牌可用,可以在令牌过期之前刷新令牌。一个最佳实践是每30分钟刷新一次令牌。我们可以使用相同的身份验证令牌 API 来刷新我们的令牌,但在 HTTP 请求体中发送以下属性:

payload = json.dumps({
  "grant_type": "refresh_token",
  "refresh_token": "UkVUS04tU0FNcWF5ZlMTmQ0ZTA5MDNlOTY="
})

另一个好习惯是在不再需要令牌时撤销它。这可以通过使用以下 API 端点来实现:

 url = "https://<NSP URL>rest-gateway/rest/api/v1/auth/  revocation"

获取网络设备和接口清单

一旦我们收到身份验证令牌,我们就可以使用 REST API 获取配置数据,以及添加新的配置。我们将从一个简单的代码示例开始,该示例将获取由 NSP 管理的网络中所有网络设备的列表。在这个代码示例中,我们将使用通过令牌 API 已经检索到的令牌:

#get_network_devices.py
import requests
pload={}
headers = {
  'Authorization': 'Bearer {token}'.format(token)
}
url = "https://{{NSP_URL}}:8544/NetworkSupervision/rest/api/v1/  networkElements"
response = requests.request("GET", url, headers=headers,   data=pload)
print(response.text)

此程序的输出将是一个包含网络设备属性的网络设备列表。我们跳过了输出显示,因为这是一组大量数据。

在下面的代码示例中,我们将展示如何根据过滤器获取设备端口(接口)列表。请注意,我们也可以将过滤器应用于网络设备。对于本代码示例,我们将要求 NSP API 根据端口名称(在我们的例子中是Port 1/1/1)给我们提供一个端口列表:

#get_ports_filter.py
import requests
payload={}
headers = {
  'Authorization': 'Bearer {token}'.format(token)
}
url = "https://{{server}}:8544/NetworkSupervision/rest/api/v1/  ports?filter=(name='Port 1/1/1')
response = requests.request("GET", url, headers=headers,   data=payload)
print(response.text)

此程序的输出将是从所有网络设备中调用名为Port 1/1/1的设备端口列表。使用单个 API 获取多个网络设备的端口是使用 NMS 的实际价值

接下来,我们将讨论如何使用 NMS API 更新网络资源。

更新网络设备端口

使用 NMS API 创建新对象或更新现有对象也很方便。我们将实现一个更新端口描述的案例,就像我们在之前的代码示例中所做的那样,使用 NetmikoNAPALMncclient。要更新端口或接口,我们将使用一个不同的 API 端点,该端点来自 网络功能管理器包NFMP)模块。NFMP 是诺基亚网络设备在诺基亚 NSP 平台下的 NMS 模块。让我们看看更新端口描述或对网络资源进行任何更改的步骤:

  1. 要更新对象或在一个现有对象下创建新对象,我们需要使用具有以下筛选标准的 v1/managedobjects/searchWithFilter API:

    #fullClassNames. The object's full class names are available in the Nokia NFMP object model documentation. We set filterExpression to search for a unique port based on the device site's ID and port name. The resultFilter attribute is used to limit the attributes that are returned by the API in the response. We are interested in the objectFullName attribute in the response of this API. 
    
  2. 接下来,我们将使用一个名为 v1/managedobjects/ofn 的不同 API 端点来更新网络对象的属性。在我们的例子中,我们只更新描述属性。对于更新操作,我们必须在有效载荷中设置 fullClassName 属性以及描述属性的新值。对于 API 端点的 URL,我们将连接我们在上一步计算的 port_ofn 变量。该程序这部分内容的示例代码如下:

    #update_port_desc.py (part 2)
    payload2 = json.dumps({
      "fullClassName": "equipment.PhysicalPort",
      "properties": {
        "description": "description added by a Python       program"
      }
    })
    url2 = "https:// NFMP_URL:8443/nfm-p/rest/api/v1/  managedobjects/"+port_ofn
    response = requests.request("PUT", url2, headers=headers,   data=payload2, verify=False)
    print(response.text)
    

网络自动化是指按照特定顺序创建和更新许多网络对象的过程。例如,我们可以在创建 IP 连接服务以连接两个或更多局域网之前更新一个端口。这类用例要求我们执行一系列任务来更新所有涉及的端口,以及许多其他对象。使用 NMS API,我们可以在程序中编排所有这些任务以实现自动化流程。

在下一节中,我们将探讨如何集成诺基亚 NSP 或类似系统以实现事件驱动通信。

集成事件驱动系统

在前面的章节中,我们讨论了如何使用请求-响应模型与网络设备和网络管理系统进行交互。在这个模型中,客户端向服务器发送请求,服务器作为对请求的回复发送响应。HTTP(REST API)和 SSH 协议基于请求-响应模型。这种模型在临时或定期配置系统或获取网络的操作状态时工作得很好。但是,如果网络中发生需要操作团队注意的事情怎么办?例如,假设设备上的硬件故障或线路电缆被切断。网络设备通常在这种情况下发出警报,并且这些警报必须立即通知操作员(通过电子邮件、短信或仪表板)。

我们可以使用请求-响应模型每秒(或每隔几秒)轮询网络设备,以检查网络设备的状态是否发生变化,或者是否有新的警报。然而,这种方式并不是网络设备资源的有效利用,并且会在网络中产生不必要的流量。那么,如果网络设备或 NMS 本身在关键资源状态发生变化或发出警报时主动联系感兴趣的客户端,会怎样呢?这种类型的模型被称为事件驱动模型,它是发送实时事件的一种流行通信方式。

事件驱动系统可以通过webhooks/WebSockets或使用流式方法来实现。WebSockets 通过 TCP/IP 套接字在 HTTP 1.1 上提供了一个双向传输通道。由于这种双向连接不使用传统的请求-响应模型,因此当我们需要在两个系统之间建立一对一连接时,WebSockets 是一种高效的方法。当我们需要两个程序之间进行实时通信时,这是最佳选择之一。所有标准浏览器都支持 WebSockets,包括 iPhone 和 Android 设备所提供的浏览器。它也是许多社交媒体平台、流媒体应用和在线游戏的流行选择。

WebSockets 是获取实时事件的一个轻量级解决方案。但是,当许多客户端希望从一个系统中接收事件时,使用流式方法可扩展且高效。基于流的基于事件模型通常遵循发布-订阅设计模式,并具有三个主要组件,如下所述:

  • 主题:所有流式消息或事件通知都存储在主题下。我们可以将主题视为一个目录。这个主题帮助我们订阅感兴趣的主题,以避免接收所有事件。

  • 生产者:这是一个将事件或消息推送到主题的程序或软件。这也被称为发布者。在我们的案例中,它将是一个 NSP 应用。

  • 消费者:这是一个从主题中获取事件或消息的程序。这也被称为订阅者。在我们的案例中,这将是我们将要编写的 Python 程序。

事件驱动系统适用于网络设备以及网络管理系统。NMS 平台使用 gRPC 或 SNMP 等事件系统从网络设备接收实时事件,并为编排层或操作或监控应用程序提供聚合接口。在我们的示例中,我们将与诺基亚 NSP 平台的事件系统交互。诺基亚 NSP 系统提供了一个基于 Apache Kafka 的事件系统。Apache Kafka 是一个开源软件,用 Scala 和 Java 开发,它提供了一个基于发布-订阅设计模式的软件消息总线实现。在与 Apache Kafka 交互之前,我们将列举通过诺基亚 NSP 提供的以下关键类别(在 Apache Kafka 中用于主题的术语)列表:

  • NSP-FAULT:这个类别涵盖了与故障或警报相关的事件。

  • NSP-PACKET-ALL:这个类别用于所有网络管理事件,包括心跳事件。

  • NSP-REAL-TIME-KPI:这个类别代表实时流通知的事件。

  • NSP-PACKET-STATS:这个类别用于统计事件。

在诺基亚 NSP 文档中可以找到完整的类别列表。所有这些类别都提供了订阅特定类型事件的附加过滤器。在诺基亚 NSP 的上下文中,我们将与 Apache Kafka 交互以创建新的订阅,然后处理来自 Apache Kafka 系统的事件。我们将从订阅管理开始。

为 Apache Kafka 创建订阅

在从 Apache Kafka 接收任何事件或消息之前,我们必须订阅一个主题或类别。请注意,一个订阅仅对一类有效。订阅通常在 1 小时后过期,因此建议在过期前 30 分钟更新订阅。

要创建新的订阅,我们将使用v1/notifications/subscriptions API 和以下示例代码来获取新的订阅:

#subscribe.py
import requests
token = <token obtain earlier>
url = "https://NSP_URL:8544/nbi-notification/api/v1/  notifications/subscriptions"
def create_subscription(category):
  headers = {'Authorization': 'Bearer {}'.format(token) }
  payload = {
      "categories": [
        {
          "name": "{}".format(category)
        }
      ]
  }
  response = requests.request("POST", url, json=payload,                               headers=headers, verify=False)
  print(response.text)
if __name__ == '__main__':
      create_subscription("NSP-PACKET-ALL")

该程序的输出将包括重要的属性,如subscriptionIdtopicIdexpiresAt等,如下所示:

{
   "response":{
      "status":0,
      "startRow":0,
      "endRow":0,
      "totalRows":1,
      "data": {
         "subscriptionId":"440e4924-d236-4fba-b590-           a491661aae14",
         "clientId": null,
         "topicId":"ns-eg-440e4924-d236-4fba-b590-           a491661aae14",
         "timeOfSubscription":1627023845731,
         "expiresAt":1627027445731,
         "stage":"ACTIVE",
         "persisted":true
      },
      "errors":null
   }
}

subscriptionId属性用于稍后更新或删除订阅。Apache Kafka 将为该订阅创建一个特定的主题。它作为topicId属性提供给我们。我们将使用这个topicId属性来连接到 Apache Kafka 以接收事件。这就是为什么我们称 Apache Kafka 中的通用主题为类别。expiresAt属性表示此订阅将过期的时间。

一旦订阅准备就绪,我们就可以连接到 Apache Kafka 以接收事件,如下一小节所述。

处理来自 Apache Kafka 的事件

使用kafka-python库,编写一个基本的 Kafka 消费者只需要几行 Python 代码。要创建一个 Kafka 客户端,我们将使用kafka-python库中的KafkaConsumer类。我们可以使用以下示例代码来消费订阅主题的事件:

#basic_consumer.py
topicid = 'ns-eg-ff15a252-f927-48c7-a98f-2965ab6c187d'
consumer = KafkaConsumer(topic_id,
                         group_id='120',
                         bootstrap_servers=[host_id], value_                          deserializer=lambda m: json.loads                          (m.decode('ascii')),
                         api_version=(0, 10, 1))
try:
    for message in consumer:
        if message is None:
            continue
        else:
            print(json.dumps(message.value, indent=4, sort_              keys=True))
except KeyboardInterrupt:
    sys.stderr.write('++++++ Aborted by user ++++++++\n')
finally:
    consumer.close()

重要提示:如果您使用的是 Python 3.7 或更高版本,则必须使用kafka-python库。如果您使用的是低于 3.7 版本的 Python,则可以使用kafka库。如果我们在 Python 3.7 或更高版本中使用kafka库,已知存在一些问题。例如,已知async在 Python 3.7 或更高版本中已成为关键字,但在kafka库中已被用作变量。当使用kafka-python库与 Python 3.7 或更高版本一起使用时,也存在 API 版本问题。这些问题可以通过设置正确的 API 版本作为参数(在这种情况下为0.10.0版本)来避免。

在本节中,我们向您展示了一个基本的 Kafka 消费者,但您可以通过访问本书提供的源代码中的更复杂示例来探索:github.com/nokia/NSP-Integration-Bootstrap/tree/master/kafka/kafka_cmd_consumer

续订和删除订阅

我们可以使用与创建订阅相同的 API 端点来使用 Nokia NSP Kafka 系统续订订阅。我们将在 URL 末尾添加subscriptionId属性,以及renewals资源,如下所示:

https://{{server}}:8544/nbi-notification/api/v1/notifications/subscriptions/<subscriptionId>/renewals

我们可以使用相同的 API 端点,通过在 URL 末尾添加subscriptionId属性,并使用 HTTP 的Delete方法来删除订阅。以下是一个删除请求的 API 端点示例:

https://{{server}}:8544/nbi-notification/api/v1/notifications/subscriptions/<subscriptionId>

在这两种情况下,我们都不会在请求体中发送任何参数。

这就结束了我们关于使用请求-响应模型和事件驱动模型与 NMS 和网络控制器集成的讨论。这两种方法在与其他管理系统集成时都将为你提供一个良好的起点。

摘要

在本章中,我们介绍了网络自动化,包括其优势和它为电信服务提供商带来的挑战。我们还讨论了网络自动化的关键用例。在介绍之后,我们讨论了网络自动化与网络设备交互时可用的传输协议。网络自动化可以以多种方式采用。我们首先探讨了如何使用 Python 中的 SSH 协议直接与网络设备交互。我们使用了 Paramiko、Netmiko 和 NAPALM Python 库从设备获取配置,并详细说明了如何将此配置推送到网络设备。接下来,我们讨论了如何使用 Python 中的 NETCONF 与网络设备交互。我们提供了与 NETCONF 一起工作的代码示例,并使用 ncclient 库获取 IP 接口配置。我们还使用相同的库更新了网络设备上的 IP 接口。

在本章的最后部分,我们探讨了如何与网络管理系统,如诺基亚 NSP 进行交互。我们使用 Python 作为 REST API 客户端和 Kafka 消费者与诺基亚 NSP 系统进行交互。我们提供了一些代码示例,说明了如何获取认证令牌,然后向 NMS 发送 REST API 以检索配置数据并更新设备上的网络配置。

本章包含了一些代码示例,使您熟悉使用 Python 通过 SSH、NETCONF 协议以及使用 NMS 级 REST API 与设备交互。如果您是自动化工程师,并希望利用 Python 功能在您的领域脱颖而出,这种实际知识至关重要。

本章总结了本书的内容。我们不仅涵盖了 Python 的高级概念,还提供了在许多高级领域使用 Python 的见解,例如数据处理、无服务器计算、Web 开发、机器学习和网络自动化。

问题

  1. Paramiko 库中用于连接设备的常用类叫什么名字?

  2. NETCONF 有哪四层?

  3. 你可以直接将配置推送到 NETCONF 中的 running 数据库吗?

  4. 为什么 gNMI 在数据收集方面比网络配置更好?

  5. RESTCONF 是否提供与 NETCONF 相同的功能,但通过 REST 接口提供?

  6. Apache Kafka 中的发布者和消费者是什么?

进一步阅读

答案

  1. paramiko.SSHClient类。

  2. 内容、操作、消息和传输。

  3. 如果网络设备不支持candidate数据库,它通常允许直接更新running数据库。

  4. gNMI 基于 gRPC,这是一个由谷歌引入的用于移动客户端和云应用之间 RPC 调用的协议。该协议针对数据传输进行了优化,这使得它在从网络设备收集数据方面比配置它们更有效率。

  5. RESTCONF 通过 REST 接口提供了 NETCONF 的大部分功能,但它并没有暴露 NETCONF 的所有操作。

  6. 发布者是发送消息到 Kafka 主题(类别)作为事件的客户端程序,而消费者是读取并处理从 Kafka 主题消息的客户端应用程序。

Packt.com

订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及领先的行业工具,帮助你规划个人发展并推进你的职业生涯。如需更多信息,请访问我们的网站。

第十五章:为什么订阅?

  • 使用来自超过 4,000 位行业专业人士的实用电子书和视频,节省学习时间,多花时间编码

  • 通过为你量身定制的技能计划提高你的学习效果

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于轻松访问关键信息

  • 复制粘贴、打印和收藏内容

你知道 Packt 为每本书都提供电子书版本,包括 PDF 和 ePub 文件吗?你可以在packt.com升级到电子书版本,并且作为印刷书客户,你有权获得电子书副本的折扣。如需了解更多详情,请联系我们 customercare@packtpub.com。

www.packt.com,你还可以阅读一系列免费的技术文章,注册各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

你可能还会喜欢以下书籍

如果你喜欢这本书,你可能还会对 Packt 出版的以下书籍感兴趣:

Python 面向对象编程

Steven F. Lott, Dusty Phillips

ISBN: 978-1-80107-726-2

  • 通过创建类和定义方法在 Python 中实现对象

  • 通过继承扩展类功能

  • 使用异常干净地处理异常情况

  • 了解何时使用面向对象的功能,更重要的是,何时不使用它们

  • 发现几个广泛使用的设计模式以及它们在 Python 中的实现方式

  • 揭示单元和集成测试的简单性,并了解为什么它们如此重要

  • 学习如何对动态代码进行静态类型检查

  • 通过 asyncio 了解并发,以及它是如何加快程序的

专家 Python 编程 - 第四版

Michał Jaworski, Tarek Ziadé

ISBN: 978-1-80107-110-9

  • 探索设置可重复和一致 Python 开发环境的现代方法

  • 有效打包 Python 代码以供社区和生产使用

  • 学习 Python 编程的现代语法元素,如 f-strings、枚举和 lambda 函数

  • 使用元类去神秘化 Python 中的元编程

  • 在 Python 中编写并发代码

  • 使用 C 和 C++编写的代码扩展和集成 Python

Packt 正在寻找像你这样的作者

如果你感兴趣成为 Packt 的作者,请访问authors.packtpub.com并今天申请。我们已与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将见解分享给全球技术社区。你可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。

分享你的想法

现在你已经完成了《Python for Geeks》,我们很乐意听听你的想法!如果你在亚马逊购买了这本书,请点击此处直接跳转到该书的亚马逊评论页面并分享你的反馈或在该购买网站上留下评论。

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

你可能还会喜欢的其他书籍

posted @ 2025-09-20 21:33  绝不原创的飞龙  阅读(26)  评论(0)    收藏  举报