原文:
zh.annas-archive.org/md5/B119EED158BCF8E2AB5D3F487D794BB2
译者:飞龙
前言
Python 是一种动态类型的解释语言,可以快速构建各种领域的应用程序,包括人工智能、桌面和 Web 应用程序。
随着 Python 生态系统的最新进展和支持高度可重用性并使模块化代码编译成为可能的大量库的可用性,Python 可以用于构建能够解决组织问题的应用程序。这些应用程序可以在短时间内开发,并且如果开发得当,可以以一种解决组织需求的方式进行扩展。
Python 3.7 版本带来了几项改进和新功能,使应用程序开发变得轻松。连同…
这本书适合谁
企业应用程序是旨在解决组织特定业务需求的关键应用程序。企业应用程序的要求与个人通常需要的要求大不相同。这些应用程序应提供高性能和可扩展性,以满足组织日益增长的需求。
考虑到这一点,本书适用于具有 Python 编程中级知识并愿意深入了解根据组织需求进行扩展的应用程序构建的开发人员。本书提供了几个示例,可以在运行在 Linux 发行版上的 Python 3.7 上执行,但也适用于其他操作系统。
为了充分利用本书,您必须对基本操作系统概念有基本的了解,例如进程管理和多线程。除此之外,对数据库系统的基本工作知识可能有益,但不是强制性的。
熟悉 Python 应用程序构建不同方面的开发人员可以学习有助于构建可扩展应用程序的工具和技术,并了解企业应用程序开发方法的想法。
充分利用本书
除了对编程有一般了解外,不需要特定的专业知识才能利用本书。
Odoo 是使用 Python 构建的,因此对该语言有扎实的了解是个好主意。我们还选择在 Ubuntu 主机上运行 Odoo(一种流行的云托管选项),并且将在命令行上进行一些工作,因此一些熟悉将是有益的。
为了充分利用本书,我们建议您找到关于 Python 编程语言、Ubuntu/Debian Linux 操作系统和 PostgreSQL 数据库的辅助阅读。
尽管我们将在 Ubuntu 主机上运行 Odoo,但我们还将提供关于如何在…设置开发环境的指导
下载示例代码文件
您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packt.com/support并注册,以便直接将文件发送到您的邮箱。
您可以按照以下步骤下载代码文件:
-
在www.packt.com上登录或注册。
-
选择“支持”选项卡。
-
单击“代码下载和勘误”。
-
在搜索框中输入书名,然后按照屏幕上的说明操作。
下载文件后,请确保使用最新版本的解压缩或提取文件夹:
-
Windows 的 WinRAR/7-Zip
-
Mac 的 Zipeg/iZip/UnRarX
-
Linux 的 7-Zip/PeaZip
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Enterprise-Application-Development-with-Python
。我们还有其他代码包来自我们丰富的书籍和视频目录,可在**github.com/PacktPublishing/
**上找到。去看看吧!
使用的约定
本书中使用了许多文本约定。
CodeInText
:表示文本中的代码词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄。以下是一个例子:“除了这三个包,读者还需要sqlalchemy
包,它提供了我们将在整个章节中使用的 ORM,以及psycopg2
,它提供了postgres
数据库绑定,允许sqlalchemy
连接到postgres
。”
代码块设置如下:
username = request.args.get('username')email = request.args.get('email')password = request.args.get('password')user_record = User(username=username, email=email, password=password)
当我们希望绘制…
第一章:使用 Python 进行企业开发
Python 在编程世界中已经存在了二十多年,多年来,这种语言已经经历了许多改进,一个不断增长的社区,以及许多生产就绪和得到良好支持的库。但 Python 是否准备好在长期由 C++、Java 和.NET 等所谓的企业级语言主导的企业应用程序开发领域取得突破?
在本章中,我们将看到 Python 如何在多年来发展,并且准备成为企业应用程序开发领域的严肃竞争者。
本章将涵盖以下主题:
-
Python 的最新发展,以促进其在企业应用程序开发中的增长
-
Python 发光的特殊用例
-
企业和通用软件之间的区别
-
开发企业应用程序的要求
技术要求
本书中的代码清单可以在github.com/PacktPublishing/Hands-On-Enterprise-Application-Development-with-Python
的chapter01
目录下找到
可以通过运行以下命令克隆代码示例:
git clone https://github.com/PacktPublishing/Hands-On-Enterprise-Application-Development-with-Python
运行代码的说明可以在各个章节目录下的README
文件中找到。
代码已经测试在运行 Fedora 28 和 Python 版本 3.6.5 的系统上运行,但它应该能够在运行 Python 3.6.5 的任何系统上运行。
Python 的最新发展
Python 是一种动态类型的解释型语言,最初非常适合于无聊和重复的日常脚本任务。但随着年龄的增长,语言获得了许多新功能和庞大的社区支持,推动了其发展,使其成为一种非常适合执行从简单的应用程序(如网络抓取)到分析大量数据以训练机器学习模型的任务的语言。这些模型本身是用 Python 编写的。让我们看看多年来发生了哪些重大变化,并了解 Python 的最新版本 Python 3 带来了什么。
放弃向后兼容性
Python 作为一种语言在多年来发生了很大变化,但尽管这一事实,用 Python 1.0 编写的程序仍然能够在 Python 2.7 中运行,这是在 Python 1.0 发布 19 年后发布的版本。
尽管对 Python 应用程序的开发人员来说是一个巨大的好处,但语言的这种向后兼容性也是语言规范的重大改进的增长和发展的主要障碍,因为如果对语言规范进行重大更改,大量旧代码库将会中断。
随着 Python 3 的发布,这种向后兼容性链被打破了。版本 3 的语言放弃了对早期版本编写的程序的支持…
这都是 Unicode
在 Python 2 时代,文本数据类型str
用于支持 ASCII 数据,对于 Unicode 数据,语言提供了unicode
数据类型。当有人想要处理特定编码时,他们会取一个字符串并将其编码为所需的编码方案。
此外,该语言天生支持将字符串类型隐式转换为unicode
类型。如下代码片段所示:
str1 = 'Hello'
type(str1) # type(str1) => 'str'
str2 = u'World'
type(str2) # type(str2) => 'unicode'
str3 = str1 + str2
type(str3) # type(str3) => 'unicode'
这曾经有效,因为在这里,Python 会隐式地使用默认编码将字节字符串str1
解码为 Unicode,然后执行连接。这里需要注意的一点是,如果str1
字符串包含任何非 ASCII 字符,那么这种连接在 Python 中将失败,引发UnicodeDecodeError
。
随着 Python 3 的到来,处理文本的数据类型发生了变化。现在,默认数据类型str
用于存储文本并支持 Unicode。此外,Python 3 还引入了一个名为bytes
的二进制数据类型,用于存储二进制数据。这两种类型str
和bytes
是不兼容的,它们之间不会发生隐式转换,任何尝试这样做的行为都会引发TypeError
,如下面的代码所示:
str1 = 'I am a unicode string'
type(str1) # type(str1) => 'str'
str2 = b"And I can't be concatenated to a byte string"
type(str2) # type(str2) => 'bytes'
str3 = str1 + str2
-----------------------------------------------------------
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: can't concat str to bytes
正如我们所看到的,尝试将unicode
类型字符串与byte
类型字符串连接失败,出现了TypeError
。虽然无法将string
隐式转换为byte
,或者将byte
隐式转换为string
,但我们有一些方法可以将string
编码为bytes
类型,将bytes
类型解码为string
。看看以下代码:
str1 = '₹100'
str1.encode('utf-8')
#b'\xe2\x82\xb9100'
b'\xe2\x82\xb9100'.decode('utf-8')
# '₹100'
字符串类型和二进制类型之间的明显区别以及对隐式转换的限制可以实现更健壮的代码和更少的错误。但这些变化也意味着,任何在 Python 2 中处理 Unicode 的代码都需要在 Python 3 中进行重写,因为存在向后不兼容性。
在这里,你应该关注用于将string
转换为bytes
和反之的编码和解码格式。选择不同的格式进行编码和解码可能会导致重要信息的丢失,并可能导致数据损坏。
类型提示的支持
Python 是一种动态类型语言,因此变量的类型在赋值后由解释器在运行时评估,如下面的代码所示:
a = 10type(a) # type(a) => 'int'a = "Joe" type(a) # type(a) => 'str'
尽管动态解释变量类型的功能在编写小型程序时可能很方便,因为代码库可以很容易地被跟踪,但当处理非常庞大的代码库时,这种语言特性也可能成为一个大问题,因为会产生大量模块,跟踪特定变量类型可能会成为一个挑战,与不兼容类型相关的愚蠢错误也很容易发生。看看以下代码:…
Python 的亮点
每种语言都是为了解决开发人员在构建特定领域软件时遇到的某种类型的问题而开发的。Python 作为一种动态类型、解释型语言,也有一系列擅长的用例。
这些用例涉及自动化重复和乏味的任务,快速原型设计应用程序,以及专注于实现特定目标的小型应用程序,比如安装软件、设置开发环境、执行清理等。
但这就是全部吗?Python 只适用于执行小任务吗?答案是否定的。作为一种语言,Python 更加强大,可以轻松完成大量越来越复杂的任务,比如运行一个网站,能够在很短的时间内扩展以满足数百万用户的需求,处理大量的传入文件,或者为图像识别系统训练机器学习模型。
我们正在讨论使用 Python 执行越来越复杂的任务,但与我们传统的编译时语言(如 C++、Java 和.NET)相比,Python 是否慢?嗯,这完全取决于一个人想要使用 Python 的上下文。如果你的目标是在处理能力有限的嵌入式设备上运行 Python 程序,那么是的,Python 可能不够,因为其解释器对处理环境的额外负载。但如果你计划在配置良好的现代硬件上运行 Web 应用程序,你可能永远不会在使用 Python 时遇到任何减速。相反,你可能会觉得在使用 Python 时更加高效,因为其语法非常简单,执行操作时无需编写数百行代码。
因此,让我们看看 Python 在企业环境中的表现。
企业 IT 的需求
企业 IT 是复杂的,为企业构建的应用程序与为普通消费者构建的应用程序有很大的不同。在为企业用户开发应用程序之前,需要考虑几个因素。让我们看看企业 IT 应用程序与普通消费者产品有何不同,如下列表所示:
-
面向业务:与为解决个人用户问题而构建的应用程序不同,企业应用程序是为满足组织的特定需求而构建的。这要求应用程序符合组织的业务实践、规则和工作流程。
-
健壮性…
Python 在企业生态系统中
Python 以多种形式存在于企业生态系统中;无论是自动化乏味和重复的任务,作为产品两层之间的粘合剂,还是用于构建快速易用的大型服务器后端客户端,该语言在各种用例中都看到了越来越多的采用。但是是什么让 Python 准备好开发大型企业应用程序呢?让我们来看一下:
- 能够快速构建原型:Python 的语法非常简单,很多事情可以用很少的代码实现。这使开发人员能够快速开发和迭代应用程序的原型。除此之外,这些原型并不总是需要被丢弃,如果开发得当,它们可以作为构建最终应用程序的良好基础。
通过快速原型化应用程序的能力,企业软件开发人员可以准确地看到需求如何在应用程序中对齐以及应用程序的性能如何。有了这些信息,应用程序的利益相关者可以更准确地定义应用程序开发的路径,从而避免因为某些事情没有按预期的方式进行而导致中期架构更改。
- 成熟的生态系统:成熟的生态系统是 Python 值得关注的特性之一。Python 的外部库数量正在迅速增长。对于大多数需要在应用程序中实现的任务,例如双因素身份验证、测试代码、运行生产 Web 服务器、与消息总线集成等,您可以轻松地寻找到一个具有相当不错支持的库。
这证明了它非常有帮助,因为它减少了代码重复量,并增加了组件的可重用性。借助诸如pip
之类的工具,很容易将所需的库添加到项目中,并借助诸如virtualenv
之类的工具,您可以轻松地在同一系统上对许多不同的项目进行分隔,而不会创建依赖混乱。
例如,如果有人想要构建一个简单的 Web 应用程序,他们可能只需使用 Flask,这是一个用于开发 Web 应用程序的微框架,并且可以继续开发 Web 应用程序,而无需担心处理套接字、操纵数据等底层复杂性。他们只需要几行代码就可以让一个简单的应用程序运行起来,如下面的代码所示:
from flask import Flask
app = Flask(__name__)
@app.route('/', methods=["GET"])
def hello():
return "Hello, this is a simple Flask application"
if name == '__main__':
app.run(host='127.0.0.1', port=5000)
现在,一旦有人调用前面的脚本,他们将拥有一个flask
HTTP 应用程序正在运行。这里剩下要做的就是启动浏览器并导航到http://localhost:5000
。然后我们将看到 Flask 在不费吹灰之力地提供 Web 应用程序。所有这些都可以在不到 10 行代码的情况下实现。
有许多外部库为许多任务提供支持,企业开发人员可以轻松地在应用程序中启用对新功能的支持,而无需从头开始编写所有内容,从而减少可能出现的错误和非标准化接口进入应用程序的机会。
- 社区支持:Python 语言不归任何特定的公司实体所有,完全由庞大的社区支持,决定标准的未来。这确保了语言将继续得到长时间的支持,并且不会很快过时。这对组织来说非常重要,因为他们希望他们运行的应用程序能够得到长期的支持。
考虑到 Python 的所有优势,如果能够以经过精心规划的方式做出决策,那么使用该语言时开发人员的生产力将得到提升,同时还能够降低软件的总拥有成本。这些决策涉及应用程序架构的布局以及使用外部库或自行开发的决定。因此,是的,Python 现在确实已经准备好在企业应用程序开发的主流世界中使用。
介绍 BugZot - 一个 RESTful 错误跟踪器
随着我们在本书的章节中的进展,我们需要一种方法来实现我们所学到的知识。
想象一下,你在一家名为Omega Corporation的组织工作,这是一家向公司和个人销售软件产品的市场领导者。Omega Corporation 需要一个系统,通过该系统可以跟踪其产品中的错误。经过大量的头脑风暴,他们启动了一个名为 BugZot 的项目,这将是他们跟踪产品中错误的工具。
让我们看看 Omega Corporation 希望通过 BugZot 项目实现什么:
- 用户能够报告产品中的错误:用户,无论是内部还是外部用户,都应该能够针对特定产品报告错误…
在开发之前收集需求
在开始开发企业应用程序之前收集软件需求可能是一项繁琐的任务,如果未能充分做到这一点,可能会导致严重后果,例如由于在应用程序开发周期后期识别需求而导致的延迟增加的成本。缺乏重要功能以改进业务流程工作流的应用程序将导致用户在最坏的情况下停止使用应用程序。
需求收集过程复杂而繁琐,在组织中可能需要数月才能完成。本书的范围超出了涉及该过程的所有步骤。本节试图简要描述需求收集软件需求过程中的一些重要步骤。
询问用户需求
对于组织内部的应用程序,可能会有各种利益相关者和用户,可以定义应用程序的需求。这些用户可以大致分为两类:
-
劳动力:这些是通常使用应用程序来完成一定任务的用户。他们不关心应用程序提供的所有功能,而是关注应用程序如何适应他们的个人工作流程。这些用户可以提供特定于他们工作的需求,但可能无法提供关于他们将来可能需要什么或其他团队可能需要什么的想法。
-
管理层:管理层由人员组成…
需求分类
一旦用户被调查了他们希望在应用程序中拥有什么,下一步就是对这些需求进行分类。广义上说,需求可以分为两部分:
-
功能需求:这些是定义应用程序功能和功能的需求。例如,BugZot 具有以下功能需求:
-
为内部和外部用户提供提交错误的功能
-
提供角色和权限支持
-
提供处理文件上传的功能
-
与电子邮件系统集成,以便在错误更改状态时发送电子邮件,等等
-
非功能需求:这些是不影响软件功能的一组要求,而是基于功能需求的隐式或显式特征。例如,在 BugZot 中,以下可能被定义为一些非功能需求:
-
应用程序应提供针对常见 Web 攻击向量(如 XSS 和 CSRF)的安全性
-
应用程序的运营成本不应超过总预算的N%
-
应用程序应能够在崩溃后需要恢复时生成备份
优先考虑需求
一旦确定并将需求分类为功能和非功能需求,就需要根据其在应用程序中的重要性对其进行优先考虑。如果不进行这种优先考虑,将导致开发成本增加、截止日期延迟,并降低组织的生产力。广义上,我们可以将需求分类为以下类别:
-
必须有:这些是对应用程序成功至关重要的要求,在应用程序发货时必须存在。
-
应该有:这些是那些将增强应用程序功能的要求,但需要进一步讨论是否…
生成软件需求规格说明文档
一旦确定、分组和优先考虑了需求,就会生成一份名为软件需求规格说明的文档。该文档描述了需要开发的软件的预期目的、需求和性质。
软件需求规格说明(SRS)将描述以下信息:
-
应用程序的预期目的
-
文档中使用的约定是特定于组织业务流程的
-
应用程序的特性
-
将使用应用程序的用户类
-
应用程序将运行的环境
-
应用程序的功能和非功能需求
一旦 SRS 生成,就会进行审查和进一步的谈判。一旦成功完成,应用程序就会进入设计阶段,其中会设计应用程序的模拟。
摘要
在本章中,我们简要介绍了不断变化的编程环境,并探讨了多年来 Python 生态系统的变化。我们看到 Python 允许快速原型设计,并且拥有大量得到良好支持的库和一个开放的社区,因此迅速成为需要长期支持和与现有系统轻松集成的企业大型应用程序开发的主要选择。
然后,我们介绍了演示应用程序 BugZot,这是我们将在本书的过程中构建的,并定义了应用程序所需的功能。
本章的最后一节涵盖了…
问题
-
在 Python 3 中是否可以对
str
类型和byte
类型执行连接等操作? -
Python 3 中引入的类型提示支持是否是强制性的?
-
除了功能和非功能需求之外,还有其他类型的需求可能需要记录到软件需求规格说明中吗?
-
需求优先级可以在哪些主要类别中进行?
-
一旦生成了软件需求规格说明文档,接下来应该采取哪些步骤?
进一步阅读
如果您想在进入企业应用程序开发世界之前再次学习 Python 编程的基础知识,Packt 有一本非常好的书可以供您参考。您可以在以下链接获取:
第二章:设计模式-做出选择
当进行软件应用程序开发项目时,它本质上被视为需要解决的问题。当我们开始开发应用程序时,我们开始开发一个特定于给定问题的解决方案。最终,这个解决方案可能开始在类似问题中得到重复使用,并成为解决这类问题的标准解决方案。随着时间的推移,我们发现很多显示相同模式的问题。一旦我们修改我们的标准解决方案以适应这种观察到的模式,我们就提出了一个设计模式。设计模式不是闹着玩的;经过多年的尝试和测试,才能产生,用于解决大量具有相似模式的问题。
设计模式不仅定义了我们构建软件应用程序的方式,还提供了关于在尝试解决特定类型问题时什么有效和什么无效的知识。有时候,没有特定的设计模式可能适合特定应用程序的需求,开发人员别无选择,只能提出独特的解决方案。
是否有一些现有的标准设计模式可以用于特定类型的问题?我们如何决定在我们的问题中使用哪种设计模式?我们可以偏离特定的设计模式并在解决方案中使用它们吗?随着我们在本章的进展,我们将尝试回答这些问题。
在本章结束时,您将了解以下内容:
-
设计模式及其分类
-
Python 的面向对象特性,以及我们如何使用它来实现一些常见的设计模式
-
特定模式可能被使用的用例
技术要求
本书的代码清单可以在github.com/PacktPublishing/Hands-On-Enterprise-Application-Development-with-Python
的chapter02
目录下找到。
可以通过运行以下命令克隆代码示例:
git clone https://github.com/PacktPublishing/Hands-On-Enterprise-Application-Development-with-Python
运行代码示例的说明可以在章节目录内的README.md
文件中找到。
设计模式
设计模式定义了我们如何组织解决给定问题的方式。它不定义可以用来解决问题的算法,而是提供了关于例如代码应该如何组织,需要定义哪些类,它们的粒度将是什么,以及如何创建不同对象的抽象。
设计模式已经获得了很多关注,1994 年出版的书籍《设计模式:可复用面向对象软件的元素》仍然是理解设计模式时的事实参考。
设计模式通常包括以下元素:
-
问题陈述:问题陈述描述了我们想要解决的问题,因此也定义了我们可以使用的设计模式。问题陈述将告诉我们关于我们计划追求的设计范围,我们可能需要注意的约束,有时还会告诉我们应用程序中不同组件如何相互通信。
-
解决方案:解决方案描述了弥补问题的设计。它详细说明了类层次结构应该如何形成,对象将如何形成,对象之间的关系以及不同组件之间的通信将如何进行。解决方案将是一个抽象设计,不指定实现的细节。这使得解决方案通用,可以应用于一类问题,而不用关心应该使用什么算法来解决特定问题。
-
后果:在软件开发世界中,没有免费的东西。一切都有代价,我们为一件事情而牺牲另一件事情。重要的是权衡是否合理。同样适用于设计模式的选择,它们也有自己的后果。大多数情况下,这些后果是空间和时间的权衡,是评估替代选项的重要部分,如果特定的设计选择不能证明权衡成本的合理性。有时,后果也可能定义语言的实现障碍,并且通常会影响应用程序的可重用性和灵活性。
选择设计模式并不是每组问题都通用的事情。解决问题将基于多种因素,如开发人员对问题的解释,需要使用的编程语言的限制,与项目相关的截止日期等。
设计模式的分类
在书籍《设计模式:可重用面向对象软件的元素》中,设计模式被分类为三大类:
-
创建模式:这些模式定义了如何创建对象,以便您的代码可以独立于存在哪些对象,并因此使其与可能发生的新对象引入代码库的影响分离。这需要将对象创建逻辑与代码库隔离开来。Singleton 和 Factory 等模式属于创建模式类别。
-
结构模式:与创建模式不同,结构模式通常用于描述…
定义设计模式的选择
在选择设计模式时,我们可能希望设计模式具有一定的特征。让我们看看如果我们要使用 Python 来实现我们的设计模式,这些特征可能包括什么:
-
最小惊讶原则:Python 之禅说应该遵循最小惊讶原则。这意味着使用的设计模式在行为方面不应该让用户感到惊讶。
-
减少耦合:耦合被定义为软件内不同组件之间相互依赖的程度。具有高耦合度的软件可能很难维护,因为对一个组件的更改可能需要对许多其他组件进行更改。耦合作为一种影响无法完全从软件中移除,但应该选择设计模式,以便在开发过程中最小化耦合度。
-
专注于简单性:开始开发一个软件时,过于泛化的设计原则可能会带来更多的害处。它可能会在代码库中引入许多不需要的功能,这些功能很少被使用或根本不被使用。设计模式的选择应该更多地专注于为所述问题提供简单的解决方案,而不是专注于特定设计模式可以解决多少常见类型的问题。
-
避免重复:良好的设计模式选择将帮助开发人员避免重复代码逻辑,并将其保留在一个位置,系统的不同组件可以从该位置访问。逻辑重复的减少不仅可以节省开发时间,还可以使维护过程变得简单,其中逻辑的更改只需要在一个地方进行,而不是在代码库的多个部分进行。
面向对象的 Python
面向对象编程(OOP)指的是以一种不关心方法组织的格式组织代码,而是关心对象、它们的属性和行为。
一个对象可以代表任何逻辑实体,比如动物、车辆和家具,并且会包含描述它们的属性和行为。
面向对象编程语言的基本构建块是类,通常将逻辑相关的实体组合成一个单一的单元。当我们需要使用这个单元时,我们创建这个单元的一个新实例,称为类对象,并使用对象公开的接口来操作对象。
Python 中的面向对象编程…
基本的面向对象编程原则
一个语言不能仅仅因为它支持类和对象就被认为是面向对象的语言。该语言还需要支持一系列不同的功能,比如封装、多态、组合和继承,才能被认为是面向对象的语言。在这方面,Python 支持许多基于面向对象编程的概念,但由于其松散的类型特性,它的实现方式有些不同。让我们看看这些特性在 Python 中的区别。
封装
封装是一个术语,用来指代类限制对其成员的访问的能力,只能通过对象公开的接口来访问。封装的概念帮助我们只关注我们想要对对象做什么的细节,而不是对象如何处理内部的变化。
在 Python 中,封装并不是严格执行的,因为我们没有访问修饰符的支持,比如私有、公共和受保护,这些可以严格控制类内部特定成员的访问。
然而,Python 确实支持封装,借助名称修饰,可以用来限制对特定属性的直接访问…
组合
组合是用来表达不同对象之间关系的属性。在组合中表达这种关系的方式是将一个对象作为另一个对象的属性。
Python 通过允许程序员构建对象,然后将其作为其他对象的一部分来支持组合的概念。例如,让我们看下面的代码片段:
class MessageHandler:
__message_type = ['Error', 'Information', 'Warning', 'Debug']
def __init__(self, date_format):
self.date_format = date_format
def new_message(message, message_code, message_type='Information'):
if message_type not in self.__message_type:
raise Exception("Unable to handle the message type")
msg = "[{}] {}: {}".format(message_type, message_code, message)
return msg
class WatchDog:
def __init__(self, message_handler, debug=False):
self.message_handler = message_handler
self.debug = debug
def new_message(message, message_code, message_type):
try:
msg = self.message_handler.new_message(message, message_code, message_type)
except Exception:
print("Unable to handle the message type")
return msg
message_handler = MessageHandler('%Y-%m-%d')
watchdog = WatchDog(message_handler)
从例子中我们可以看到,我们已经将message_handler
对象作为watchdog
对象的属性。这标志着我们可以在 Python 中实现组合的一种方式。
继承
继承是我们创建对象层次结构的一种方式,从最一般的到最具体的。通常作为另一个类的基础的类也被称为基类,而继承自基类的类被称为子类。例如,如果一个类B
派生自类A
,那么我们会说类B
是类A
的子类。
就像 C++一样,Python 支持多重和多层继承的概念,但不支持在继承类时使用访问修饰符的概念,而 C++支持。
让我们看看如何在 Python 中实现继承,尝试模拟 BugZot 应用程序中的新请求将是什么样子。以下代码片段给出…
Python 中的多重继承
让我们看一个抽象的例子,展示了我们如何在 Python 中实现多重继承,可以在接下来的代码片段中看到:
class A:
def __init__(self):
print("Class A")
class B:
def __init__(self):
print("Class B")
class C(A,B):
def __init__(self):
print("Class C")
这个例子展示了我们如何在 Python 中实现多重继承。一个有趣的地方是要理解当我们使用多重继承时,Python 中的方法解析顺序是如何工作的。让我们来看看。
多重继承中的方法解析顺序
那么,基于前面的例子,如果我们创建一个C
类的对象会发生什么呢?
>>> Cobj = C()Class C
正如我们所看到的,这里只调用了派生类的构造函数。那么如果我们想要调用父类的构造函数呢?为此,我们需要在我们的类C
构造函数内部使用super()
调用。为了看到它的作用,让我们稍微修改一下C
的实现:
>>> class C(A,B):... def __init__(self):... print("C")... super().__init__()>>> Cobj = C()CA
一旦我们创建了派生类的对象,我们可以看到派生类的构造函数首先被调用,然后是第一个继承类的构造函数。super()
调用自动…
利用 mixin
Mixin 是每种面向对象语言中都存在的概念,可以用来实现可以在代码的不同位置重复使用的对象类。诸如 Django Web 框架之类的项目提供了许多预构建的 mixin,可以用于在我们为应用程序实现的自定义类中实现一定的功能集(例如,对象操作、表单渲染等)。
那么,mixin 是语言的一些特殊特性吗?答案是否定的,它们不是一些特殊特性,而是一些不打算成为独立对象的小类。相反,它们被构建为通过多重继承支持为类提供一些指定的额外功能。
回到我们的示例应用 BugZot,我们需要一种以 JSON 格式返回多个对象数据的方法。现在,我们有两个选择;我们可以在单个方法的级别构建返回 JSON 数据的功能,或者我们可以构建一个可以在多个类中重复使用的 mixin:
Import json
class JSONMixin:
def return_json(self, data):
try:
json_data = json.dumps(data)
except TypeError:
print("Unable to parse the data into JSON")
return json_data
现在,让我们想象一下,如果我们想要我们在尝试理解继承时在示例中实现的 bug 类。我们所需要做的就是在Bug
类中继承JSONMixin
:
class Bug(Request, JSONMixin):
…
通过简单地继承该类,我们就得到了所需的功能。
抽象基类
在面向对象编程中,抽象基类是那些只包含方法声明而不包含实现的类。这些类不应该有独立的对象,而是被构建为基类。从抽象基类派生的类需要为抽象类中声明的方法提供实现。
在 Python 中,虽然你可以通过不提供已声明方法的实现来构建抽象类,但语言本身并不强制派生类为方法提供实现。因此,如果在 Python 中执行以下示例,它将完美运行:
class AbstractUser: def return_data(self): passclass ...
元类
Python 提供了许多特性,其中一些直接对我们可见,例如列表推导、动态类型评估等,而另一些则不那么直接。在 Python 中,许多事情都可以被认为是魔术,是在幕后发生的。其中之一就是元类的概念。
在 Python 中,一切都是对象,无论是方法还是类。即使在 Python 内部,类也被认为是可以传递给方法、分配给变量等的一等对象。
但是,正如面向对象编程的概念所述,每个对象都表示一个类的实例。因此,如果我们的类是对象,那么它们也应该是某个类的实例。那么这个类是什么?这个问题的答案是type
类。Python 中的每个类都是type
类的实例。
这可以很容易地验证,如下面的代码片段所示:
class A:
def __init__(self):
print("Hello there from class A")
>>>isinstance(A, type)
True
这些对象是类的对象,被称为元类。
在 Python 中,我们不经常直接使用元类,因为大多数时候,我们试图通过其他简单的解决方案来解决元类的问题。但是元类确实为我们提供了很多创建类的方法。让我们首先看一下如何通过设计LoggerMeta
类来创建我们自己的元类,该类将强制实例类为不同以HANDLER_
为前缀的日志方法提供有效的处理程序方法:
class LoggerMeta(type):
def __init__(cls, name, base, dct):
for k in dct.keys():
if k.startswith('HANDLER_'):
if not callable(dct[k]):
raise AttributeError("{} is not callable".format(k))
super().__init__(name, base, dct)
def error_handler():
print("error")
def warning_handler():
print("warning")
class Log(metaclass=LoggerMeta):
HANDLER_ERROR = error_handler
HANDLER_WARN = warning_handler
HANDLER_INFO = 'info_handler'
def __init__(self):
print(“Logger class”)
在这个例子中,我们通过从 type 类继承来定义了一个名为LoggerMeta
的元类
。(为了定义任何元类,我们需要从 type 类或任何其他元类继承。继承的概念在元类
创建期间也适用。)一旦我们声明了我们的元类
,我们在元类
中提供了__init__
魔术方法的定义。元类的__init__
魔术方法接收类对象、要创建的新类的名称、新类将派生自的基类列表以及包含用于初始化新类的属性的字典。
在__init__
方法中,我们提供了一个实现,用于验证以HANDLER_
开头的类属性是否有有效的处理程序分配给它们。如果属性分配的处理程序不可调用,我们会引发AttributeError
并阻止类的创建。在__init__
方法的最后,我们返回基类__init__
方法的调用结果。
在下一个例子中,我们创建两个简单的方法,它们将充当我们处理错误类型消息和警告类型消息的处理程序。
在这个例子中,我们定义了一个元类为LoggerMeta
的类日志。这个类包含一些属性,比如HANDLER_ERROR
、HANDLER_WARN
、HANDLER_INFO
和魔术方法__init__
。
现在,让我们看看如果我们尝试执行提供的例子会发生什么:
python3 metaclass_example.py
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 6, in __init__
AttributeError: HANDLER_INFO is not callable
从输出中可以看出,一旦解释器解析了类日志的定义以创建类,元类__init__
方法就会被调用,验证类的属性并引发AttributeError
。
Python 中的元类为我们提供了很多强大的功能,并使我们能够以神奇的方式做很多事情,例如基于方法名称生成类属性,并跟踪类的实例化数量。
通过学习 Python 中的面向对象编程和元类的所有内容,现在让我们继续使用它们来实现 Python 中的一些设计模式,并学习如何决定使用哪种设计模式。
单例模式
单例模式是《设计模式》一书中四人帮之一的模式,它可以在应用程序中有各种用途,我们希望一个类在整个应用程序中只有一个实例。
单例模式强制一个类只能有一个实例,该实例将被应用程序中的任何组件/模块使用。当我们想要控制只使用一个对象来访问资源时,这种强制可以很有用。这种类型的资源可以是日志文件、数据库、崩溃处理机制等。
在大多数基于面向对象的语言中,要实现单例模式,第一步是将类构造函数设为私有,然后在类内部使用静态方法…
call 魔术方法
__call__
魔术方法在 Python 元类的上下文中是特殊的。与__init__
方法不同,__init__
方法在我们从元类创建新类时被调用,而__call__
方法在初始化类的对象时被调用。为了更好地理解这一点,让我们尝试运行以下示例:
class ExampleMeta(type):
def __init__(cls, name, bases, dct):
print("__init__ called")
return super().__init__(name, bases, dct)
def __call__(cls, *args, **kwargs):
print("__call__ called")
return super().__call__(*args, **kwargs)
class Example(metaclass=Example):
def __init__(self):
print("Example class")
__init__ called
>>> obj = Example()
__call__ called
从这个例子中,可以清楚地看出__init__
方法是在解释器完成基于元类
的类初始化后被调用的,而__call__
方法是在创建类的对象时被调用的。
现在,有了这个理解,让我们构建我们的数据库连接类,它将提供我们的数据库操作的支持。在这个例子中,我们只关注类的初始化部分,而将在后面的章节中提供完整的类实现细节。
现在,在bugzot
目录下,让我们创建一个名为database.py
的文件,其中将保存我们的数据库类:
from bugzot.meta import Singleton
class Database(metaclass=Singleton):
def __init__(self, hostname, port, username, password, dbname, **kwargs):
"""Initialize the databases
Initializes the database class, establishing a connection with the database and providing
the functionality to call the database.
:params hostname: The hostname on which the database server runs
:parms port: The port on which database is listening
:params username: The username to connect to database
:params password: The password to connect to the database
:params dbname: The name of the database to connect to
"""
self.uri = build_uri(hostname, port, username, password, dbname)
#self.db = connect_db()
self.db_opts = kwargs
#self.set_db_opts()
def connect_db(self):
"""Establish a connection with the database."""
pass
def set_db_opts(self):
"""Setup the database connection options."""
pass
在这个例子中,我们定义了一个数据库类,它将帮助我们建立与数据库的连接。这个类的不同之处在于,无论我们尝试创建这个类的新实例时,它总是返回相同的对象。例如,让我们看看如果我们创建这个相同类的两个不同对象会发生什么:
dbobj1 = Database("example.com", 5432, "joe", "changeme", "testdb")
dbobj2 = Database("example.com", 5432, "joe", "changeme", "testdb")
>>> dbobj1
<__main__.Database object at 0x7fb6d754a7b8>
>>> dbobj2
<__main__.Database object at 0x7fb6d754a7b8>
在这个例子中,我们可以看到,当我们尝试实例化该类的新对象时,返回的是数据库对象的相同实例。
现在,让我们来看看另一个有趣的模式,即工厂模式。
工厂模式
在开发大型应用程序时,有些情况下我们可能希望根据用户输入或其他动态因素动态初始化一个类。为了实现这一点,我们可以在类实例化期间初始化所有可能的对象,并根据环境输入返回所需的对象,或者可以完全推迟类对象的创建,直到收到输入为止。
工厂模式是后一种情况的解决方案,其中我们在类内部开发一个特殊的方法,负责根据环境输入动态初始化对象。
现在,让我们看看如何在 Python 中实现工厂模式…
模型-视图-控制器模式
让我们从一个图表开始讨论 MVC 模式:
该图表显示了使用 MVC 模式的应用程序中请求的流程。当用户发出新的请求时,应用程序拦截请求,然后将请求转发给适当的控制器处理该请求。一旦控制器接收到请求,它将与模型交互,根据其收到的请求执行一些业务逻辑。这可能涉及更新数据库或获取一些数据。一旦模型执行了业务逻辑,控制器执行视图并传递给视图需要显示请求的任何数据。
虽然我们将在本书的后面实现 MVC 模式,但在开发 BugZot 应用程序时,让我们来看看 MVC 模式中的不同组件以及它们扮演的角色。
控制器
控制器充当模型和视图之间的中介。当首次向应用程序发出请求时,控制器拦截请求,并根据此决定需要调用哪个模型和视图。一旦决定了这一点,控制器就执行模型来运行业务逻辑,从模型中检索数据。一旦检索到数据并且模型执行完成,控制器就执行视图,并使用从模型中收集的数据。一旦视图执行完成,用户就会看到视图的响应。
简而言之,控制器负责执行以下操作:
- 拦截应用程序发出的请求,并执行所需的…
模型
模型是应用程序的业务逻辑所在的地方。许多时候,开发人员会将模型与数据库混淆,这对于一些 Web 应用程序可能是正确的,但在一般情况下并非如此。
模型的作用是处理数据,提供对数据的访问,并在请求时允许修改。这包括从数据库或文件系统检索数据,向其中添加新数据,并在需要更新时修改现有数据。
模型不关心存储的数据应该如何呈现给用户或应用程序的其他组件,因此将呈现逻辑与业务逻辑解耦。模型也不经常更改其模式,并且在应用程序生命周期中基本保持一致。
因此,简而言之,模型负责执行以下角色:
-
提供访问应用程序中存储的数据的方法
-
将呈现逻辑与业务逻辑解耦
-
为存储在应用程序中的数据提供持久性
-
提供一致的接口来处理数据
视图
视图负责向用户呈现数据,或通过向用户呈现界面来操作模型中存储的数据。MVC 中的视图通常是动态的,并根据模型中发生的更改频繁变化。视图也可以被认为仅包含应用程序的呈现逻辑,而不考虑应用程序将如何存储数据以及如何检索数据。通常,视图可以用于缓存呈现状态,以加速数据的显示。
因此,简而言之,以下是视图执行的功能:
-
为应用程序提供呈现逻辑,以显示应用程序中存储的数据
-
为用户提供…
摘要
在本章中,我们讨论了设计模式的概念以及它们如何帮助我们解决设计应用程序中常遇到的一些问题。然后,我们讨论了如何决定使用哪种设计模式,以及是否有必要选择已经定义的模式之一。在本章的进一步探讨中,我们探索了 Python 作为一种语言的一些面向对象的能力,并且还探讨了在 Python 中实现抽象类和元类的一些示例,以及我们如何使用它们来构建其他类并修改它们的行为。
在掌握了面向对象的 Python 知识后,我们继续在 Python 中实现一些常见的设计模式,如单例模式和工厂模式,并探索了 MVC 模式,了解它们试图解决的问题。
现在我们掌握了设计模式的知识,是时候了解如何使我们应用程序内部处理数据的过程更加高效了。下一章将带领我们探索不同的技术,帮助我们有效地处理应用程序内部将发生的数据库操作。
问题
-
我们如何在 Python 中实现责任链模式,以及它可以使用的一些可能用例是什么?
-
__new__
方法和__init__
方法之间有什么区别? -
我们如何使用 ABCMeta 类作为抽象类的元类来实现抽象类?
第三章:构建大规模数据库操作
在企业软件开发领域,开发人员一直在构建处理大量数据的应用程序。在计算机的早期,系统通常跨越比我们目前居住的房间还要大的空间,数据存储在平面文件格式中,而今天,系统已经缩小到以前存放单个系统的相同大小的房间中,我们现在可以运行成千上万个系统,每个系统都与其他系统协调,为我们提供可以以光速处理数据的机器。随着时间的推移,数据存储的方式也从使用平面文件发展到了复杂的数据库管理系统。
随着企业规模的增长和由新兴领域带来的不断扩大的业务,企业应用程序需要处理的数据量也在增长,这使得了解如何构建我们的应用程序以处理大规模数据库相关操作变得重要。虽然构建大规模数据库操作永远不可能是一种适合所有情况的解决方案,但我们将涵盖一些常见的构建应用程序的要点,这些应用程序可以轻松扩展以处理数据增加、模式修改的要求、应用程序复杂性的增加等。
尽管有多种类型的数据库,如 SQL、NoSQL 和图形数据库,可以用来存储应用程序数据,取决于企业所需的应用程序类型,本章重点关注使用 SQL 的关系数据库管理系统,因为它们非常流行,并且能够处理大量的用例。
通过本章结束时,您将学到以下内容:
-
使用对象关系映射器(ORMs)及其提供的好处
-
为了提高效率和便于修改,构建数据库模型
-
专注于维护数据库一致性
-
急切加载和延迟加载之间的区别
-
利用缓存加速查询
技术要求
本书中的代码清单可以在github.com/PacktPublishing/Hands-On-Enterprise-Application-Development-with-Python
的chapter03
目录下找到
可以通过运行以下命令克隆代码示例:
git clone https://github.com/PacktPublishing/Hands-On-Enterprise-Application-Development-with-Python
本章提供的代码示例需要您在系统上安装和配置以下系统包:
-
python-devel
-
PostgreSQL
-
Python -
virtualenv
除了这三个包,您还需要sqlalchemy
包,它提供了我们在整个章节中将使用的 ORM,以及psycopg2
,它提供了postgres
数据库绑定,以允许sqlalchemy ...
数据库和对象关系映射器
正如我们在前几章中讨论的,Python 为我们提供了许多面向对象的能力,并允许我们以类和对象的术语来映射我们的用例。现在,当我们可以将我们的问题集映射到一个类及其对象时,为什么我们不应该将我们的数据库表也映射为对象,其中一个特定的类代表一个表,它的对象代表表中的行。沿着这条路走,不仅有助于我们维护我们编写代码的一致性,还有助于我们建模我们的问题。
提供了通过它们可以将我们的数据库映射到对象的功能的框架被称为 ORMs,它们帮助我们将我们的数据库可视化为一组类和对象。
在 Python 领域中,看到 ORMs 是非常常见的。例如,流行的 Python Web 框架 Django 提供了自己的 ORM 解决方案。然后,还有 SQLAlchemy,它提供了一个完整的 ORM 解决方案和支持各种关系数据库的数据库工具包。
但是,要说服开发人员使用 ORM 框架,应该有比仅仅说它们能够将数据库映射到类和对象,并为您提供面向对象的接口来访问数据库更好的优势。让我们看看 ORM 的使用带来了哪些优势:
-
抽象出特定供应商的 SQL:关系数据库领域充满了选择,有几家公司在推广他们的产品。这些产品中的每一个都可以在如何通过使用 SQL 实现某个功能上有所不同。有时,一些数据库可能实现了一些尚未在其他数据库中支持的 SQL 关键字。对于开发人员来说,如果他们需要支持具有不连贯功能集的多个数据库,这可能会成为一个问题。由于 ORM 已经知道如何处理这些数据库的差异,它们帮助开发人员减轻了支持多个数据库的问题。大多数情况下,使用 ORM 时,开发人员所需要做的就是修改数据库连接的统一资源标识符(URI),然后他们就可以准备在应用程序中使用新的数据库了。
-
减少重复 SQL 的需求:在编写应用程序时,有很多地方需要使用类似的查询从相同的表中检索数据。这将导致很多重复的 SQL 代码被写入很多地方,不仅导致很多格式不佳的代码,还会因为 SQL 查询构造不当而导致错误的出现(人类在做重复工作时很容易失去注意力,开发人员也会这样吗?)。ORM 解决方案通过提供对 SQL 命令的抽象和根据我们调用不同方法动态生成 SQL 来减少编写 SQL 以实现相同结果的需求。
-
增加应用程序的可维护性:由于 ORM 允许您一次定义数据库模型并通过实例化类在整个应用程序中重用它,它允许您在一个地方进行更改,然后在整个应用程序中反映出来。这使得维护应用程序的任务变得稍微不那么繁琐(至少与处理数据库相关的部分)。
-
提高生产力:这本身不是一个特性,而是前面提到的点的副作用。使用 ORM 解决方案,开发人员现在不再那么担心始终考虑 SQL 查询,或者试图遵循特定的设计模式。他们现在可以专注于如何最好地设计他们的应用程序。这显著提高了开发人员的生产力,并允许他们完成更多工作并提高时间的利用率。
在这一章中,我们将专注于如何利用 ORM 来最好地开发我们的企业应用程序,以便它们可以轻松地与数据库交互并高效地处理大规模的数据库操作。为了保持本章简单,我们将坚持使用 SQLAlchemy,它将自己作为一个 SQL 工具包,并为 Python 提供了一个 ORM 解决方案,并为 Python 领域中的不同框架提供了许多绑定。它被一些相当大规模的项目使用,如 OpenStack,Fedora 项目和 Reddit。
设置 SQLAlchemy
在我们深入研究如何为应用程序创建最佳的数据库模型以促进高效的大规模数据库操作之前,我们首先需要设置我们的 ORM 解决方案。由于我们将在这里使用 SQLAlchemy,让我们看看如何在开发环境中设置它。
为了使 SQLAlchemy 工作,你应该有一个数据库管理系统设置,可以是在你的系统上或远程机器上,你可以连接到它。一个暴露端口的容器也可以为我们完成工作。为了保持示例简单,我们假设读者在这里使用 PostgreSQL 作为他们的数据库解决方案,并且了解 PostgreSQL 设置的工作原理。现在,让我们看看如何设置 SQLAlchemy:
mkdir ch3 && cd ch3 ...
构建最佳数据库模型
实现对数据库的任何有效访问的第一步是为数据库构建一个最佳模型。如果一个模型不是最佳的,那么加速对数据库的访问的其他技术将几乎没有什么区别。
但在我们深入研究如何为数据库构建最佳模型之前,让我们首先看看如何实际使用 SQLAlchemy 为我们的数据库构建任何模型。
举个例子,假设我们想要构建一个模型来代表我们的 BugZot 应用程序中的用户。在我们的 BugZot 应用程序中,用户将需要提供以下字段:
-
名字和姓氏
-
用户名
-
电子邮件地址
-
密码
此外,我们的 BugZot 应用程序还需要维护有关用户的一些其他信息,例如他们在系统中的会员级别,用户有权利的特权,用户帐户是否处于活动状态,以及发送给用户激活他们帐户的激活密钥。
现在,让我们看看如果我们尝试使用 SQLAlchemy 来满足这些要求建立用户表会发生什么。以下代码描述了我们如何在 SQLAlchemy 中构建用户模型:
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Boolean, Date, Integer, String, Column
from datetime import datetime
# Initialize the declarative base model
Base = declarative_base()
# Construct our User model
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True, autoincrement=True)
first_name = Column(String, nullable=False)
last_name = Column(String, nullable=False)
username = Column(String(length=25), unique=True, nullable=False)
email = Column(String(length=255), unique=True, nullable=False)
password = Column(String(length=255), nullable=False)
date_joined = Column(Date, default=datetime.now())
user_role = Column(String, nullable=False)
user_role_permissions = Column(Integer, nullable=False)
account_active = Column(Boolean, default=False)
activation_key = Column(String(length=32))
def __repr__(self):
return "<User {}>".format(self.username)
这个例子展示了我们如何使用 SQLAlchemy 构建模型。现在,让我们看看我们在代码示例中做了什么。
在代码示例的开始部分,我们首先导入了declarative_base
方法,该方法负责为我们的模型提供基类。
Base = declarative_base()
行将基本模型分配给我们的基本变量。
接下来我们做的事情是包括来自 SQLAlchemy 的不同数据类型,这些数据类型将在我们的模型定义中使用。
最后的导入导入了我们将在数据库模型中使用的 Python datetime
库。
现在,不考虑我们的代码将如何填充数据库模型的不同字段,让我们看看我们是如何设计我们的用户模型的。
设计模型的第一步是定义一个作为我们模型类的用户类。这个类派生自我们在代码中之前初始化的基本模型。
__tablename__ = 'users'
行定义了当这个数据库模型在数据库中实现时应该给表的名称。
接着,我们开始定义表将包含的列。为了定义列,我们使用key=value
的方式,其中 key 定义了列的名称,value 定义了列的属性。
例如,要定义 id 列,它应该是整数类型,并且应该作为用户表的主键,我们这样定义:
id = Column(Integer, primary_key=True, autoincrement=True)
我们现在可以看到它是多么简单。我们不需要编写任何 SQL 来定义我们的列。同样,通过只传递unique=True
和nullable=False
参数给列构造函数,就可以很容易地强制一个特定字段应该具有唯一值,并且不能有 null 值,可以从以下行作为例子:
username = Column(String(length=25), unique=True, nullable=False)
在我们定义了所有的列之后,我们提供了__repr__
方法的定义。__repr__
方法是一个魔术方法,它由内部的repr()
Python 方法调用,以提供对象的表示,比如当用户发出print(userobj)
时。
这样就完成了我们使用 SQLAlchemy 定义用户模型的定义。很简单,不是吗?我们不需要编写任何 SQL;我们只需快速地将列添加到一个类中,然后让 SQLAlchemy 处理其他所有事情。现在,虽然所有这些都很有趣且容易实现,但我们犯了一些错误,现在似乎没有造成任何伤害,但随着我们的应用规模扩大,这些错误将会变得代价高昂。让我们来看看这些错误。
我们模型定义的问题
虽然 SQLAlchemy 为我们提供了很多抽象来轻松定义用户模型,但它也让我们很容易犯一些错误,一旦应用规模扩大并且企业增长,这些错误就会变得代价高昂。让我们来看看我们在定义这个模型时犯了一些错误:
- 易受变化影响:我们当前的用户模型定义使得一旦应用规模扩大,对模型进行更改变得非常困难。让我们举个例子,假设组织决定在错误报告上为用户提供更多权限。在 SQL 方面,为了实现这个效果,我们需要编写一个查询,遍历所有记录并具有
user_role
作为用户…
优化我们的模型
在讨论如何构建最佳模型之前,我们首先需要了解最佳模型应具备的特征。让我们来看看以下内容:
-
易于调整:一个优化的模型应该根据应用程序不断增长的需求变化而容易调整。这意味着更改特定模型不应该需要在整个应用程序中进行更改,并且应该具有高内聚性。
-
最大化主机吞吐量:每个主机都有不同的架构,数据模型应该能够利用底层主机资源,以最大化吞吐量。这可以通过使用特定架构和用例的正确数据存储引擎,或者在一组机器上运行数据库以增加并行执行能力来实现。
-
高效存储:数据库模型还应考虑到随着存储在其中的数据增长,可能使用的存储空间。这可以通过仔细选择数据类型来实现。例如,仅表示一个只能有两个值(true 或 false)的列,使用整数类型会浪费大量磁盘空间,随着数据库中记录的数量增加。对于这样的列,名义数据类型可以是布尔型,它在内部不占用太多空间。
-
易于调整:一个高效的模型将谨慎地为可以加速对特定表的查询处理的列建立索引。这将导致数据库的响应时间得到改善,并且用户不会因为应用程序从数据库返回 10,000 条记录需要长达 20 分钟而感到沮丧。
为了实现这些目标,我们现在需要简化我们的模型,并使用关系数据库提供的关系概念。现在让我们开始重构我们的用户模型,使其更加优化。
为了实现这一点,首先我们需要将一个大模型分解为多个小模型,在我们的代码库中独立存在,并且不要将所有东西耦合得太紧。让我们开始吧。
我们要移出模型的第一件事是如何处理角色和权限。由于角色及其权限不会在用户之间有太大的差异(肯定不是每个用户都会有一个唯一的角色,也不是每个角色都可以有不同的权限集),我们可以将这些字段移动到另一个模型,称为权限。以下代码说明了这一点:
class Role(Base):
__tablename__ = 'roles'
id = Column(Integer, primary_key=True, autoincrement=True)
role_name = Column(String(length=25), nullable=False, unique=True)
role_permissions = Column(Integer, nullable=False)
def __repr__(self):
return "<Role {}>".format(role_name)
现在,我们已经将角色与用户模型解耦。这使我们可以轻松地对提供的角色进行修改,而不会引起太多问题。这些修改可能包括重命名角色或更改现有角色的权限。我们只需要在一个地方进行修改,就可以反映到所有具有相同角色的用户身上。让我们看看如何在我们的用户模型中利用关系数据库管理系统(RDBMS)中的关系来做到这一点。
以下代码示例显示了如何实现角色模型和用户模型之间的关系:
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True, autoincrement=True)
first_name = Column(String, nullable=False)
last_name = Column(String, nullable=False)
username = Column(String(length=25), unique=True, nullable=False)
email = Column(String(length=255), unique=True, nullable=False)
password = Column(String(length=255), nullable=False)
date_joined = Column(Date, default=datetime.now())
user_role = Column(Integer, ForeignKey("roles.id"))
account_active = Column(Boolean, default=False)
activation_key = Column(String(length=32))
def __repr__(self):
return "<User {}>".format(self.username)
在这个代码示例中,我们将user_role
修改为整数,并存储在roles
模型中存在的值。任何尝试向这个字段插入不在 roles 模型中的值的操作都会引发 SQL 异常,表示不允许该操作。
现在,继续使用同一个例子,让我们考虑用户模型的activation_key
列。一旦用户激活了他们的账户,我们可能就不再需要激活密钥。这为我们提供了在用户模型中进行一次优化的机会。我们可以将这个激活密钥从用户模型中移出,并存储在一个单独的模型中。一旦用户成功激活了他们的账户,记录就可以被安全地删除,而不会有用户模型被修改的风险。因此,让我们开发激活密钥的模型。以下代码示例说明了我们想要做的事情:
class ActivationKey(Base):
__tablename__ = 'activation_keys'
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"))
activation_key = Column(String(length=32), nullable=False)
def __repr__(self):
return "<ActivationKey {}>".format(self.id)
在这个例子中,我们实现了ActivationKey
模型。由于每个激活密钥都属于唯一的用户,我们需要存储哪个用户拥有哪个激活密钥。我们通过向用户模型的id
字段引入外键来实现这一点。
现在,我们可以安全地从用户模型中移除activation_key
列,而不会引起任何麻烦。
利用索引
索引是一种可以在适合建立索引的字段上提供大量性能优势的东西。但是,如果被索引的列没有经过慎重选择,索引也可能毫无用处,甚至会损害数据库的性能。例如,在表中索引每一列可能不会带来任何优势,而且会不必要地占用磁盘空间,同时使数据库操作变慢。
因此,在我们以一个例子来介绍的 ORM 中如何对特定字段建立索引之前,让我们首先澄清在数据库上下文中索引到底是什么(不深入探讨它们的工作原理),这个数据结构…
保持数据库一致性
数据库通常在应用程序部署后的整个生命周期中并行进行大量操作。这些操作可以是从数据库中检索信息,也可以是修改数据库状态的操作,比如插入新记录、更新现有记录或删除其他记录。目前大型组织生产中使用的大多数数据库都具有相当多的弹性,可以处理环境中可能发生的错误和崩溃,以防止数据损坏和停机。
但这并不能完全解除应用程序开发人员对数据库内数据一致性的关注。让我们试着理解这种情况。
在企业级应用程序中,任何给定时间点都会有许多数据库查询并行运行。这些查询来自于许多用户使用的应用程序或内部应用程序维护作业。其中一个主要的事实是,并非所有的查询都能成功执行。这可能是由于多种原因,比如查询中的数据不符合模式,为列值提供了不正确的数据类型,以及违反约束。当这种情况发生时,数据库引擎会阻止查询执行并返回查询错误。这是完全可以接受的,因为我们不正确的查询没有对数据库进行任何不正确的更改。但是当这个查询是一系列操作的一部分,用于在数据库中创建一个新资源时,情况就变得棘手了。现在我们需要确保在失败的查询之前由其他查询所做的更改被恢复。
这种行为仍然可以通过应用程序的开发人员通过跟踪 SQL 查询并在事情变得混乱时手动恢复它们的更改来解决。
但是,如果数据库引擎由于执行查询时发生错误而崩溃。现在我们处于一个无法预测数据库状态的情况下,处理这种情况可能会变得非常繁琐,并且可能会成为一个长时间阻碍整个组织运营的任务,直到数据库一致性得到验证。那么,我们能做些什么?有没有办法可以防止这些问题的出现?答案是肯定的。让我们来看看。
利用事务来维护一致性
关系数据库中的事务为我们提供了解决刚才讨论的问题的能力。在关系数据库方面,事务可以被认为是由多个数据库查询组成的信封,这些查询要么作为一个任务执行,要么在任何一个失败时完全恢复。我们还可以将事务视为数据库操作的原子单位,在这里,即使一个失败也会恢复整个事务。但是,这难道不正是我们需要解决数据库一致性问题的吗?
现在,让我们看看我们的 ORM 解决方案如何帮助我们实现事务支持。
为了理解这一点,让我们举个例子。我们的 BugZot…
理解延迟加载与急切加载
当我们查询从数据库加载数据时,这个操作可能会定义我们构建的应用程序的响应时间。这主要发生在需要加载大量数据并且应用程序等待数据库将所有这些行和列返回给它时。
这样的操作可能需要一些时间,从几毫秒到超过 10 秒,这取决于从数据库查询多少数据。这里的问题是,我们能否优化这一点以改善我们应用程序的响应时间?
这个问题的答案在于使用 SQL 关系和 ORM 层加载技术。虽然关系可以帮助我们定义两个模型之间的关系,加载技术定义了 ORM 如何检索关系。当需要加载大量数据时,这可以证明是非常有帮助的,不仅提供了一个机制,通过这个机制我们可以推迟加载关系数据直到需要它们,而且还可以在应用程序的内存占用方面节省相当多的空间。所以,让我们来看看这些技术。
使用关系
有了关系数据库管理系统的支持,我们现在可以定义两个模型之间的关系。数据库支持对两个模型之间不同类型的关系进行建模,例如:
-
一对一关系:这是一种关系,其中一个模型的记录只与另一个模型的一个记录相关联。例如,我们的用户模型中的用户只有一个激活密钥与我们的 ActivationKey 模型相关联。这是一种一对一关系。
-
一对多关系:这是一种关系,其中一个模型的记录映射到另一个模型的多个记录。例如,如果我们有一个描述 bug 条目的 Bug 模型,那么我们可以说,一个用户…
延迟加载
许多 ORM 层以及 SQLAlchemy 都试图尽可能地延迟数据加载。通常情况下,只有在应用程序实际访问对象时才会加载数据。这种延迟加载数据直到尝试访问数据的技术被称为延迟加载。
这种技术对于减少应用程序的响应时间非常有帮助,因为整个数据不是一次性加载的,而是按需加载的。这种优化是以运行更多的 SQL 查询为代价的,这些查询将在请求时检索实际数据。但是有没有一种方法可以明确控制这种技术呢?
对于每个 ORM 解决方案,答案都会有所不同,但其中很多实际上允许您启用或禁用延迟加载行为。那么,在 SQLAlchemy 中如何控制这一点呢?
看一下我们在上一节中对用户模型的修改,我们可以通过在我们的角色字段中添加一个额外的属性来明确告诉 SQLAlchemy 从我们的角色模型中延迟加载数据,如下面的片段所示:
role = relationship("Role", lazy_load='select')
这个额外的lazy_load
属性定义了 SQLAlchemy 用来从我们的角色模型加载数据的技术。下面的例子展示了在延迟加载期间请求的流程:
>>> Session = sessionmaker(bind=engine)
>>> db_session = Session()
>>> user_record = db_session.query(User).first()
INFO sqlalchemy.engine.base.Engine SELECT users.username AS users_username, users.id AS users_id, users.role_id AS users_role_id
FROM users
LIMIT %(param_1)s
INFO sqlalchemy.engine.base.Engine {'param_1': 1}
>>> role = user_record.role
INFO sqlalchemy.engine.base.Engine SELECT roles.id AS roles_id, roles.role_name AS roles_role_name, roles.role_permissions AS roles_role_permissions
FROM roles
WHERE roles.id = %(param_1)s
INFO sqlalchemy.engine.base.Engine {'param_1': 1}
从这个例子中可以看出,SQLAlchemy 在我们尝试访问角色模型的数据之前并不尝试加载角色模型的数据。一旦我们尝试访问角色模型的数据,SQLAlchemy 就会向数据库发出SELECT
查询,获取结果并返回填充的对象,然后我们现在可以使用它。
与按需加载数据的技术相反,我们也可以要求 SQLAlchemy 在第一次请求时加载所有数据。这可以节省我们等待应用程序等待 ORM 层按需从数据库获取数据的几毫秒时间。
这种技术被称为急切加载,我们将在接下来的部分中解释。
急切加载
有时我们希望加载我们想要的对象的数据以及我们的对象映射到的关系的数据。这是一个有效的用例,比如当开发人员确信他们将访问关系的数据时,无论情况如何。
在这些用例中,没有必要浪费时间,而 ORM 层会按需加载关系。这种加载对象数据以及与我们的主对象相关的关联对象的数据的技术被称为急切加载。
SQLAlchemy 提供了一种简单的方法来实现这种行为。还记得我们在上一节中指定的lazy_load
属性吗?是的,这就是你需要从延迟加载行为切换到急切加载的全部内容…
优化数据加载
我们可以为应用程序的性能提供的一种提升是优化它从数据库加载数据的方式。这并不是一件复杂的事情,ORM 解决方案使得这一切变得更加简单。
优化数据加载只有几条规则。因此,让我们看看这些规则是什么,以及它们如何能够证明有利:
-
推迟加载可以跳过的数据:当我们知道我们不需要从数据库中获取的所有数据时,我们可以安全地推迟加载该数据,利用延迟加载技术。例如,如果我们想要向我们的 BugZot 应用程序的所有用户发送邮件,这些用户有超过 10 个未解决的 bug,并且不是管理员,我们可以推迟加载角色的关系。考虑到一个有很多用户的大型数据库,这可以帮助显著减少应用程序的响应时间,以及整体内存占用,而只需付出一些额外的查询,这可能是一个可取的权衡。
-
如果数据将被使用,则尽早加载:与第一点完全相反,如果我们知道应用程序将使用数据,无论情况如何,那么一次性加载它而不是发出额外的查询来按需加载数据是完全有道理的。例如,如果我们想要将所有管理员提升为超级管理员,我们知道我们将访问所有用户的角色字段。那么,让应用程序懒加载角色字段就没有意义。我们可以简单地要求应用程序急切地加载所需的数据,以便应用程序不必等待数据按需加载。这种优化会增加内存使用量和初始响应时间,但一旦所有数据加载完毕,就会提供快速执行的优势。
-
不加载不需要的数据:有时对象映射的一些关系在处理过程中根本不需要。在这种情况下,我们可以通过简单地设置
lazy_load='noload'
来节省大量内存和时间,从而根本不加载这些关系对象。在 SQLAlchemy 中可以很容易地实现这一点。一个这样的用例是当我们只想要更新数据库中用户的last_active
时间时,不需要加载关系。在这种情况下,我们知道我们不需要验证与用户角色相关的任何内容,因此我们可以完全跳过加载角色。
如果加载技术完全嵌入在模型定义中,显然无法实现这些效果。因此,SQLAlchemy 确实提供了另一种通过使用不同方法来实现这些效果的方式,这些方法根据它们从数据库加载数据的技术命名,例如,lazyload()
用于延迟加载,joinedload()
用于连接急切加载,subqueryload()
用于子查询急切加载,noload()
用于不加载,我们将在后面的章节中解释它们,包括它们如何在实际应用程序中使用。
现在我们熟悉了加载技术以及如何利用它们的优势,现在让我们来看看本章的最后一个主题之一,我们将看到如何利用缓存来加快应用程序的响应时间,以及节省一遍又一遍地查询数据库的工作,这在应用程序执行大量数据密集型操作时确实会帮助我们。
利用缓存
在大多数企业应用程序中,一旦访问过的数据就会被再次使用。这可能是在不同的请求中,也可能是因为请求正在操作相同的数据集。
在这些情况下,如果我们试图一遍又一遍地从数据库中再次访问相同的数据,这将是一种巨大的资源浪费,导致应用程序向数据库发出大量查询,导致数据库负载高,响应时间差。
我们使用的 ORM 层提供了一定程度的缓存以访问过的数据,但是,大部分控制权仍然掌握在应用程序开发人员手中,他可以通过分析哪些数据将一遍又一遍地使用来使应用程序性能良好。
在数据库级别进行缓存
数据库是相当复杂的软件。它们不仅能够高效地存储我们的数据,还能够以同样的效率提供检索数据的机制。这背后涉及了许多复杂的逻辑。
使用 ORM 的优势之一是数据库可以在查询级别执行缓存。由于数据库应该以最快的方式返回数据,数据库系统通常会缓存反复执行的查询。这种缓存发生在查询解析级别,因此当在数据库上执行相同的查询时,可以通过不再解析相同的查询来节省一些时间。
这种缓存可以提高响应时间,因为保存了大量解析查询的工作。
块级缓存
现在,让我们来看一下我们可以在应用程序级别使用的缓存类型,这可能会提供重要帮助。
要理解应用程序块级缓存的概念,让我们看一下以下简单的代码片段:
for name in ['super_admin', 'admin', 'user']: if db_session.query(User).first().role.role_name == name: print("True")
从我们可以假设的情况来看,这可能已经查询了一次,然后从数据库中检索了数据,然后将一遍又一遍地使用它来与名称变量进行比较。但让我们来看一下前面代码的输出:
INFO sqlalchemy.engine.base.Engine SELECT users.username AS users_username, users.id AS users_id, users.role_id AS ...
使用用户级缓存
用户级缓存是另一种可以证明非常有用的缓存级别。想象一下,每次用户从一个页面移动到另一个页面时都从数据库查询用户的个人详细信息。这不仅效率低下,而且在高负载情况下会受到惩罚,当数据库的响应时间非常高时,请求可能会超时,用户将无法登录到应用程序,直到整体负载减少。
那么,有什么可以在这里帮助的吗?
答案是用户级缓存。当我们知道某些数据是特定于用户且对安全性不重要时,我们可以简单地从数据库中加载一次并将其保存在用户端。这可以通过实现 cookie 或在客户端创建临时文件来实现。这些 cookie 或临时文件存储有关用户的非机密数据,例如用户 ID 或用户名,或其他不重要的数据,例如用户的姓名。
每当应用程序想要加载这些数据时,它首先检查用户是否在其端有这些数据可用。如果找到数据,则从那里加载数据。如果在用户端找不到数据,则向数据库发出请求,然后从那里加载数据,最后在客户端缓存。
这种技术在试图减少特定于用户的数据加载的影响时非常有帮助,并且不需要经常从数据库刷新。
通过使用键值缓存机制,还有更复杂的缓存数据的技术,我们将在后面的章节中看到,比如使用诸如 memcached 之类的工具来实现内存缓存,这在处理大量数据时可能会非常有帮助。然而,由于涉及的主题复杂性可能涵盖数百页,这超出了本书的范围。
总结
在本章中,我们学习了如何构建数据库模型,以帮助我们在处理大规模数据时使应用程序性能更高。我们看到优化模型可以是优化的第一阶段,它可以帮助我们使应用程序更易于维护,通过减少数据库模型之间的耦合。然后,我们继续讨论索引如何有助于通过对更频繁访问的列进行索引来加快访问数据库内部数据。
后来,我们讨论了通过使用事务来维护数据库一致性的重要方面之一。
本章的最后部分涵盖了数据加载技术,如延迟加载、急切加载和无加载,…
问题
-
数据库表规范化的好处是什么?
-
通过
select
和通过joined
进行延迟加载有什么区别? -
在运行数据库更新查询时,我们如何保持数据的完整性?
-
从数据库缓存数据的不同级别是什么?
第四章:处理并发
正如我们在上一章中看到的,当处理任何大型企业应用程序时,我们会处理大量数据。这些数据以同步方式处理,并且只有在特定进程的数据处理完成后才发送结果。当处理的数据不大时,这种模型是完全可以接受的。但是考虑一种情况,需要在生成响应之前处理大量数据。那么会发生什么?答案是,应用程序响应时间变慢。
我们需要一个更好的解决方案。一种允许我们并行处理数据,从而获得更快应用程序响应的解决方案。但是我们如何实现这一点呢?问题的答案是并发…
技术要求
本书中的代码清单可以在github.com/PacktPublishing/Hands-On-Enterprise-Application-Development-with-Python
的chapter04
目录下找到。
可以通过运行以下命令克隆代码示例:
git clone https://github.com/PacktPublishing/Hands-On-Enterprise-Application-Development-with-Python
本章中提到的代码示例需要运行 Python 3.6 及以上版本。虚拟环境是将依赖项与系统隔离的首选选项。
并发的需求
大多数情况下,当我们构建相当简单的应用程序时,我们不需要并发。简单的顺序编程就可以很好地工作,一个步骤在另一个步骤完成后执行。但随着应用程序用例变得越来越复杂,并且有越来越多的任务可以轻松地推入后台以改善应用程序的用户体验,我们最终围绕并发的概念展开。
并发本身就是一个不同的东西,并且使编程任务变得更加复杂。但是,尽管增加了复杂性,但并发也带来了许多功能,以改善应用程序的用户体验。
在我们深入讨论为什么我们…
GUI 应用程序中的并发
我们已经习惯使用的硬件每年都变得越来越强大。如今,即使是我们智能手机内部的 CPU 也具有四核或八核的配置。这些配置允许并行运行多个进程或线程。不利用并发的硬件改进将是对之前提到的硬件改进的浪费。如今,当我们在智能手机上打开应用程序时,大多数应用程序都有两个或更多个线程在运行,尽管我们大部分时间都不知道。
让我们考虑一个相当简单的例子,在我们的设备上打开一个照片库应用程序。当我们打开照片库时,一个应用程序进程就会启动。这个进程负责加载应用程序的 GUI。GUI 在主线程中运行,允许我们与应用程序进行交互。现在,这个应用程序还会生成另一个后台线程,负责遍历操作系统的文件系统并加载照片的缩略图。从文件系统加载缩略图可能是一个繁琐的任务,并且可能需要一些时间,这取决于需要加载多少缩略图。
尽管我们注意到缩略图正在慢慢加载,但在整个过程中,我们的应用程序 GUI 仍然保持响应,并且我们可以与之交互,查看进度等。所有这些都是通过并发编程实现的。
想象一下,如果这里没有使用并发。应用程序将在主线程中加载缩略图。这将导致 GUI 在主线程完成加载缩略图之前变得无响应。这不仅会非常不直观,而且还会导致糟糕的用户体验,而我们通过并发编程避免了这种情况。
现在我们已经对并发编程如何证明其巨大用处有了一个大致的了解,让我们看看它如何帮助我们设计和开发企业应用程序,以及它可以实现什么。
企业应用程序中的并发
企业应用程序通常很大,通常涉及大量用户发起的操作,如数据检索、更新等。现在,让我们以我们的 BugZot 应用程序为例,用户可能会在提交错误报告时附上图形附件。这在提交可能影响应用程序 UI 或在 UI 上显示错误的错误时是一个常见的过程。现在,每个用户可能会提交图像,这些图像可能在质量上有所不同,因此它们的大小可能会有所不同。这可能涉及到非常小的尺寸的图像和尺寸非常大且分辨率很高的图像。作为应用程序开发人员,您可能知道以 100%质量存储图像可能会…
使用 Python 进行并发编程
Python 提供了多种实现并行或并发的方法。所有这些方法都有各自的优缺点,在实现方式上有根本的不同,需要根据使用情况做出选择。
Python 提供的实现并发的方法之一是在线程级别上进行,允许应用程序启动多个线程,每个线程执行一个任务。这些线程提供了一种易于使用的并发机制,并在单个 Python 解释器进程内执行,因此非常轻量级。
另一种实现并行的机制是通过使用多个进程代替多个线程。通过这种方法,每个进程在其自己的独立 Python 解释器进程内执行一个单独的任务。这种方法为多线程 Python 程序在全局解释器锁(GIL)存在的情况下可能面临的问题提供了一些解决方法,但也可能增加管理多个进程和增加内存使用量的额外开销。
因此,让我们首先看看如何使用线程实现并发,并讨论它们所附带的好处和缺点。
多线程的并发
在大多数现代处理器系统中,多线程的使用是司空见惯的。随着 CPU 配备多个核心和诸如超线程等技术的出现,允许单个核心同时运行多个线程,应用程序开发人员不会浪费任何一个利用这些技术提供的优势的机会。
作为一种编程语言,Python 通过使用线程模块支持多线程的实现,允许开发人员在应用程序中利用线程级别的并行性。
以下示例展示了如何使用 Python 中的线程模块构建一个简单的程序:
# simple_multithreading.pyimport threadingclass SimpleThread(threading.Thread): ...
线程同步
正如我们在前一节中探讨的,虽然在 Python 中可以很容易地实现线程,但它们也有自己的陷阱,需要在编写面向生产用例的应用程序时予以注意。如果在应用程序开发时不注意这些陷阱,它们将产生难以调试的行为,这是并发程序所以闻名的。
因此,让我们试着找出如何解决前一节讨论的问题。如果我们仔细思考,我们可以将问题归类为多个线程同步的问题。应用程序的最佳行为是同步对文件的写入,以便在任何给定时间只有一个线程能够写入文件。这将强制确保在已经执行的线程完成其写入之前,没有线程可以开始写入操作。
为了实现这种同步,我们可以利用锁的力量。锁提供了一种简单的实现同步的方法。例如,将要开始写操作的线程将首先获取锁。如果锁获取成功,线程就可以继续执行其写操作。现在,如果在中间发生上下文切换,并且另一个线程即将开始写操作,它将被阻塞,因为锁已经被获取。这将防止线程在已经运行的写操作之间写入数据。
在 Python 多线程中,我们可以通过使用threading.Lock
类来实现锁。该类提供了两种方法来方便地获取和释放锁。当线程想要在执行操作之前获取锁时,会调用acquire()
方法。一旦锁被获取,线程就会继续执行操作。一旦线程的操作完成,线程调用release()
方法释放锁,以便其他可能正在等待它的线程可以获取锁。
让我们看看如何使用锁来同步我们的 JSON 到 YAML 转换器示例中的线程操作。以下代码示例展示了锁的使用:
import threading
import json
import yaml
class JSONConverter(threading.Thread):
def __init__(self, json_file, yaml_file, lock):
threading.Thread.__init__(self)
self.json_file = json_file
self.yaml_file = yaml_file
self.lock = lock
def run(self):
print("Starting read for {}".format(self.json_file))
self.json_reader = open(self.json_file, 'r')
self.json = json.load(self.json_reader)
self.json_reader.close()
print("Read completed for {}".format(self.json_file))
print("Writing {} to YAML".format(self.json_file))
self.lock.acquire() # We acquire a lock before writing
self.yaml_writer = open(self.yaml_file, 'a+')
yaml.dump(self.json, self.yaml_writer)
self.yaml_writer.close()
self.lock.release() # Release the lock once our writes are done
print("Conversion completed for {}".format(self.json_file))
files = ['file1.json', 'file2.json', 'file3.json']
write_lock = threading.Lock()
conversion_threads = []
for file in files:
converter = JSONConverter(file, 'converted.yaml', write_lock)
conversion_threads.append(converter)
converter.start()
for cthread in conversion_threads:
cthread.join()
print("Exiting")
在这个例子中,我们首先通过创建threading.Lock
类的实例来创建一个lock
变量。然后将这个实例传递给所有需要同步的线程。当一个线程需要进行写操作时,它首先通过获取锁来开始写操作。一旦这些写操作完成,线程释放锁,以便其他线程可以获取锁。
如果一个线程获取了锁但忘记释放它,程序可能会陷入死锁状态,因为没有其他线程能够继续。应该谨慎地确保一旦线程完成其操作,获取的锁就被释放,以避免死锁。
可重入锁
除了提供多线程的一般锁定机制的threading.Lock
类之外,其中锁只能被获取一次直到释放,Python 还提供了另一种可能对实现递归操作的程序有用的锁定机制。这种锁,称为可重入锁,使用threading.RLock
类实现,可以被递归函数使用。该类提供了与锁类提供的类似方法:acquire()
和release()
,分别用于获取和释放已获取的锁。唯一的区别是当递归函数在调用堆栈中多次调用acquire()
时发生。当相同的函数一遍又一遍地调用获取方法时,…
条件变量
让我们想象一下,不知何故,我们有一种方法可以告诉我们的Thread-1
等待,直到Thread-2
提供了一些数据可供使用。这正是条件变量允许我们做的。它们允许我们同步依赖于共享资源的两个线程。为了更好地理解这一点,让我们看一下以下代码示例,它创建了两个线程,一个用于输入电子邮件 ID,另一个负责发送电子邮件:
# condition_variable.py
import threading
class EmailQueue(threading.Thread):
def __init__(self, email_queue, max_items, condition_var):
threading.Thread.__init__(self)
self.email_queue = email_queue
self.max_items = max_items
self.condition_var = condition_var
self.email_recipients = []
def add_recipient(self, email):
self.email_recipients.append(email)
def run(self):
while True:
self.condition_var.acquire()
if len(self.email_queue) == self.max_items:
print("E-mail queue is full. Entering wait state...")
self.condition_var.wait()
print("Received consume signal. Populating queue...")
while len(self.email_queue) < self.max_items:
if len(self.email_recipients) == 0:
break
email = self.email_recipients.pop()
self.email_queue.append(email)
self.condition_var.notify()
self.condition_var.release()
class EmailSender(threading.Thread):
def __init__(self, email_queue, condition_var):
threading.Thread.__init__(self)
self.email_queue = email_queue
self.condition_var = condition_var
def run(self):
while True:
self.condition_var.acquire()
if len(self.email_queue) == 0:
print("E-mail queue is empty. Entering wait state...")
self.condition_var.wait()
print("E-mail queue populated. Resuming operations...")
while len(self.email_queue) is not 0:
email = self.email_queue.pop()
print("Sending email to {}".format(email))
self.condition_var.notify()
self.condition_var.release()
queue = []
MAX_QUEUE_SIZE = 100
condition_var = threading.Condition()
email_queue = EmailQueue(queue, MAX_QUEUE_SIZE, condition_var)
email_sender = EmailSender(queue, condition_var)
email_queue.start()
email_sender.start()
email_queue.add_recipient("joe@example.com")
在这个代码示例中,我们定义了两个类,分别是EmailQueue
,它扮演生产者的角色,并在电子邮件队列中填充需要发送电子邮件的电子邮件地址。然后还有另一个类EmailSender
,它扮演消费者的角色,从电子邮件队列中获取电子邮件地址并发送邮件给它们。
现在,在EmailQueue
的__init__
方法中,我们接收一个 Python 列表作为参数,这个列表将作为队列使用,一个定义列表最多应该容纳多少项的变量,以及一个条件变量。
接下来,我们有一个方法add_recipient
,它将一个新的电子邮件 ID 附加到EmailQueue
的内部数据结构中,以临时保存电子邮件地址,直到它们被添加到发送队列中。
现在,让我们进入run()
方法,这里发生了真正的魔术。首先,我们启动一个无限循环,使线程始终处于运行模式。接下来,我们通过调用条件变量的acquire()
方法来获取锁。我们这样做是为了防止线程在意外时间切换上下文时对我们的数据结构进行任何形式的破坏。
一旦我们获得了锁,我们就会检查我们的电子邮件队列是否已满。如果已满,我们会打印一条消息,并调用条件变量的wait()
方法。对wait()
方法的调用会释放条件变量获取的锁,并使线程进入阻塞状态。只有在条件变量上调用notify()
方法时,这种阻塞状态才会结束。现在,当线程通过notify()
接收到信号时,它会继续其操作,首先检查内部队列中是否有一些数据。如果它在内部队列中找到了一些数据,那么它会用这些数据填充电子邮件队列,并调用条件变量的notify()
方法来通知EmailSender
消费者线程。现在,让我们来看看EmailSender
类。
在这里不需要逐行阅读,让我们把重点放在EmailSender
类的run()
方法上。由于这个线程需要始终运行,我们首先启动一个无限循环来做到这一点。然后,我们要做的下一件事是,在共享条件变量上获取锁。一旦我们获得了锁,我们现在可以操作共享的email_queue
数据结构。因此,我们的消费者首先要做的事情是检查电子邮件队列是否为空。如果发现队列为空,我们的消费者将调用条件变量的wait()
方法,有效地释放锁并进入阻塞状态,直到电子邮件队列中有一些数据为止。这会导致控制权转移到负责填充队列的EmailQueue
类。
现在,一旦电子邮件队列中有一些电子邮件 ID,消费者将开始发送邮件。一旦队列耗尽,它通过调用条件变量的notify
方法向EmailSender
类发出信号。这将允许EmailSender
继续其操作,填充电子邮件队列。
让我们看看当我们尝试执行前面的示例程序时会发生什么:
python condition_variable.py
E-mail queue is empty. Entering wait state...
E-mail queue populated. Resuming operations...
Sending email to joe@example.com
E-mail queue is empty. Entering wait state...
通过这个例子,我们现在了解了在 Python 中如何使用条件变量来解决生产者-消费者问题。有了这些知识,现在让我们来看看在我们的应用程序中进行多线程时可能出现的一些问题。
多线程的常见陷阱
多线程提供了许多好处,但也伴随着一些陷阱。如果不加以避免,这些陷阱在应用程序投入生产时可能会带来痛苦的经历。这些陷阱通常会导致意外行为,可能只会偶尔发生,也可能在特定模块的每次执行时都会发生。这其中令人痛苦的是,当这些问题是由多个线程的执行引起时,很难调试这些问题,因为很难预测特定线程何时执行。因此,在开发阶段讨论这些常见陷阱发生的原因以及如何在开发阶段避免它们是值得的。
一些常见的原因是…
竞争条件
在多线程的上下文中,竞争条件是指两个或更多个线程尝试同时修改共享数据结构的情况,但由于线程的调度和执行方式,共享数据结构被修改成一种使其处于不一致状态的方式。
这个声明是否令人困惑?别担心,让我们通过一个例子来理解它:
考虑我们之前的 JSON 转 YAML 转换器问题的例子。现在,假设我们在将转换后的 YAML 输出写入文件时没有使用锁。现在假设我们有两个名为writer-1
和writer-2
的线程,它们负责向共同的 YAML 文件写入。现在,想象一下,writer-1
和writer-2
线程都开始了写入文件的操作,并且操作系统安排线程执行的方式是,writer-1
开始写入文件。现在,当writer-1
线程正在写入文件时,操作系统决定该线程完成了其时间配额,并将该线程与writer-2
线程交换。现在,需要注意的一点是,当被交换时,writer-1
线程尚未完成写入所有数据。现在,writer-2
线程开始执行并完成了在 YAML 文件中的数据写入。在writer-2
线程完成后,操作系统再次开始执行writer-1
线程,它开始再次写入剩余的数据到 YAML 文件,然后完成。
现在,当我们打开 YAML 文件时,我们看到的是一个文件,其中包含了两个写入线程混合在一起的数据,因此,使我们的文件处于不一致的状态。writer-1
和writer-2
线程之间发生的问题被称为竞争条件。
竞争条件属于非常难以调试的问题类别,因为线程执行的顺序取决于机器和操作系统。因此,在一个部署上可能出现的问题在另一个部署上可能不会出现。
那么,我们如何避免竞争条件?嗯,我们已经有了问题的答案,而且我们最近刚刚使用过它们。所以,让我们来看看一些可以预防竞争条件发生的方法:
-
在关键区域使用锁:关键区域指的是代码中共享变量被线程修改的区域。为了防止竞争条件在关键区域发生,我们可以使用锁。锁本质上会导致除了持有锁的线程外,所有其他线程都会被阻塞。需要修改共享资源的所有其他线程只有在当前持有锁的线程释放锁时才能执行。可以使用的锁的类别包括互斥锁(一次只能由一个线程持有)、可重入锁(允许递归函数对同一共享资源进行多次锁定)和条件对象(可用于在生产者-消费者类型的环境中同步执行)。
-
使用线程安全的数据结构:预防竞争条件的另一种方法是使用线程安全的数据结构。线程安全的数据结构是指能够自动管理多个线程对其所做修改并串行化其操作的数据结构。Python 提供的一个线程安全的共享数据结构是队列。当操作涉及多个线程时,可以轻松地使用队列。
现在,我们对竞争条件是什么,它是如何发生的,以及如何避免有了一个概念。有了这个想法,让我们来看看由于我们预防竞争条件而可能出现的其他问题之一。
死锁
死锁是指两个或更多个线程永远被阻塞,因为它们彼此依赖或者一个资源永远不会被释放。让我们通过一个简单的例子来理解死锁是如何发生的:
考虑我们之前的 JSON 转 YAML 转换器的例子。现在,假设我们在线程中使用了锁,这样当一个线程开始向文件写入时,它首先对文件进行互斥锁定。现在,在线程释放这个互斥锁之前,其他线程无法执行。
因此,让我们想象一下有两个线程writer-1
和writer-2
,它们试图写入共同的输出文件。现在,当writer-1
开始执行时,它首先在文件上获取锁并开始操作。…
GIL 的故事
如果有人告诉你,即使你创建了一个多线程程序,只有一个线程可以同时执行?这种情况在系统只包含一个一次只能执行一个线程的单核心时是真实的,多个运行线程的幻觉是由 CPU 频繁地在线程之间切换而产生的。
但这种情况在 Python 的一个实现中也是真实的。Python 的原始实现,也称为 CPython,包括一个全局互斥锁,也称为 GIL,它只允许一个线程同时执行 Python 字节码。这有效地限制了应用程序一次只能执行一个线程。
GIL 是在 CPython 中引入的,因为 CPython 解释器不是线程安全的。GIL 通过交换运行多个线程的属性来有效地解决了线程安全问题。
GIL 的存在在 Python 社区中一直是一个备受争议的话题,有很多提案旨在消除它,但由于各种原因,包括对单线程应用程序性能的影响、破坏对 GIL 存在依赖的功能的向后兼容性等,没有一个提案被纳入 Python 的生产版本。
那么,GIL 的存在对于你的多线程应用程序意味着什么呢?实际上,如果你的应用程序利用多线程来执行 I/O 工作负载,那么由于大部分 I/O 发生在 GIL 之外,你可能不会受到 GIL 的性能损失影响,因此多个线程可以被复用。只有当应用程序使用多个线程执行需要大量操作应用程序特定数据结构的 CPU 密集型任务时,GIL 的影响才会被感知到。由于所有数据结构操作都涉及 Python 字节码的执行,GIL 将通过不允许多个线程同时执行严重限制多线程应用程序的性能。
那么,GIL 引起的问题是否有解决方法?答案是肯定的,但应该采用哪种解决方案完全取决于应用程序的用例。以下选项可能有助于避免 GIL:
-
**切换 Python 实现:**如果你的应用程序并不一定依赖于底层的 Python 实现,并且可以切换到另一个实现,那么有一些 Python 实现是没有 GIL 的。一些没有 GIL 的实现包括:Jython 和 IronPython,它们可以完全利用多处理器系统来执行多线程应用程序。
-
**利用多进程:**Python 在构建考虑并发的程序时有很多选择。我们探讨了多线程,这是实现并发的选项之一,但受到 GIL 的限制。实现并发的另一个选项是使用 Python 的多进程能力,它允许启动多个进程并行执行任务。由于每个进程在自己的 Python 解释器实例中运行,因此 GIL 在这里不成问题,并允许充分利用多处理器系统。
了解了 GIL 对多线程应用程序的影响,现在让我们讨论多进程如何帮助你克服并发的限制。
多进程并发
Python 语言提供了一些非常简单的方法来实现应用程序的并发。我们在 Python 线程库中看到了这一点,对于 Python 的多进程能力也是如此。
如果您想要借助多进程在程序中构建并发,那么借助 Python 的多进程库和该库提供的 API,实现起来非常容易。
那么,当我们说我们将使用多进程来实现并发时,我们是什么意思呢?让我们试着回答这个问题。通常,当我们谈论并发时,有两种方法可以帮助我们实现它。其中一种方法是运行单个应用程序实例,并允许其使用多个线程。…
Python 多进程模块
Python 提供了一种简单的方法来实现多进程程序。这种实现的便利性得益于 Python 的多进程模块,该模块提供了重要的类,如 Process 类用于启动新进程;Queue 和 Pipe 类用于促进多个进程之间的通信;等等。
以下示例快速概述了如何使用 Python 的多进程库创建一个作为单独进程执行的 URL 加载器:
# url_loader.py
from multiprocessing import Process
import urllib.request
def load_url(url):
url_handle = urllib.request.urlopen(url)
url_data = url_handle.read()
# The data returned by read() call is in the bytearray format. We need to
# decode the data before we can print it.
html_data = url_data.decode('utf-8')
url_handle.close()
print(html_data)
if __name__ == '__main__':
url = 'http://www.w3c.org'
loader_process = Process(target=load_url, args=(url,))
print("Spawning a new process to load the url")
loader_process.start()
print("Waiting for the spawned process to exit")
loader_process.join()
print("Exiting…")
在这个例子中,我们使用 Python 的多进程库创建了一个简单的程序,它在后台加载一个 URL 并将其信息打印到 stdout。有趣的地方在于理解我们如何轻松地在程序中生成一个新的进程。所以,让我们来看看。为了实现多进程,我们首先从 Python 的多进程模块中导入 Process 类。下一步是创建一个函数,该函数以要加载的 URL 作为参数,然后使用 Python 的 urllib 模块加载该 URL。一旦 URL 加载完成,我们就将来自 URL 的数据打印到 stdout。
接下来,我们定义程序开始执行时运行的代码。在这里,我们首先定义了我们想要加载的 URL,并将其存储在 url 变量中。接下来的部分是我们通过创建 Process 类的对象在程序中引入多进程。对于这个对象,我们将目标参数提供为我们想要执行的函数。这类似于我们在使用 Python 线程库时已经习惯的目标方法。Process 构造函数的下一个参数是 args 参数,它接受在调用目标函数时需要传递给目标函数的参数。
要生成一个新的进程,我们调用 Process 对象的 start()方法。这将在一个新的进程中启动我们的目标函数并执行其操作。我们做的最后一件事是等待这个生成的进程退出,通过调用 Process 类的 join()方法。
这就是在 Python 中创建多进程应用程序的简单方法。
现在,我们知道如何在 Python 中创建多进程应用程序,但是如何在多个进程之间分配特定的任务呢?嗯,这很容易。以下代码示例修改了我们之前示例中的入口代码,以利用多进程模块中的 Pool 类的功能来实现这一点:
from multiprocessing import Pool
if __name__ == '__main__':
url = ['http://www.w3c.org', 'http://www.microsoft.com', '[http://www.wikipedia.org', '[http://www.packt.com']
with Pool(4) as loader_pool:
loader_pool.map(load_url, url)
在这个例子中,我们使用多进程库中的 Pool 类创建了一个包含四个进程的进程池来执行我们的代码。然后使用 Pool 类的 map 方法,将输入数据映射到执行函数中的一个单独的进程中,以实现并发。
现在,我们有多个进程在处理我们的任务。但是,如果我们想让这些进程相互通信怎么办。例如,在之前的 URL 加载问题中,我们希望进程返回数据而不是在 stdout 上打印数据怎么办?答案在于使用管道,它为进程之间提供了双向通信的机制。
以下示例利用管道使 URL 加载器将从 URL 加载的数据发送回父进程:
# url_load_pipe.py
from multiprocessing import Process, Pipe
import urllib.request
def load_url(url, pipe):
url_handle = urllib.request.urlopen(url)
url_data = url_handle.read()
# The data returned by read() call is in the bytearray format. We need to
# decode the data before we can print it.
html_data = url_data.decode('utf-8')
url_handle.close()
pipe.send(html_data)
if __name__ == '__main__':
url = 'http://www.w3c.org'
parent_pipe, child_pipe = Pipe()
loader_process = Process(target=load_url, args=(url, child_pipe))
print("Spawning a new process to load the url")
loader_process.start()
print("Waiting for the spawned process to exit")
html_data = parent_pipe.recv()
print(html_data)
loader_process.join()
print("Exiting…")
在这个例子中,我们使用管道为父进程和子进程提供双向通信机制。当我们在代码的__main__
部分调用pipe
构造函数时,构造函数返回一对连接对象。每个连接对象都包含一个send()
和一个recv()
方法,用于在两端之间进行通信。使用send()
方法从child_pipe
发送的数据可以通过parent_pipe
的recv()
方法读取,反之亦然。
如果两个进程同时从管道的同一端读取或写入数据,可能会导致管道中的数据损坏。尽管,如果进程使用两个不同的端口或两个不同的管道,这就不成问题了。只有可以通过 pickle 序列化的数据才能通过管道发送。这是 Python 多进程模块的一个限制。
同步进程
与同步线程的操作一样重要的是,多进程上下文中的操作同步也很重要。由于多个进程可能访问相同的共享资源,它们对共享资源的访问需要进行序列化。为了帮助实现这一点,我们在这里也有锁的支持。
以下示例展示了如何在多进程模块的上下文中使用锁来同步多个进程的操作,通过获取与 URL 相关联的 HTML 并将其写入一个共同的本地文件:
# url_loader_locks.pyfrom multiprocessing import Process, Lockimport urllib.requestdef load_url(url, lock): url_handle = urllib.request.urlopen(url) ...
总结
在这一章中,我们探讨了如何在 Python 应用程序中实现并发以及它的用途。在这个探索过程中,我们揭示了 Python 多线程模块的功能,以及如何使用它来生成多个线程来分配工作负载。然后,我们继续了解如何同步这些线程的操作,并了解了多线程应用程序可能出现的各种问题,如果不加以处理。然后,本章继续探讨了全局解释器锁(GIL)在某些 Python 实现中所施加的限制,以及它如何影响多线程工作负载。为了探索克服 GIL 所施加的限制的可能方法,我们继续了解了 Python 的多进程模块的使用,以及它如何帮助我们利用多处理器系统的全部潜力,通过使用多个进程而不是多个线程来实现并行处理。
问题
-
Python 是通过哪些不同的方法实现并发应用程序的?
-
如果已经获得锁的线程突然终止会发生什么?
-
当应用程序接收到终止信号时,如何终止执行线程?
-
如何在多个进程之间共享状态?
-
有没有一种方法可以创建一个进程池,然后用于处理任务队列中的任务?
第五章:构建大规模请求处理
在企业环境中,随着用户数量的增长,同时尝试访问 Web 应用程序的用户数量也会增长是正常的。这给我们带来了一个有趣的问题,即如何扩展 Web 应用程序以处理大量用户的并发请求。
将 Web 应用程序扩展以处理大量用户是可以通过多种方式实现的任务,其中最简单的方式之一可以是增加更多基础设施并运行应用程序的更多实例。然而,尽管这种技术简单,但对应用程序可扩展性的经济影响很大,因为运行规模化应用程序的基础设施成本可能是巨大的。我们当然需要以这样的方式设计我们的应用程序,以便它能够轻松处理大量并发请求,而不需要频繁地扩展基础设施。
在前一章奠定的基础上,我们将看到如何应用这些技术来构建一个可扩展的应用程序,可以处理大量并发请求,同时学习一些其他技术,将帮助我们轻松地扩展应用程序。
在本章中,我们将看到以下技术来扩展我们的 Web 应用程序,以处理大规模的请求处理:
-
在 Web 应用部署中利用反向代理
-
使用线程池来扩展请求处理
-
了解使用 Python AsyncIO 的单线程并发代码的概念
技术要求
本书中的代码清单可以在github.com/PacktPublishing/Hands-On-Enterprise-Application-Development-with-Python
的chapter05
目录下找到。
可以通过运行以下命令克隆代码示例:
git clone https://github.com/PacktPublishing/Hands-On-Enterprise-Application-Development-with-Python
为了成功执行代码示例,需要安装 python-virtualenv
包。
容纳增加的并发问题
多年来,互联网存在的时间里,Web 应用架构师常常面临的最常见问题之一是如何处理不断增加的并发。随着越来越多的用户上线并使用 Web 应用程序,迫切需要扩展基础设施来管理所有这些请求。
即使对于我们的企业 Web 应用程序也是如此。尽管我们可以估计企业内可能有多少用户同时访问这些 Web 应用程序,但没有硬性规定适用于未来的时间。随着企业的发展,访问应用程序的客户数量也会增加,给基础设施增加更多压力,并增加扩展的需求。但是,在尝试扩展应用程序以适应不断增加的客户数量时,我们有哪些选择?让我们来看看。
多种扩展选项
技术世界提供了许多选项,以扩展应用程序以适应不断增长的用户群体;其中一些选项只是要求增加硬件资源,而其他选项则要求应用程序围绕处理内部的多个请求来构建。大多数情况下,扩展选项分为两大类,即垂直扩展和水平扩展:
让我们看看它们,找出它们的利弊:
- 垂直扩展:垂直扩展的整个概念基于向现有资源添加更多资源的事实…
为可扩展性工程应用
在大多数企业项目在生产阶段通常使用一个框架或另一个框架来决定应用程序的服务方式的时候,仍然有必要深入了解如何在开发应用程序时保持应用程序的可扩展性。
在本节中,我们将看看不使用一些预先构建的框架,如何构建可扩展的应用程序的不同技术。在本节课程中,我们将看到如何使用线程/进程池来同时处理多个客户端,以及资源池化为什么是必要的,以及是什么阻止我们为处理每个其他传入请求启动单独的线程或进程。
但在我们深入探讨如何在应用程序开发中利用线程池或进程池之前,让我们首先看一下通过哪种简单的方式我们可以将传入请求的处理交给后台线程。
以下代码实现了一个简单的套接字服务器,首先接受传入的连接,然后将其交给后台线程进行读写,从而释放主线程以接受其他传入连接:
# simple_socket_thread.py
#!/usr/bin/python3
import socket
import threading
# Let's first create a TCP type Server for handling clients
class Server(object):
"""A simple TCP Server."""
def __init__(self, hostname, port):
"""Server initializer
Keyword arguments:
hostname -- The hostname to use for the server
port -- The port on which the server should bind
"""
self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.hostname = hostname
self.port = port
self.bind_connection()
self.listen()
def bind_connection(self):
"""Bind the server to the host."""
self.server.bind((self.hostname, self.port))
def listen(self):
"""Start listening for the incoming connections."""
self.server.listen(10) # Queue a maximum of 10 clients
# Enter the listening loop
while True:
client, client_addr = self.server.accept()
print("Received a connection from %s" % str(client_addr))
client_thread = threading.Thread(target=self.handle_client, args=(client,))
client_thread.daemon = True
client_thread.start()
def handle_client(self, client):
"""Handle incoming client connection.
Keyword arguments:
client -- The client connection socket
"""
print("Accepted a client connection")
while True:
buff = client.recv(1024).decode()
if not buff:
break
print(buff)
print("Client closed the connection")
client.close() # We are done now, let's close the connection
if __name__ == '__main__':
server = Server('localhost', 7000)
在这段代码中,我们实现了一个简单的Server
类,它在机器上初始化了一个基于 TCP 的服务器,准备接受传入的连接。在不偏离太多的情况下,让我们试着专注于这段代码的重要方面,在这里我们在listen()
方法下启动了服务器的监听循环。
在listen()
方法下,我们首先调用套接字的listen()
方法,并告诉它最多可以排队 10 个尚未被接受的连接。一旦达到这个限制,服务器将拒绝任何进一步的客户端连接。接下来,我们开始一个无限循环,在循环中首先调用套接字的accept()
方法。对accept()
方法的调用将阻塞,直到客户端尝试建立连接。成功尝试后,accept()
调用将返回客户端连接套接字和客户端地址。客户端连接套接字可用于与客户端执行 I/O 操作。
接下来发生的有趣部分是:一旦客户端连接被接受,我们就启动一个负责处理与客户端通信的守护线程,并将客户端连接套接字交给线程处理。这实质上使我们的主线程从处理客户端套接字的 I/O 中解放出来,因此,我们的主线程现在可以接受更多的客户端。这个过程对于连接到我们服务器的每个其他客户端都会继续进行。
到目前为止一切都很好;我们有了一个很好的方法来处理传入的客户端,我们的服务可以随着客户端数量的增加逐渐扩展。这是一个简单的解决方案,不是吗?嗯,在提出这个解决方案的过程中,显然我们忽略了一个主要缺陷。缺陷在于我们没有实现任何与应用程序可以启动多少个线程来处理传入客户端相关的控制。想象一下,如果一百万个客户端尝试连接到我们的服务器会发生什么?我们真的会同时运行一百万个线程吗?答案是否定的。
但为什么不可能呢?让我们来看看。
控制并发
在前面的例子中,我们遇到了一个问题,为什么我们不能有一百万个线程,每个线程处理一个单独的客户端?这应该为我们提供了大量的并发性和可扩展性。但是,有许多原因实际上阻止我们同时运行一百万个线程。让我们试着看看可能阻止我们无限扩展应用程序的原因:
- 资源限制:服务器处理的每个客户端连接都不是免费的。随着每个新连接的客户端,我们都在消耗机器的一些资源。这些资源可能包括映射到套接字的文件描述符,用于保存信息的一些内存…
使用线程池处理传入的连接
正如我们在前一节中看到的,我们不需要无限数量的线程来处理传入的客户端。我们可以通过有限数量的线程来处理大量的客户端。但是,我们如何在我们的应用程序中实现这个线程池呢?事实证明,使用 Python 3 和concurrent.futures
模块实现线程池功能是相当容易的。
以下代码示例修改了我们现有的 TCP 服务器示例,以使用线程池来处理传入的客户端连接,而不是任意启动无限数量的线程:
# simple_socket_threadpool.py
#!/usr/bin/python3
from concurrent.futures import ThreadPoolExecutor
import socket
# Let's first create a TCP type Server for handling clients
class Server(object):
"""A simple TCP Server."""
def __init__(self, hostname, port, pool_size):
"""Server initializer
Keyword arguments:
hostname -- The hostname to use for the server
port -- The port on which the server should bind
pool_size -- The pool size to use for the threading executor
"""
# Setup thread pool size
self.executor_pool = ThreadPoolExecutor(max_workers=pool_size)
# Setup the TCP socket server
self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.hostname = hostname
self.port = port
self.bind_connection()
self.listen()
def bind_connection(self):
"""Bind the server to the host."""
self.server.bind((self.hostname, self.port))
def listen(self):
"""Start listening for the incoming connections."""
self.server.listen(10) # Queue a maximum of 10 clients
# Enter the listening loop
while True:
client, client_addr = self.server.accept()
print("Received a connection from %s" % str(client_addr))
self.executor_pool.submit(self.handle_client, client)
def handle_client(self, client):
"""Handle incoming client connection.
Keyword arguments:
client -- The client connection socket
"""
print("Accepted a client connection")
while True:
buff = client.recv(1024).decode()
if not buff:
break
print(buff)
print("Client closed the connection")
client.close() # We are done now, let's close the connection
if __name__ == '__main__':
server = Server('localhost', 7000, 20)
在这个例子中,我们修改了我们的 TCP 服务器代码,以利用线程池来处理客户端连接,而不是启动任意数量的线程。让我们看看我们是如何做到的。
首先,要使用线程池,我们需要初始化线程池执行器的实例。在Server
类的__init__
方法中,我们首先通过调用其构造函数来初始化线程池执行器:
self.executor_pool = ThreadPoolExecutor(max_workers=pool_size)
ThreadPoolExecutor
构造函数接受一个max_workers
参数,该参数定义了ThreadPool
内可能有多少并发线程。但是,max_workers
参数的最佳值是多少呢?
一个经验法则是将max_workers
= (5 x CPU 核心总数)。这个公式背后的原因是,在 Web 应用程序中,大多数线程通常在等待 I/O 完成,而少数线程正在忙于执行 CPU 绑定的操作。
创建了ThreadPoolExecutor
之后,下一步是提交作业,以便它们可以由执行器池内的线程处理。这可以通过Server
类的listen()
方法来实现:
self.executor_pool.submit(self.handle_client, client)
ThreadPoolExecutor
的submit()
方法的第一个参数是要在线程内执行的方法的名称,第二个参数是要传递给执行方法的参数。
这样做非常简单,而且给我们带来了很多好处,比如:
-
充分利用底层基础设施提供的资源
-
处理多个请求的能力
-
增加了可扩展性,减少了客户端的等待时间
这里需要注意的一点是,由于ThreadPoolExecutor
利用了线程,CPython 实现可能由于 GIL 的存在而无法提供最大性能,GIL 不允许同时执行多个线程。因此,应用程序的性能可能会因所使用的底层 Python 实现而异。
现在出现的问题是,如果我们想要规避全局解释器锁,那该怎么办呢?在仍然使用 Python 的 CPython 实现的情况下,有没有一些机制?我们在上一章讨论了这种情况,并决定使用 Python 的多进程模块来代替线程库。
此外,事实证明,使用ProcessPoolExecutor
是一件相当简单的事情。并发.futures 包中的底层实现处理了大部分必要性,并为程序员提供了一个简单易用的抽象。为了看到这一点,让我们修改我们之前的例子,将ProcessPoolExecutor
替换为ThreadPoolExecutor
。要做到这一点,我们只需要首先从 concurrent.futures 包中导入正确的实现,如下行所述:
from concurrent.futures import ProcessPoolExecutor
我们需要做的下一件事是修改我们的__init__
方法,以创建一个进程池而不是线程池。下面的__init__
方法的实现显示了我们如何做到这一点:
def __init__(self, hostname, port, pool_size):
"""Server initializer
Keyword arguments:
hostname -- The hostname to use for the server
port -- The port on which the server should bind
pool_size -- The size of the pool to use for the process based executor
"""
# Setup process pool size
self.executor_pool = ProcessPoolExecutor(max_workers=pool_size)
# Setup the TCP socket server
self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.hostname = hostname
self.port = port
self.bind_connection()
self.listen()
确实,这是一个简单的过程,现在我们的应用程序可以使用多进程模型而不是多线程模型。
但是,我们可以保持池大小不变,还是它也需要改变?
每个进程都有自己的内存空间和需要维护的内部指针,这使得进程比使用线程实现并发更重。这提供了减少池大小的理由,以便允许更重的底层系统资源的使用。作为一个一般规则,对于ProcessPoolExecutor
,max_workers
可以通过公式max_workers
= *(2 x CPU 核心数 + 1)*来计算。
这个公式背后的推理可以归因于这样一个事实,即在任何给定时间,我们可以假设一半的进程将忙于执行网络 I/O,而另一半可能忙于执行 CPU 密集型任务。
所以,现在我们对如何使用资源池以及为什么这是一个比启动任意数量的线程更好的方法有了一个相当清楚的想法。但是,这种方法仍然需要大量的上下文切换,并且也高度依赖于所使用的底层 Python 实现。但肯定有比这更好的东西。
考虑到这一点,让我们尝试进入 Python 王国的另一个领域,即异步编程领域。
使用 AsyncIO 进行异步编程
在我们深入探讨异步编程这个未知领域之前,让我们首先回顾一下为什么我们使用线程或多个进程。
使用线程或多个进程的主要原因之一是增加并发性,从而使应用程序能够处理更多的并发请求。但这是以增加资源利用率为代价的,而且使用多线程或启动更重的进程来适应更高的并发性,需要复杂的实现在共享数据结构之间实现锁定。
现在,在构建可扩展的 Web 应用程序的背景下,我们还有一些与一般用途不同的主要区别…
AsyncIO 术语
正如我们最近讨论的,Python 中对异步编程的支持是通过事件循环和协程来实现的。但它们究竟是什么?让我们来看一下:
事件循环
事件循环,顾名思义,就是一个循环。这个循环的作用是,当一个新任务应该被执行时,事件循环会将这个任务排队。现在,控制权转移到了事件循环。当事件循环运行时,它会检查它的队列中是否有任务。如果有任务存在,控制权就会转移到这个任务上。
现在,在异步执行任务的上下文中,这是一个有趣的部分。假设事件循环的队列中有两个任务,即任务 A 和任务 B。当事件循环开始执行时,它会检查它的任务队列的状态。事件队列发现它的队列中有任务。因此,事件队列选择了任务 A。现在发生了上下文切换…
协程
Python AsyncIO 中的协程提供了执行多个同时操作的轻量级机制。协程在 Python 中作为生成器的特殊用例实现。因此,在我们深入理解协程之前,让我们花一点时间来理解生成器。
一般来说,生成器是那些生成一些值的函数。然而,这是每个其他函数所做的,那么生成器与常规函数有何不同。区别在于一般函数的生命周期与生成器的生命周期不同。当我们调用一个函数时,它产生一些值,返回它,一旦调用移出函数体,函数的作用域就被销毁。当我们再次调用函数时,会生成并执行一个新的作用域。
相比之下,当我们调用一个生成器时,生成器可以返回一个值,然后进入暂停状态,控制权转移到调用者。此时,生成器的作用域不会被销毁,它可以从之前离开的地方继续生成值。这基本上为我们提供了一个通过它可以拉取或产生一些值的函数。
以下代码示例显示了如何编写一个简单的生成器函数:
def get_number():
i = 0
while True:
yield i
i = i + 1
num = get_number()
print(next(num))
>>> 0
print(next(num))
>>> 1
这里有趣的部分是,通过简单地一次又一次地调用生成器,生成器不会继续提供下一个结果。为了产生新的结果,我们需要在生成器上使用next()
方法。这允许我们从生成器中产生新的结果。
现在,协程实现了生成器的一个特殊用例,在这个用例中,它不仅可以产生新的结果,还可以接收一些数据。这是通过 yield 和生成器的send()
方法的组合实现的。
以下代码示例显示了一个简单协程的实现:
def say_hello():
msg = yield "Hello"
yield msg
greeting = say_hello()
next(greeting)
>>> Hello
greeting.send("Joe")
>>> Joe
由于协程允许函数暂停和恢复,因此可以懒惰地生成结果,这使得它成为异步编程的一个很好的选择,其中任务经常进入阻塞状态,一旦它们的操作完成,就会从那里恢复。
任务
Python AsyncIO 中的任务是包装协程的机制。每个任务都有与之关联的结果,可能会立即生成,也可能会延迟,这取决于任务的类型。这个结果被称为 Future。
在 AsyncIO 中,任务是 Future 的一个子类,它包装了一个协程。当协程完成生成值时,任务返回并被事件循环标记为完成,因此从事件队列的任务队列中移除。
现在,我们对与 Python AsyncIO 相关的术语有了相当清楚的概念。现在让我们深入一些行动,并编写一个简单的程序来了解 Python AsyncIO 的工作原理。
编写一个简单的 Python AsyncIO 程序
现在是时候做好准备,开始深入了解 Python 异步编程的世界,了解 AsyncIO 是如何工作的。
以下代码使用 Python 请求库和 AsyncIO 实现了一个简单的 URL 获取器:
# async_url_fetch.py
#!/usr/bin/python3
import asyncio
import requests
async def fetch_url(url):
response = requests.get(url)
return response.text
async def get_url(url):
return await fetch_url(url)
def process_results(future):
print("Got results")
print(future.result())
loop = asyncio.get_event_loop()
task1 = loop.create_task(get_url('http://www.google.com'))
task2 = loop.create_task(get_url('http://www.microsoft.com'))
task1.add_done_callback(process_results)
task2.add_done_callback(process_results)
loop.run_forever()
这是一个小而不错的异步程序,实现了 Python AsyncIO 库。现在,让我们花一些时间来理解我们在这里做了什么。
从头开始,我们已经导入了 Python 请求库来从我们的 Python 代码中进行网络请求,并且还导入了 Python 的 AsyncIO 库。
接下来,我们定义了一个名为fetch_url
的协程。在 AsyncIO 中定义协程的一般语法需要使用async
关键字:
async def fetch_url(url)
接下来是另一个名为get_url
的协程的定义。在get_url
例程中,我们调用另一个协程fetch_url
,它执行实际的 URL 获取。
由于fetch_url
是一个阻塞协程,我们在调用fetch_url
之前使用await
关键字。这表示这个方法可以被暂停,直到结果被获取:
return await fetch_url(url)
程序中的下一个部分是process_results
方法的定义。我们使用这个方法作为一个回调来处理get_url
方法的结果一旦它们到达。这个方法接受一个参数,一个future
对象,它将包含get_url
函数调用的结果。
在方法内部,可以通过future
对象的results()
方法访问 future 的结果:
print(future.results())
有了这个,我们已经为执行 AsyncIO 事件循环设置了所有基本的机制。现在,是时候实现一个真正的事件循环并向其提交一些任务了。
我们首先通过调用get_event_loop()
方法获取 AsyncIO 事件循环。get_event_loop()
方法返回在代码运行的平台上的 AsyncIO 的最佳事件循环实现。
AsyncIO 实现了多个事件循环,程序员可以使用。通常,对get_event_loop()
的简单调用将返回系统解释器正在运行的最佳事件循环实现。
一旦我们创建了循环,现在我们通过使用create_task()
方法向事件循环提交了一些任务。这将任务添加到事件循环的队列中以执行。现在,由于这些任务是异步的,我们不知道哪个任务会首先产生结果,因此我们需要提供一个回调来处理任务的结果。为了实现这一点,我们使用任务的add_done_callback()
方法向任务添加回调:
task1.add_done_callback(process_results)
一旦这里的一切都设置好了,我们就开始进入run_forever
模式的事件循环,这样事件循环就会继续运行并处理新的任务。
有了这个,我们已经完成了一个简单的 AsyncIO 程序的实现。但是,嘿,我们正在尝试构建一个企业规模的应用程序。如果我想用 AsyncIO 构建一个企业级 Web 应用程序呢?
因此,现在让我们看看如何使用 AsyncIO 来实现一个简单的异步套接字服务器。
使用 AsyncIO 实现简单的套接字服务器
Python 实现提供的 AsyncIO 库提供了许多强大的功能。其中之一是能够接口和管理套接字通信。这为程序员提供了实现异步套接字处理的能力,因此允许更多的客户端连接到服务器。
以下代码示例构建了一个简单的套接字处理程序,使用基于回调的机制来处理与客户端的通信:
# async_socket_server.py#!/usr/bin/python3import asyncioclass MessageProtocol(asyncio.Protocol): """An asyncio protocol implementation to handle the incoming messages.""" def connection_made(self, transport): ...
增加应用程序的并发性
当我们通过框架构建 Web 应用程序时,大多数情况下,框架通常会提供一个小巧易用的 Web 服务器。尽管这些服务器在开发环境中用于快速实现更改并在开发阶段内部调试应用程序中的问题,但这些服务器无法处理生产工作负载。
即使整个应用程序是从头开始开发的情况下,通常也是一个好主意通过使用反向代理来代理与 Web 应用程序的通信。但问题是,为什么我们需要这样做?为什么我们不直接运行 Web 应用程序并让它处理传入的请求。让我们快速浏览一下 Web 应用程序的所有职责:
-
处理传入请求:当一个新的请求到达 Web 应用程序时,Web 应用程序可能需要决定如何处理该请求。如果 Web 应用程序有可以处理请求的工作进程,应用程序将接受请求,将其交给工作进程,并在工作进程完成处理后返回请求的响应。如果没有工作进程,那么 Web 应用程序必须将此请求排队以供以后处理。在最坏的情况下,当队列积压超过最大排队客户端数量的阈值时,Web 应用程序必须拒绝请求。
-
提供静态资源:如果 Web 应用程序需要生成动态 HTML 页面,它也可以兼作服务器发送静态资源,如 CSS、Javascript、图像等,从而增加负载。
-
处理加密:现在大多数网络应用程序都启用了加密。在这种情况下,我们的网络应用程序还需要我们来管理加密数据的解析并提供安全连接。
这些都是由一个简单的网络应用程序服务器处理的相当多的责任。我们实际上需要的是一种机制,通过这种机制,我们可以从网络应用程序服务器中卸载很多这些责任,让它只处理它应该处理的基本工作,并且真正发挥其作用。
在反向代理后运行
因此,我们改进网络应用程序处理大量客户端的第一步行动是,首先从它的肩上卸下一些责任。为了实现这一点,首先要做的简单选择是将网络应用程序放在反向代理后面运行:
那么,反向代理到底是做什么的呢?反向代理的工作方式是,当客户端请求到达网络应用程序服务器时,反向代理会拦截该请求。根据定义的规则将请求匹配到适当的后端应用程序,反向代理然后将该请求转发到…
提高安全性
考虑使用反向代理时首先想到的一个优势是提高安全性。这是因为现在我们可以在防火墙后面运行我们的网络应用程序,因此无法直接访问。反向代理拦截请求并将其转发到应用程序,而不让用户知道他们所发出的请求在幕后发生了什么。
对网络应用程序的受限访问有助于减少恶意用户可以利用的攻击面,从而破坏网络应用程序,并访问或修改关键记录。
改进连接处理
反向代理服务器还可以用于改进网络应用程序的连接处理能力。如今,为了加快获取远程内容的速度,Web 浏览器会打开多个连接到 Web 服务器,以增加资源的并行下载。反向代理可以排队并提供连接请求,同时网络应用程序正在处理待处理的请求,从而提高连接接受性并减少应用程序管理连接状态的负载。
资源缓存
当网络应用程序为特定客户端请求生成响应时,有可能会再次收到相同类型的请求,或者再次请求相同的资源。对于每个类似的请求,使用网络应用程序一遍又一遍地生成响应可能并不是一个优雅的解决方案。
反向代理有时可以帮助理解请求和响应模式,并为它们实施缓存。当启用缓存时,当再次收到类似的请求或再次请求相同的资源时,反向代理可以直接发送缓存的响应,而不是将请求转发到网络应用程序,从而卸载了网络应用程序的很多开销。这将提高网络应用程序的性能,并缩短客户端的响应时间。
提供静态资源
大多数网络应用程序提供两种资源。一种是根据外部输入生成的动态响应,另一种是保持不变的静态内容,例如 CSS 文件、Javascript 文件、图像等。
如果我们可以从网络应用程序中卸载这些责任中的任何一项,那么它将提供很大的性能增益和改进的可扩展性。
我们在这里最好的可能性是将静态资源的提供转移到客户端。反向代理也可以充当服务器,为客户端提供静态资源,而无需将这些请求转发到 Web 应用程序服务器,从而大大减少了等待的请求数量…
摘要
在本章的过程中,我们了解了构建我们的 Web 应用程序以处理大量并发请求的不同方式。我们首先了解并学习了不同的扩展技术,如垂直扩展和水平扩展,并了解了每种技术的不同优缺点。然后,我们进一步深入讨论了帮助我们提高 Web 应用程序本身处理更多请求的能力的主题。这使我们进入了使用资源池的旅程,以及为什么使用资源池而不是随意分配资源给每个到达 Web 应用程序的新请求是一个好主意。在旅程的进一步过程中,我们了解了处理传入请求的异步方式,以及为什么异步机制更适合于更 I/O 绑定的 Web 应用程序的更高可扩展性。我们通过研究反向代理的使用以及反向代理为我们扩展 Web 应用程序提供的优势来结束了我们关于为大量客户端扩展应用程序的讨论。
现在,通过了解我们如何使我们的应用程序处理大量并发请求,下一章将带领我们通过构建演示应用程序的过程,利用到目前为止在本书中学到的不同概念。
问题
-
我们如何使用同一应用程序的多个实例来处理传入请求?
-
我们如何实现进程池并将客户端请求分发到它们之上?
-
我们是否可以实现一个同时利用进程池和线程池的应用程序?在实施相同的过程中可能会遇到什么问题?
-
我们如何使用 AsyncIO 实现基本的 Web 服务器?