Python 为我们提供了几个结构来分组和组织我们的软件。在第 1节通过特殊方法实现更紧密的集成中,我们研究了几种使用类定义将数据结构和行为组合在一起,并创建由结构和行为定义的离散对象的技术。在本章中,我们将介绍用于封装类和函数定义以及共享对象的模块。我们还将把包看作一种设计模式,将相关模块分组在一起。
Python 使创建简单模块变得非常容易。每当我们创建 Python 文件时,我们都在创建一个模块。随着我们的设计范围越来越大,越来越复杂,包的使用对于在模块之间保持清晰的组织变得越来越重要。本章将建议模块定义的模式。
有些语言鼓励将单个类放在单个文件中;此规则不适用于 Python。Pythonic 实践是将整个模块视为重用单元;在单个模块中有许多密切相关的类和函数定义是常见的做法。
Python 有一些专门的、保留的模块名。对于更大的应用程序,我们可以实现一个__main__模块。此模块必须设计为公开复杂应用程序的 OS 命令行界面。在如何安装与应用程序关联的模块方面,我们也有一定的灵活性。我们可以使用默认的工作目录、环境变量设置或 Pythonlib/site-packages目录。这些方法各有优缺点。
本章的代码文件可在上找到 https://git.io/fj2US 。
一种常见的方法是单一模块。可以这样设想整个组织:
module.py
┣━━ class A:
┃ ┗━━ def method(self): ...
┣━━ class B:
┃ ┗━━ def method(self): ...
┗━━ def function(): ...此示例显示了一个包含多个类和函数的模块。我们将在本章后面的设计模块一节中讨论模块设计。
更复杂的方法是一个模块包,可以想象如下:
package
┣━━ __init__.py
┣━━ module1.py
┃ ┣━━ class A:
┃ ┃ ┗━━ def method(self): ...
┃ ┗━━ def function(): ...
┗━━ module2.py
┗━━ ...此示例显示了一个包含两个模块的包。每个模块都包含类和函数。我们将在本章后面的设计包一节中介绍包设计。
我们将避免分发 Python 代码这一更复杂的问题。有许多技术可以为 Python 项目创建源代码分发。各种分发技术超出了本书的范围。Python 标准库的软件打包和分发部分解决了一些物理文件打包问题。分发 Python 模块文档提供了有关创建代码分发的信息。
在本章中,我们将介绍以下主题:
- 设计模块
- 整体模块与模块项
- 设计包装
- 设计主脚本和主模块
- 设计长时间运行的应用程序
- 组织代码分为
src、scripts、tests、docs - 安装 Python 模块
在本书的第二到第九章中,我们研究了许多设计类的技术,它们是面向对象的设计和编程的基础。该模块是类的集合;它是相关类和函数的更高级别分组。很少尝试单独重用单个类
因此,该模块是 Python 实现和重用的基本组件。正确设计的模块可以重用,因为所需的类和函数捆绑在一起。所有 Python 编程都是在模块级别提供的。
Python 模块是一个文件。文件扩展名必须为.py。.py前面的文件名必须是有效的 Python 名称。Python 语言参考**e的第 2.3 节为我们提供了名称的完整定义。本定义中的一个条款如下:
"Within the ASCII range (U+0001..U+007F), the valid characters for identifiers are the uppercase and lowercase letters A through Z, the underscore _ and, except for the first character, the digits 0 through 9."
操作系统(OS)文件名允许 ASCII 范围内的字符多于 Python 名称;必须避免这种额外的操作系统复杂性。特别是,连字符是 Python 模块名称中的一个潜在问题;在复杂文件名中使用下划线。因为文件名的词干(不带.py扩展名)成为模块名,所以这些名称也应该是有效的 Python 标识符。
The Python runtime may also create additional .pyc and .pyo files for its own private purposes; it's best to simply ignore these files. Generally, they're cached copies of code objects used to reduce the time to load a module. These files should be ignored.
每次我们创建一个.py文件时,我们都会创建一个模块。通常,我们会创建 Python 文件,而不做很多设计工作。这种简单性是使用 Python 的一个好处。在本章中,我们将了解创建可重用模块的一些设计注意事项。
现在让我们看一下 Python 模块的一些设计模式。
Python 模块有三种常见的设计模式:
- 可导入库模块:这些模块是要导入的。它们包含类、函数的定义,也许还包含一些赋值语句来创建一些全局变量。他们不做任何真正的工作;它们可以导入而不必担心导入操作的副作用。我们将研究两个用例:
- 整体模块:一些模块被设计为整体导入,创建一个包含所有项的模块名称空间。
- 项目集合:一些模块设计为允许导入单个项目,而不是创建模块对象;
math模块就是这种设计的一个主要例子。
- 可运行脚本模块:这些模块将从命令行执行。它们包含的不仅仅是类和函数定义。脚本将包含执行实际工作的语句。副作用的存在意味着它们不能被有意义地输入。
- 条件脚本模块:这些模块是上述两种用例的混合体:它们可以导入,也可以从命令行运行。这些模块将具有主导入开关,如*\uuuuuu main\uuuuuu–顶级脚本环境部分中的Python 标准库*中所述。
以下是库文档中的条件脚本开关:
if __name__ == "__main__":
main() 这需要一个main()函数来完成脚本的工作。此设计支持两个用例:可运行和可导入。当模块从命令行运行时,它评估main()并执行预期的工作。当导入模块时,不会对函数进行求值,导入只会创建各种定义,而不做任何实际工作。
我们建议更复杂一些,如第 18 章处理命令行中所示:
if __name__ == "__main__":
with Setup_Logging():
with Build_Config() as config:
main = Simulate_Command()
main.configure(config)
main.run() 这将导致以下基本设计提示:
Importing a module should have few side effects.
创建几个模块级变量是导入可以接受的副作用。真正的工作——访问网络资源、打印输出、更新文件和其他类型的处理——不应该在导入模块时发生。
没有__name__ == "__main__"节的主脚本模块通常是个坏主意,因为它无法导入和重用。除此之外,文档工具很难与主脚本模块一起工作,也很难进行测试。文档工具倾向于导入模块,导致工作意外完成。类似地,测试需要小心避免将模块作为测试设置的一部分导入。
在下一节中,我们将比较一个模块和一个类定义。这两个概念在许多方面相似。
模块和类的定义之间有许多相似之处:
- 模块和类都有 Python 语法规则定义的名称。为了帮助区分它们,模块通常有一个前导小写字母;类通常有一个前导大写字母。
- 模块和类定义是包含其他对象的命名空间。
- 模块是全局命名空间
sys.modules中的单例对象。类定义在命名空间中是唯一的,可以是全局命名空间、__main__或某些本地命名空间。类定义与模块单例略有不同,因为类定义可以替换。一旦导入模块,如果不使用特殊功能,如importlib.reload(),则无法再次导入模块。 - 类和模块的定义都作为命名空间中的一系列语句进行计算。
- 模块中定义的函数类似于类定义中的静态方法。
- 模块中定义的类类似于另一个类中定义的类。
模块和类之间有两个显著差异:
- 我们不能创建模块的实例;它总是一个单身。我们可以创建一个类的多个实例。
- 模块中的赋值语句创建模块命名空间中的全局变量;模块中的其他函数可以使用它,而无需将模块名称用作命名空间的限定符。然而,类定义中的赋值语句会创建一个属于类命名空间的变量:它需要一个限定符来将其与类外的全局变量区分开来。
Modules, packages, and classes can all be used to encapsulate data and process it into a tidy object. Classes can have multiple instances; modules cannot.
模块和类之间的相似性意味着它们之间的选择是一个设计决策,需要权衡和选择。在大多数情况下,需要具有不同状态的多个实例是决定因素。因为模块是单例,所以我们不能有离散实例。
模块的单例模式意味着我们将使用模块(或包)来包含类和函数定义。这些定义只创建一次,即使import语句多次提到它们。这是一个奇妙的简化,允许我们在各种上下文中重复import语句
例如,logging模块通常在多个其他模块中导入。单例模式意味着日志配置可以完成一次,并将应用于所有其他模块。类似地,一个配置模块可能会在多个地方导入。模块的单例特性确保配置可以由任何模块导入,但将是真正的全局配置。
在编写使用单个连接数据库的应用程序时,具有多个访问函数的模块将类似于单例类。数据库访问层可以在整个应用程序中导入,但将是单个共享全局对象。
一旦我们确定了一个模块的通用设计模式,接下来要考虑的是确保它具有所有常见的预期元素。
在下一节中,我们将了解模块的预期内容。
Python 模块具有典型的组织结构。在某种程度上,PEP 8 对此进行了定义,更多信息可在中找到 http://www.python.org/dev/peps/pep-0008/ 。
模块的第一行可以是#!注释;典型版本的代码如下所示:
#!/usr/bin/env python3这用于帮助操作系统工具(如bash)找到可执行脚本文件的 Python 解释器。对于 Windows,这一行可能与#!C:\Python3\python.exe的行类似。
较旧的 Python2 模块可能包含一个编码注释,用于指定其余文本的编码。这可能类似于以下代码:
# -*- coding: utf-8 -*- Python3 避免了编码注释;操作系统编码信息足够。
模块的下一行应该是一个三引号模块 docstring,用于定义模块文件的内容。与其他 Python 文档字符串一样,文本的第一段应该是摘要。接下来应该对模块的内容、用途和用法进行更完整的定义。这可能包括 RST 标记,以便文档工具可以从 docstring 生成美观的结果。我们将在第 20 章、质量和文件中对此进行说明。
在 docstring 之后,我们可以包含任何版本控制信息。例如,我们可能有以下代码:
__version__ = "2.7.18" 这是一个模块全局变量,我们可以在应用程序的其他地方使用它来确定模块的版本号。这包括在 docstring 之后,但在模块主体之前。下面是模块的所有import语句。按照惯例,它们位于模块前部的一个大模块中。
import语句之后是模块的各种类和函数定义。为了确保它们能够正确工作并对正在阅读代码的人有意义,这些代码以所需的任何顺序呈现。
Java and C++ tend to focus on one class per file. That's a silly limitation. It doesn't apply to Python.
如果文件有很多类,我们可能会发现模块有点难以理解。如果我们发现自己使用大型评论广告牌将一个模块划分为多个部分,这就暗示我们所写的内容可能比单个模块更复杂。
公告牌注释如以下示例所示:
################################
# FUNCTIONS RELATED TO API USE #
################################与其使用公告牌式的注释,不如将模块分解为单独的模块。公告牌注释应成为新模块的文档字符串。在某些情况下,类定义可能是分解复杂模块的好主意
有时,模块中的全局变量很方便。logging模块利用它来跟踪应用程序可能创建的所有记录器。另一个例子是random模块创建Random类的默认实例的方式。这允许许多模块级函数为随机数提供简单的 API。我们不必创建random.Random的实例。
PEP-8 约定建议这些模块全局变量应具有ALL_CAPS样式名称,以使其可见。使用类似于pylint的工具进行代码质量检查将导致对全局变量的建议。
下一节将整个模块与模块项进行比较。
设计库模块的内容有两种方法。一些模块是一个集成的整体,而其他模块更像是松散相关项的集合。当我们将一个模块作为一个整体进行设计时,它通常会有几个类或函数,这些类或函数是该模块面向公众的 API。当我们将模块设计为松散相关项的集合时,每个单独的类或函数往往是独立的。
我们经常在导入和使用模块的方式中看到这种区别。我们将看三种变化:
- 使用
import some_module命令:这将导致对some_module.py模块进行评估,并将结果对象收集到一个名为some_module的名称空间中。这要求我们为模块中的所有对象使用限定名称,例如,some_module.this和some_module.that。使用限定名称使模块成为一个完整的整体。 - 使用
from some_module import this, that命令:这将导致对some_module.py模块文件进行求值,并且在当前本地名称空间中仅创建命名对象。我们现在可以使用没有模块名称空间的this或that作为限定符。使用非限定名称就是为什么模块看起来像是一个分离对象的集合。一个常见的例子是类似于from math import sqrt, sin, cos的语句,用于导入一些数学函数。 - 使用
from some_module import *命令:这将导入模块,并使所有非私有名称成为执行导入的命名空间的一部分。私有名称以_开头,不会作为导入的名称之一保留。我们可以通过在模块内提供一个__all__列表来明确限制模块导入的名称数量。这个字符串对象名称列表将由import *语句详细说明。我们经常使用__all__变量来隐藏作为构建模块一部分的实用程序函数,而不是提供给模块客户端的 API 的一部分。
当我们回顾卡片组的设计时,我们可以选择将套装作为默认情况下不导入的实现细节。如果我们有一个cards.py模块,我们可以包含以下代码:
from enum import Enum
__all__ = ["Deck", "Shoe"]
class Suit(str, Enum):
Club = "\N{BLACK CLUB SUIT}"
Diamond = "\N{BLACK DIAMOND SUIT}"
Heart = "\N{BLACK HEART SUIT}"
Spade = "\N{BLACK SPADE SUIT}"
class Card: ...
def card(rank: int, suit: Suit) -> Card: ...
class Deck: ...
class Shoe(Deck): ...__all__变量的使用使得Suit和Card名称可见。card()函数、Suit类、Deck类是默认不导入的实现细节。例如,假设我们执行以下代码:
from cards import * 前面的语句只会在应用程序脚本中创建Deck和Shoe,因为它们是__all__变量中唯一显式给出的名称。
当我们执行以下命令时,它将导入模块,而不会将任何名称放入全局命名空间:
import cards 即使没有导入到名称空间中,我们仍然可以访问限定的cards.card()方法来创建Card实例。
每种技术都有优点和缺点。整个模块需要使用模块名称作为限定符;这使对象的原点显式。从模块中导入项会缩短它们的名称,这可以使复杂的编程更紧凑、更容易理解。
在下一节中,我们将看到如何设计包。
设计包时的一个重要考虑因素是不要。蟒蛇的禅诗(也称为import this)包括以下几行:
"Flat is better than nested"
我们可以在 Python 标准库中看到这一点。图书馆的结构相对平坦;嵌套模块很少。深度嵌套的包可能会被过度使用。我们应该对过度筑巢持怀疑态度。
Python 包是一个带有额外文件__init__.py的目录。目录名必须是正确的 Python 名称。操作系统名称包含许多 Python 名称中不允许的字符。
我们经常看到包的三种设计模式:
- 简单包是一个包含空
__init__.py文件的目录。此包名称将成为包内模块集合的限定符。我们将使用以下代码从包中选择一个模块:
import package.module - 模块包混合可以有一个
__init__.py文件,该文件实际上是一个模块定义。此顶级模块将从包内的模块导入元素,并通过__init__模块将其公开。我们将使用以下代码导入整个包,就像导入单个模块一样:
import package - 模块包 hybrid 的另一个变体使用
__init__.py文件在备选实现中进行选择。我们通过代码将包当作单个模块来使用,如下例所示:
import package 第一种包相对简单。我们将一个__init__.py文件添加到一个目录,然后,我们就完成了包的创建。另外两个更为复杂;我们将详细介绍这些。
让我们看看如何设计一个混合模块包。
在某些情况下,设计演变为非常复杂的模块;它可能变得如此复杂,以至于一个文件就成了一个坏主意。当我们开始把 Billboard 评论放在一个模块中时,我们应该考虑将一个复杂的模块重构成一个由几个较小的模块构建的包。
在这种情况下,包可以像下面这种结构一样简单。我们可以创建一个目录,名为blackjack;在此目录中,__init__.py文件类似于以下示例:
"""Blackjack package"""
from blackjack.cards import Shoe
from blackjack.player import Strategy_1, Strategy_2
from blackjack.casino import ReSplit, NoReSplit, NoReSplitAces, Hit17, Stand17
from blackjack.simulator import Table, Player, Simulate
from betting import Flat, Martingale, OneThreeTwoSix 这向我们展示了如何构建一个类似模块的包,它实际上是从子模块导入的部件的组装。整个应用程序可以命名为simulate.py,包含如下代码:
from blackjack import *
table = Table(
decks=6, limit=500, dealer=Hit17(), split=NoReSplitAces(),
payout=(3,2))
player = Player(
play=Strategy_1(), betting=Martingale(), rounds=100,
stake=100)
simulate = Simulate(table, player, 100)
for result in simulate:
print(result) 这段代码向我们展示了如何使用from blackjack import *来创建大量源于blackjack包中许多其他模块的类定义。具体来说,有一个完整的blackjack包,其中包含以下模块:
blackjack.cards包包含Card、Deck和Shoe定义。blackjack.player包包含各种游戏策略。blackjack.casino包包含许多自定义赌场规则变化方式的类。blackjack.simulator包包含顶级模拟工具。- 应用程序还使用
betting包定义各种博彩策略,这些策略不是 21 点独有的,但适用于任何赌场游戏。
此软件包的体系结构可以简化我们升级或扩展设计的方式。如果每个模块都更小、更集中,那么就更容易阅读和理解。单独更新每个模块可能更简单。
让我们看看如何设计具有替代实现的包。
在某些情况下,我们会有一个顶层__init__.py文件,在包目录中的一些替代实现之间进行选择。决策可能基于平台、CPU 体系结构或操作系统库的可用性。
对于具有替代实现的包,有两种常见的设计模式和一种不太常见的设计模式:
- 检查
platform或sys以确定实现的细节,并决定使用if语句导入什么。 - 尝试
import并使用try块异常处理来解决配置细节。 - 作为一种不太常见的替代方法,应用程序可以检查配置参数以确定应该导入什么。这有点复杂。我们在导入应用程序配置和基于配置导入其他应用程序模块之间存在一个排序问题。如果没有这一潜在的复杂步骤序列,导入要简单得多。
我们将展示一个名为some_algorithm的假设包的结构。这将是顶级目录的名称。要创建复杂包,some_algorithm目录必须包含多个文件,如下所述:
__init__.py模块将决定导入两个实现中的哪一个。定义包时需要此名称。此模块的内容将显示在以下代码块中。abstraction.py可以为这两个实现提供任何必要的抽象定义。使用单个通用模块有助于为mypy检查提供一致的类型提示。- 每个实现都是包中的另一个模块。我们将概述两种实现选择,称为
short_module.py和long_module.py。这些模块名称在包外都不可见。
这是一个some_algorithm包裹的__init__.py。该模块根据平台信息选择一个实现。这可能类似于以下示例:
import sys
from typing import Type
from Chapter_19.some_algorithm.abstraction import AbstractSomeAlgorithm
SomeAlgorithm: Type[AbstractSomeAlgorithm]
if sys.platform.endswith( "32" ):
from Chapter_19.some_algorithm.short_version import *
SomeAlgorithm = Implementation_Short
else :
from Chapter_19.some_algorithm.long_version import *
SomeAlgorithm = Implementation_Long此模块基于两个可用的实现模块之一定义SomeAlgorithm类。对于 32 位平台,short_version.py模块提供一个名为Implementation_Short的类,将使用该类。对于 64 位平台,long_version.py模块提供Implementation_Long类。
我们还需要在some_algorithm包中提供两个模块;long_version.py模块提供适合 64 位体系结构的实现;short_version模块提供了一种替代实现。设计必须具有模块同构性;这类似于类同构。这两个模块必须包含具有相同名称和相同 API 的类和函数。
如果两个文件都定义了一个名为SomeClass的类,那么我们可以在应用程序中编写以下代码:
from Chapter_19 import some_algorithm
x = some_algorithm.SomeAlgorithm() 我们可以像导入模块一样导入some_algorithm包。这将导入some_algorithm/__init__.py模块。此模块定位适当的实现并提供所需的类定义。
每个实现都是相似的。两者都将合并抽象类,以便向mypy等工具明确这两个实现是相同的。以下是short_implementation.py模块的内容。
from .abstraction import AbstractSomeAlgorithm
class Implementation_Short(AbstractSomeAlgorithm):
def value( self ) -> int :
return 42此模块导入抽象类定义。然后定义一个合适的子类。此开销有助于mypy确认定义的类是抽象类定义的完整实现。
对于复杂的应用程序,这种替代实现策略非常有用。它允许单个代码库在许多环境中工作,在这些环境中,配置更改尽可能晚地在部署管道中进行。
下一节介绍如何使用ImportError异常。
if语句的另一种替代方法是使用try语句定位候选实现。当存在不同的分布时,此技术效果良好。通常,特定于平台的分发版可能包含平台特有的文件。
在第 16 章日志和警告模块中,我们向您展示了在出现配置错误或问题时提供警告的设计模式。在某些情况下,跟踪变体配置不值得警告,因为变体配置是一种设计功能。
下面是一个some_algorithm包的__init__.py,它根据包中模块文件的可用性选择实现:
try:
from some_algorithm.long_version import *
except ImportError as e:
from some_algorithm.short_version import * 这取决于有两个不同的发行版,其中包括some_algorithm/long_version.py文件或some_algorithm/short_version.py文件。如果找不到some_algorithm.long_version模块,则导入some_alogirithm.short_version。实现模块的内容与前面代码块中显示的内容相同。只有__init__.py模块会改变。
创建变体发行版超出了本书的范围。Python 打包机构 PyPA 有文档说明如何创建特定于平台的 wheel 和 egg 文件。
这种 try/except 技术不能扩展到两个或三个以上的替代实现。随着选择数量的增加,except块将变得非常嵌套
我们来看看如何设计一个主脚本和主模块。
一个顶级主脚本将执行我们的应用程序。在某些情况下,我们可能会有多个主脚本,因为我们的应用程序会做一些事情。我们有三种编写顶级主脚本的一般方法:
- 对于非常小的应用程序,我们可以使用
python3 some_script.py运行应用程序。这是我们在大多数示例中向您展示的样式。 - 对于一些更大的应用程序,我们将有一个或多个文件,用 OS
chmod +x命令将其标记为可执行文件。通过setup.py安装,我们可以将这些可执行文件放入 Python 的scripts目录。我们在命令行中使用some_script.py运行这些应用程序。 - 对于复杂的应用程序,我们可以在应用程序包中添加一个
__main__.py模块。为了提供一个整洁的界面,标准库提供了runpy模块和-m命令行选项,将使用这个特殊命名的模块。我们可以用python3 -m some_app来运行这个。
我们将详细介绍最后两个选项。
要使用可执行脚本文件,我们有一个两步实现:使其可执行并包括一行#!(sharp bang,或shebang)。我们来看看细节。
将脚本文件标记为可执行文件的 Linux 命令如下所示:
chmod +x some_script.pyshebang 线通常类似于以下示例:
#!/usr/bin/env python3此行将指示操作系统使用命名程序执行脚本文件。在本例中,我们使用/usr/bin/env程序定位python3程序来运行脚本。python3程序将获得脚本文件作为其输入。
当脚本文件被标记为可执行文件,并且该文件包含#!行时,我们可以在命令行中使用some_script.py来运行脚本。
对于更复杂的应用程序,此顶级脚本可能会导入其他模块和包。重要的是,这些顶级可执行脚本文件应尽可能简单,以促进各种组件的重用。主要设计原则包括以下内容:
- 使脚本模块尽可能小。任何复杂性都应存在于导入的模块中
- 脚本模块不应具有新的或独特的代码。它应该强调从其他模块导入和使用代码。
- 从长远来看,没有一个程序是独立的。任何有价值的软件都将被扩展并重新调整用途。甚至应用程序的顶级脚本也可以集成到更大的包装器中。
我们的设计目标必须始终包括复合、大规模编程的思想。主脚本文件应尽可能短。下面是我们的例子:
import simulation
if __name__ == "__main__":
with simulation.Setup_Logging():
with simulation.Build_Config() as config:
main = simulation.Simulate_Command()
main.configure(config)
main.run() 所有相关工作代码均从名为simulation的模块导入。本模块中没有引入独特的新代码。
在下一节中,我们将看到如何创建_main_模块。
为了使用runpy接口,我们必须在应用程序的顶级包中添加一个小的__main__.py模块。我们强调了这个顶级可执行脚本文件的设计。
我们应该始终允许重构应用程序以构建更大、更复杂的复合应用程序。如果__main__.py中隐藏了功能,我们需要将其拉入一个具有清晰、可导入名称的模块中,以便其他应用程序可以使用它。
__main__.py模块遵循上一节所示的代码,创建一个可执行脚本文件。唯一真正的区别是使用特殊名称__main__.py,使 Python 运行时更容易找到包的主模块。它还使其他人更容易找到包处理的主要部分。
Python 编程中的一个重要注意事项是将多个较小的程序组合成有用的较大程序。在下一节中,我们将研究聚合,或者大型中的编程。
下面的示例向我们展示了为什么我们不应该将唯一的工作代码放入__main__.py模块。我们将向您展示一个基于扩展现有包的快速假设示例。
假设我们有一个名为analysis的通用统计包,其中包含一个顶级__main__.py模块。这实现了一个命令行界面,该界面将计算给定 CSV 文件的描述性统计信息。此应用程序具有如下命令行 API:
python3 -m analysis -c 10 some_file.csv 此命令使用-c选项指定要分析的列。输入文件名在命令行上作为位置参数提供。
让我们进一步假设,我们有一个可怕的设计问题。我们在analysis/__main__.py模块中定义了一个高级函数analyze()。以下是__main__.py模块的概要:
import argparse
from analysis import some_algorithm
def analyze(config: argparse.Namespace) -> None: ...
def main(argv: List[str] = sys.argv[1:]) -> None: ...
if __name__ == "__main__":
main(sys.argv[1:])analysis包包括一个__main__.py模块。这个模块不仅仅只是运行在别处定义的函数和类。它还包括一个独特的、可重用的函数定义analyze()。在我们尝试重用analysis包的元素之前,这不是一个问题。
我们的目标是将其与 21 点模拟相结合。由于这里的设计错误,这不会很好地实现。我们可能会认为我们可以做到:
import analysis
import simulation
import types
def sim_and_analyze():
with simulation.Build_Config() as config_sim:
config_sim.outputfile = "some_file.csv"
s = simulation.Simulate()
s.configure(config_sim)
s.run()
config_stats = types.SimpleNamespace(
column=10, input="some_file.csv")
analysis.analyze(config_stats) 我们尝试使用analysis.analyze(),假设有用的analyze()函数是一个简单模块的一部分。Python 命名规则使它看起来好像是一个具有名为analyze()的函数的模块。大多数情况下,模块和包结构的实现细节对于成功使用并不重要。然而,这是大型中重用和编程的一个例子,其中结构需要透明。
通过在__main__中定义函数,这种简单的组合变得不必要的困难。我们希望避免被迫这样做:
def analyze(column, filename):
import subprocess
subprocess.run(
["python3", "-m", "stats", "-c", column, filename])我们不需要通过命令行 API 创建复合 Python 应用程序。为了创建现有应用程序的合理组合,我们可能被迫重构analysis/__main__.py以删除此模块中的任何定义,并将其作为一个整体推送到包中。
下一节将介绍如何设计长期运行的应用程序。
长时间运行的应用程序服务器将从某种队列读取请求,并制定对这些请求的响应。在许多情况下,我们利用 HTTP 协议将应用程序服务器构建到 web 服务器框架中。关于如何按照web 服务器网关接口(WSGI设计模式实现 RESTful web 服务的详细信息,请参见第 13 章、传输和共享对象。
桌面 GUI 应用程序有许多与服务器相同的功能。它从包含鼠标和键盘操作的队列中读取事件。它处理每个事件并给出某种 GUI 响应。在某些情况下,响应可能是对文本小部件的小更新。在其他情况下,可能会打开或关闭文件,并且菜单项的状态可能会更改。
在这两种情况下,应用程序的中心功能都是一个永远运行的循环,用于处理事件或请求。因为这些循环很简单,所以它们通常是框架的一部分。对于 GUI 应用程序,我们可能在以下代码中有这样的循环:
root = Tkinter.Tk()
app = Application(root)
root.mainloop() 对于Tkinter应用程序,顶级小部件的mainloop()获取每个 GUI 事件,并将其交给适当的框架组件进行处理。当对象处理事件发生在示例中的顶级小部件root上时,执行quit()方法,循环将正常终止。
对于基于 WSGI 的 web 服务器框架,我们可能有一个类似以下代码的循环:
httpd = make_server('', 8080, debug)
httpd.serve_forever() 在本例中,服务器的serve_forever()方法获取每个请求,并将其交给本例中的应用程序debug进行处理。当应用程序执行服务器的shutdown()方法时,循环将正常终止。
我们通常有一些额外的要求来区分长期运行的应用程序:
- 稳健:在处理外部操作系统或网络资源时,必须成功应对超时和其他错误。一个允许插件和扩展的应用程序框架,可能会有一个扩展组件包含一个错误,而整个框架必须优雅地处理这个错误。Python 的普通异常处理非常适合编写健壮的服务器。在第 15 章、设计原则和模式中,我们讨论了一些高层注意事项。
- 可审核:简单、集中的日志并不总是足够的。在第 16 章日志和警告模块中,我们介绍了创建多个日志以支持安全或财务审计需求的技术。
- 可调试:普通的单元测试和集成测试减少了对复杂调试工具的需求。但是,如果不提供一些调试支持,外部资源和软件插件或扩展可能会造成难以处理的复杂性。更复杂的日志记录可能会有所帮助。
- 可配置:除了简单的技术峰值外,我们希望能够启用或禁用应用程序功能。例如,启用或禁用调试日志是常见的配置更改。在某些情况下,我们希望在不完全停止和重新启动应用程序的情况下进行这些更改。在第 14 章、配置文件和持久化中,我们介绍了一些配置应用程序的技术。在第 18 章处理命令行中,我们扩展了这些技术。
- 可控。简单的长时间运行的服务器可以简单地被杀死,以便用不同的配置重新启动它。为了确保缓冲区被正确刷新,操作系统资源被正确释放,最好使用
SIGKILL以外的信号来强制终止。Python 在signal模块中提供了信号处理功能。
动态配置的最后两个要求和关闭服务器的干净方式,使我们将主输入流与辅助控制输入分离。此控制输入可提供额外的配置或关机请求。
我们有多种方法通过附加通道提供异步输入:
- 最简单的方法之一是使用
multiprocessing模块创建队列。在这种情况下,一个简单的管理客户端可以与该队列交互,以控制或查询服务器或 GUI。关于multiprocessing的更多示例,请参见第 13 章、发送和共享对象。我们可以在管理客户端和服务器之间传输控制或状态对象。 - 较低级别的技术在Python 标准库的网络和进程间通信部分中定义。这些模块还可用于与长时间运行的服务器或 GUI 应用程序协调。
- 使用状态的持久存储,以便长时间运行的进程可以使用不同的配置终止和重新启动。我们在第 10 章、序列化和保存—JSON、YAML Pickle、CSV 和 Shelve 中研究了持久化技术;第 11 章*通过货架存储和检索物品;*和第 12 章通过 SQLite存储和检索对象。其中任何一个都可以用来保存服务器的状态,从而实现无缝重启。
基于 web 的服务器有两种常见的用例:
- 有些服务器提供 RESTful API。
- 一些服务器专注于提供用户体验(UX)。
RESTful API 服务器通常由移动应用程序使用,UX 是单独打包的。RESTful API 服务器通常维护持久数据库中的状态。作为服务器可靠性工程的一部分,可能有多个服务器副本来共享工作负载,软件升级通常通过引入新版本并将工作负载从旧服务器转移到新服务器来实现。当一个服务器有多个副本时,则需要共享持久化存储来保持用户事务的状态,因为每个请求都可以由不同的服务器处理。动态配置和控制是通过转移工作负载和停止旧服务器来处理的,因此工作由新服务器来处理。
从 web 服务器提供 UX 通常需要在服务器上维护会话状态。在这种情况下,终止服务器意味着用户的会话状态丢失。我们不想让愤怒的用户因为重新配置了服务器而丢失购物车的内容。如果会话信息缓存在数据库中,发送给用户的 cookie 只不过是一个数据库密钥,那么我们就可以创建非常健壮的 web 服务器。FlaskSession 项目(更多信息见)https://pythonhosted.org/Flask-Session 提供了许多将会话信息保存在缓存中的方法,以便停止和重新启动服务器。
下一节介绍如何将代码组织为src、scripts、tests和docs。
正如我们在上一节中所指出的,在 Python 项目中根本不需要复杂的目录结构。理想的结构遵循标准库,是一个相对平坦的模块列表。这将包括间接费用,如setup.py和README文件。这是令人愉快的简单和易于使用。
当模块和包变得更复杂时,我们通常需要强加一些结构。对于复杂的应用程序,一种常见的方法是将 Python 代码分成几个包。为了使示例具体化,我们假设我们的应用程序名为my_app:
my_app/src:此目录包含所有工作应用程序代码。所有不同的模块和包都在这里。在某些情况下,此src目录中只有一个顶级包名。在其他情况下,可能会有许多模块或包列在src下。src目录的优点是可以简单地使用mypy、pylint或pyflakes进行静态分析。my_app/scripts或my_app/bin:此目录可以包含构成 OS 级命令行 API 的任何脚本。这些脚本可以通过setup.py复制到 Pythonscripts目录。如前所述,这些应该类似于__main__.py模块;它们应该非常短,并且可以被认为是 Python 代码的 OS 文件名别名。my_app/tests:该目录可以有各种测试模块。大多数模块的名称都以test_开头,因此它们可以被pytest自动发现。my_app/docs:此目录将包含文档。我们将在第 20 章、质量和文档中了解这一点。
顶级目录名my_app可能会增加一个版本号,以允许有多个分支或版本可用,从而将my_app-v1.1作为顶级目录名。更好的策略是使用复杂的版本控制工具,如git。这可以管理在单个目录结构中具有多个版本的软件。使用git命令切换分支比在相邻目录中尝试多个分支效果更好。
顶层目录将包含setup.py文件,用于将应用程序安装到 Python 的标准库结构中。参见发布 Python 模块(https://docs.python.org/3/distributing/index.html 了解更多信息。此外,当然,一个README.rst文件将被放置在此目录中。这里找到的其他常见文件有tox.ini文件,用于配置整个测试环境,以及environment.yaml文件,用于构建用于使用应用程序的 Conda 环境。
当应用程序模块和测试模块位于单独的目录中时,我们需要在运行测试时将应用程序称为已安装的模块。我们可以使用PYTHONPATH环境变量进行此操作。我们可以按照以下代码运行测试套件:
PYTHONPATH=my_app/src python3 -m test 我们在执行命令的同一行上设置一个环境变量。这可能令人惊讶,但这是bash外壳的一流功能。这允许我们对PYTHONPATH环境变量进行非常本地化的重写。
下一节将展示如何安装 Python 模块。
我们有几种安装 Python 模块或包的技术:
- 我们可以编写
setup.py并使用分发实用程序模块distutils将包安装到 Python 的lib/site-packages目录中。Python 打包授权文档对此进行了详细描述。见https://www.pypa.io/en/latest/ 供参考。构建供他人安装的软件可能很复杂,通常需要复杂的测试用例,使用tox工具来构建环境、运行测试和创建分发包。参见https://tox.readthedocs.io/en/latest/ 了解更多信息。 - 我们可以设置
PYTHONPATH环境变量以包含我们的包和模块。我们可以在 shell 中临时设置,也可以通过编辑我们的~/.bash_profile或系统的/etc/profile进行更永久的设置。我们将在本节后面的部分中了解这一点。 - 当前工作目录也是一个包。它总是排在
sys.path列表的第一位。在处理一个简单的单模块 Python 应用程序时,这非常方便
设置环境变量可以是暂时的,也可以是持久的。我们可以在交互式会话中使用命令进行设置,如下所示:
export PYTHONPATH=~/my_app-v1.2/src这将PYTHONPATH设置为在搜索模块时包含命名目录。通过对环境的简单更改,可以有效地安装模块。Python 的lib/site-packages中没有写入任何内容。
这是一个瞬态设置,在结束终端会话时可能会丢失。另一种方法是更新我们的~/.bash_profile,以包括对环境的更永久性改变。我们只需将该export行附加到.bash_profile,以便每次登录时都使用该包。
对于 web 服务器应用程序,可能需要更新 Apache、NGINX 或 uWSGI 配置,以包括对必要的 Python 模块的访问。创建 web 服务器有两种方法:
- 安装所有东西。使用 Python 包安装来创建整个 web 服务器。这将涉及为定制的应用程序组件使用本地包索引。在持续集成/持续部署(CI/CD)的集成阶段将完成更多工作。
- 仅安装开源。使用 Python 包安装开源组件。对定制的应用组件使用 Git checkout 和
PYTHONPATH。更多工作在 CI/CD 的部署阶段完成。
这两种方法都能很好地发挥作用。虽然安装一切方法具有简单的表面优势,但它可以创建所有定制应用软件的可安装版本。对于专有的、不会作为开源发布的软件,创建可安装版本似乎不需要进行集成工作。
只安装开源组件会导致更复杂的应用程序部署。除了对专有组件进行git检查外,还将安装conda(或pip开源组件
我们在设计模块和包时考虑了许多因素。模块和单例类之间有着深刻的相似之处。当我们设计一个模块时,结构封装和处理的基本问题与类设计一样重要。
当我们设计一个包时,我们需要对深层嵌套结构的需要持怀疑态度。当存在不同的实现时,我们需要使用包;我们研究了一些处理这种变化的方法。我们可能还需要定义一个包,将多个模块组合成一个类似模块的包。我们看了__init__.py如何从包装内进口。
我们的包装技术层次很深。我们可以简单地将功能组织为定义的功能。我们可以将定义的函数及其相关数据组合成一个类。然后我们可以将相关的类组合成一个模块。最后,我们可以将相关模块组合成一个包。
当我们认为软件是一种捕获知识和表示的语言时,我们必须考虑一个类或模块是什么意思。模块是 Python 软件构建、分发、使用和重用的单元。除了极少数例外,模块必须围绕重用的可能性进行设计。
在大多数情况下,我们将使用一个类,因为我们希望有多个对象作为该类的实例。一个类通常(但并非总是)会有状态实例变量。
当我们看到只有一个实例的类时,并不完全清楚类是否真的是必要的。独立函数可能与单个实例类一样有意义。在某些情况下,具有单独功能的模块可能是一种合适的设计,因为模块本身就是单例的。
一般期望是定义的简单有状态集合。模块是一个名称空间,它也可以包含一些局部变量。这与类定义类似,但缺乏创建实例的能力。
虽然我们可以创建不可变的类(使用__slots__、扩展NamedTuple、使用冻结的@dataclass或重写属性设置器方法),但我们不能轻松创建不可变的模块。不可变的模块对象似乎没有用例。
小型应用程序可以是单个模块。更大的应用程序通常是一个包。与模块设计一样,包的设计应该是为了重用。一个更大的应用程序包应该包括一个__main__模块。
在下一章中,我们将整合一些面向对象的设计技术。我们将了解设计和实现的总体质量。一个考虑是向其他人保证我们的软件是可信的。值得信赖的软件的一个方面是连贯、易于使用的文档。