Python-开发高级教程-全-

Python 开发高级教程(全)

原文:Advanced Python Development

协议:CC BY-NC-SA 4.0

一、原型和环境

在这一章中,我们将探索不同的方法来试验不同的 Python 函数做什么,以及什么时候是使用这些不同选项的合适时机。使用其中一种方法,我们将构建一些简单的函数来提取我们将要聚集的第一批数据,并看看如何将它们构建到一个简单的命令行工具中。

Python 中的原型

在任何 Python 项目中,从花几个小时开发的东西到运行几年的项目,都需要原型化函数。这可能是你做的第一件事,或者它可能在项目中期悄悄出现,但迟早你会发现自己在 Python shell 中尝试代码。

有两种广泛的方法来实现原型:要么运行一段代码并查看结果,要么一次执行一条语句并查看中间结果。一般来说,一个接一个地执行语句更有效率,但是有时如果有你已经确信的代码块,恢复运行代码块似乎更容易。

Python shell(也称为 REPL,表示 R ead、 E val、 P rint、 L oop)是大多数人对使用 Python 的第一次介绍。能够启动解释器并实时输入命令是直接进入编码的一种强有力的方式。它允许您运行命令并立即看到它们的结果,然后调整您的输入而不删除任何变量的值。与编译语言相比,编译语言的开发流程是围绕编译文件然后运行可执行文件来构建的。对于像 Python 这样的解释型语言中的简单程序来说,等待时间要短得多。

用 REPL 制作原型

REPL 的优势很大程度上在于尝试简单的代码并获得对函数如何工作的直观理解。它不太适合有大量流控制的情况,因为它不太容忍错误。如果您在键入循环体的一部分时出错,您将不得不重新开始,而不仅仅是编辑错误的行。用一行 Python 代码修改一个变量并查看输出,这非常适合将 REPL 用于原型制作。

例如,我经常发现很难记住内置函数filter(...)是如何工作的。有几种方法可以提醒自己:我可以在 Python 网站上或者使用我的代码编辑器/IDE 查看这个函数的文档。或者,我可以尝试在我的代码中使用它,然后检查我得到的值是否是我所期望的,或者我可以使用 REPL 来查找对文档的引用或者只是尝试该函数。

在实践中,我通常发现自己在尝试一些东西。下面是一个典型的例子,我第一次尝试反转了参数,第二次尝试提醒我 filter 返回一个自定义对象,而不是一个元组或列表,第三次尝试提醒我 filter 只包含匹配条件的元素,而不是排除匹配条件的元素。

>>> filter(range(10), lambda x: x == 5)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'function' object is not iterable
>>> filter(lambda x: x == 5, range(10))
<filter object at 0x033854F0>
>>> tuple(filter(lambda x: x == 5, range(10)))
(5,)

Note

内置函数帮助(...)在试图理解函数如何工作时是非常宝贵的。由于 filter 有一个清晰的 docstring,调用help(filter)并读取信息可能会更简单。然而,当将多个函数调用链接在一起时,尤其是当试图理解现有代码时,能够试验样本数据并查看交互是如何进行的是非常有帮助的。

如果我们尝试将 REPL 用于涉及更多流量控制的任务,比如著名的面试编码测试问题 FizzBuzz(清单 1-1 ),我们可以看到它不可饶恕的本质。

for num in range(1, 101):
    val = ''
    if num % 3 == 0:
        val += 'Fizz'
    if num % 5 == 0:
        val += 'Buzz'
    if not val:
        val = str(num)
    print(val)

Listing 1-1fizzbuzz.py – a typical implementation

如果我们要一步一步地构建它,我们可以从创建一个输出不变数字的循环开始:

>>> for num in range(1, 101):
...     print(num)
...
1
.
.
.
98
99
100

此时,我们将在新行中看到数字 1 到 100,因此我们将开始添加逻辑:

>>> for num in range(1, 101):
...     if num % 3 == 0:
...         print('Fizz')
...     else:
...         print(num)
...
1
.
.
.
98
Fizz
100

每次我们这样做的时候,我们都不得不重新输入我们之前输入的代码,有时有小的改动,有时一字不差。这些行一旦输入就不可编辑,所以任何打字错误都意味着整个循环需要重新输入。

您可以决定原型化循环体而不是整个循环,以便更容易地跟踪条件的动作。在这个例子中,从 1 到 14 的 n 值是用三路 if 语句正确生成的,其中n=15是第一个被错误渲染的。虽然这是在循环体的中间,但是很难检查条件交互的方式。

在这里你会发现 REPL 和剧本对缩进的解释之间的第一个区别。Python 解释器对在 REPL 模式下如何缩进的解释比执行脚本时更严格,要求在任何返回到缩进级别 0 的取消缩进后面有一个空行。

>>> num = 15
>>> if num % 3 == 0:
...     print('Fizz')
... if num % 5 == 0:
  File "<stdin>", line 3
    if num % 5 == 0:
     ^
SyntaxError: invalid syntax

此外,当返回到缩进级别 0 时,REPL 只允许一个空行,而在 Python 文件中,它被视为最后一个缩进级别的隐式延续。清单 1-2 (与清单 1-1 的区别仅在于增加了空行)作为python fizzbuzz_blank_lines.py调用时有效。

for num in range(1, 101):
    val = ''
    if num % 3 == 0:
        val += 'Fizz'
    if num % 5 == 0:
        val += 'Buzz'

    if not val:
        val = str(num)

    print(val)

Listing 1-2fizzbuzz_blank_lines.py

然而,由于缩进解析规则的差异,将清单 1-2 的内容输入 Python 解释器会导致以下错误:

>>> for num in range(1, 101):
...     val = ''
...     if num % 3 == 0:
...         val += 'Fizz'
...     if num % 5 == 0:
...         val += 'Buzz'
...
>>>     if not val:
  File "<stdin>", line 1
    if not val:
    ^
IndentationError: unexpected indent
>>>         val = str(num)
  File "<stdin>", line 1
    val = str(num)
    ^
IndentationError: unexpected indent
>>>
>>>     print(val)
  File "<stdin>", line 1
    print(val)
    ^
IndentationError: unexpected indent

当您习惯于在文件中编写 Python 时,使用 REPL 来构建循环或条件的原型很容易出错。犯错误和不得不重新输入代码的挫败感足以抵消使用这种方法比使用简单脚本节省的时间。虽然可以使用箭头键滚动回先前输入的行,但多行构造(如循环)不会组合在一起,因此很难重新运行循环体。在整个会话中使用>>>...提示也使得复制和粘贴先前的行变得困难,无论是重新运行它们还是将它们集成到一个文件中。

使用 Python 脚本构建原型

很有可能通过编写一个简单的 Python 脚本并运行它直到它返回正确的结果来构建代码原型。与使用 REPL 不同,这可以确保在出错时很容易重新运行代码,并且代码存储在文件中,而不是存储在终端的回滚缓冲区中。不幸的是,这确实意味着不能在代码运行时与代码进行交互,这导致它被戏称为“printf 调试”,以 C 打印变量的功能命名。

顾名思义,从脚本执行中获取信息的唯一可行的方法是使用print(...)函数将数据记录到控制台窗口。在我们的示例中,通常会在循环体中添加一个打印,以查看每次迭代发生了什么:

Tip

f-string 对于 printf 调试很有用,因为它们允许您将变量插入到一个字符串中,而不需要额外的字符串格式化操作。

for num in range(1,101):
    print(f"n: {num} n%3: {num%3} n%5: {num%5}")

以下是结果:

n: 1 n%3: 1 n%5: 1
.
.
.
n: 98 n%3: 2 n%5: 3
n: 99 n%3: 0 n%5: 4
n: 100 n%3: 1 n%5: 0

这提供了一个容易理解的视图,显示脚本正在做什么,但是它确实需要重复一些逻辑。这种重复使错误更容易被遗漏,从而导致大量的时间损失。与 REPL 相比,代码被永久存储是其最大的优势,但是它为程序员提供了较差的用户体验。打字错误和简单的错误会变得令人沮丧,因为从编辑文件到在终端中运行它需要进行上下文切换。看一眼你需要的信息也可能更困难,这取决于你如何组织你的打印报表。尽管有这些缺陷,但它的简单性使得向现有系统添加调试语句非常容易,因此这是最常用的调试方法之一,尤其是在试图对问题有一个广泛的理解时。

使用脚本和 pdb 构建原型

内置的 Python 调试器 pdb 是任何 Python 开发人员的武器库中最有用的工具。这是调试复杂代码的最有效方法,也是检查 Python 脚本在多级表达式(如列表理解)中做什么的唯一方法。 3

在许多方面,原型代码是调试的一种特殊形式。我们知道我们写的代码是不完整的,并且包含错误,但是我们没有试图找到一个缺陷,而是试图分阶段建立复杂性。pdb 的许多有助于调试的特性使这变得更加容易。

当您启动 pdb 会话时,您会看到一个(Pdb)提示,允许您控制调试器。在我看来,最重要的命令是sn extbc ontinuep retty p rintd ebug4

stepnext都执行当前语句并移动到下一条语句。他们对“下一个”语句的看法不同。Step 移动到下一条语句,不管它在哪里,所以如果当前行包含一个函数调用,下一行就是该函数的第一行。Next 不会将执行移到该函数中;它认为下一条语句是当前函数中的下一条语句。如果你想检查一个函数调用在做什么,那么就进入它。如果您相信函数正在做正确的事情,使用 next 来掩饰它的实现并获得结果。

breakcontinue允许代码的更长部分在没有直接检查的情况下运行。break用于指定要返回到 pdb 提示符的行号,并带有在该范围内评估的可选条件,例如break 20 x==1continue命令返回到正常的执行流程;除非遇到另一个断点,否则不会返回到 pdb 提示符。

Tip

如果您发现可视化状态显示更加自然,您可能会发现很难跟踪您在调试会话中的位置。我建议您安装 pdb++调试器,它会显示一个代码清单,并突出显示当前行。ide,比如 PyCharm,更进一步,它允许你在一个正在运行的程序中设置断点,并直接从你的编辑器窗口控制步进。

最后,debug允许您指定任意 python 表达式来单步执行。这让您可以从 pdb 提示符中调用包含任何数据的任何函数,如果您在意识到错误所在之前已经使用了nextcontinue来传递一个点,这将非常有用。它作为debug somefunction()被调用,并修改(Pdb)提示,通过添加额外的一对括号让您知道您在嵌套的 pdb 会话中,使提示成为((Pdb))5

事后调试

有两种调用 pdb 的常见方法,要么在代码中显式调用,要么直接调用所谓的“事后调试”事后调试在 pdb 中启动一个脚本,如果出现异常,将触发 pdb。它是通过使用python -m pdb yourscript.py而不是python yourscript.py运行的。脚本不会自动启动;您将看到一个 pdb 提示,允许您设置断点。要开始执行脚本,您应该使用continue命令。当您设置的断点被触发或程序终止时,您将返回到 pdb 提示符。如果程序因错误而终止,它允许您查看错误发生时设置的变量。

或者,您可以使用 step 命令逐个运行文件中的语句;然而,对于除了最简单的脚本之外的所有脚本,最好在您希望开始调试的地方设置一个断点,并从那里开始调试。

以下是在 pdb 中运行清单 1-1 并设置条件断点(输出略)的结果:

> python -m pdb fizzbuzz.py
> c:\fizzbuzz_pdb.py(1)<module>()
-> def fizzbuzz(num):
(Pdb) break 2, num==15
Breakpoint 1 at c:\fizzbuzz.py:2
(Pdb) continue
1
.
.
.
13
14
> c:\fizzbuzz.py(2)fizzbuzz()
-> val = ''
(Pdb) p num
15

当与前面的基于脚本的方法结合使用时,这种风格效果很好。它允许您在代码执行的各个阶段设置任意断点,并在您的代码触发异常时自动提供 pdb 提示,而无需您事先知道发生了什么错误以及错误发生在哪里。

断点功能

内置的breakpoint()6允许你精确地指定 pdb 在程序中控制的位置。当调用这个函数时,执行立即停止,并显示一个 pdb 提示符。它的行为就像先前已经在当前位置设置了 pdb 断点一样。通常在 if 语句或异常处理程序中使用breakpoint(),以模拟调用 pdb 提示的条件断点和事后调试风格。尽管这意味着要修改源代码(因此不适合调试生产问题),但它消除了每次运行程序时设置断点的需要。

在计算值 15 时调试 fizzbuzz 脚本可以通过添加一个新条件来查找num == 15并将breakpoint()放入主体中来完成,如清单 1-3 所示。

for num in range(1, 101):
    val = ''
    if num == 15:
        breakpoint()
    if num % 3 == 0:
        val += 'Fizz'
    if num % 5 == 0:
        val += 'Buzz'
    if not val:
        val = str(num)
    print(val)

Listing 1-3fizzbuzz_with_breakpoint.py

要在构建原型时使用这种风格,创建一个简单的 Python 文件,其中包含您认为可能需要的导入和您知道自己拥有的任何测试数据。然后,在文件底部添加一个breakpoint()调用。无论何时执行该文件,您都会发现自己处于一个交互式环境中,拥有您需要的所有功能和数据。

Tip

我强烈推荐使用库remote-pdb来调试复杂的多线程应用。要使用它,安装remote-pdb包并用环境变量PYTHONBREAKPOINT =remote_pdb.set_trace python yourscript.py启动你的应用。当您在代码中调用breakpoint()时,连接信息被记录到控制台。更多选项参见remote-pdb文档。

用 Jupyter 制作原型

Jupyter 是一套工具,用于以更加用户友好的方式与支持 REPL 的语言进行交互。它提供了广泛的支持,使得与代码的交互更加容易,比如显示绑定到函数输入或输出的小部件,这使得非技术人员与复杂函数的交互更加容易。在这个阶段对我们有用的功能是,它允许将代码分解成逻辑块并独立运行它们,还能够保存这些块并在以后返回。

Jupyter 是用 Python 编写的,但它是 Julia、Python 和 R 编程语言的通用前端。它旨在作为一种工具,用于共享提供简单用户界面的自包含程序,例如,用于数据分析。许多 Python 程序员创建 Jupyter 笔记本而不是控制台脚本,尤其是那些从事科学工作的人。在这一章中,我们没有以那种方式使用 Jupyter 我们使用它是因为它的特性恰好与原型任务相吻合。

支持多种语言的设计目标意味着它也支持 Haskell、Lua、Perl、PHP、Rust、Node.js 以及许多其他语言。每种语言都有 ide、REPLs、文档网站等等。使用 Jupyter 进行这种类型的原型开发的一个最显著的优点是,它允许您开发一个工作流,该工作流也可以在不熟悉的环境和语言中工作。例如,全栈 web 程序员经常必须同时处理 Python 和 JavaScript 代码。相比之下,科学家可能需要方便地访问 Python 和 r。拥有单一接口意味着语言之间的一些差异被消除了。

由于 Jupyter 不是特定于 Python 的,并且有内置的支持来选择使用什么样的后端来运行当前代码,所以我建议以这样一种方式安装它,以便在整个系统中都可以方便地使用它。如果您通常将 Python 实用程序安装到虚拟环境中,这没问题。 7 然而,我已经将 Jupyter 安装到我的用户环境中:

> python -m pip install --user jupyter

Note

由于 Jupyter 是以用户模式安装的,您需要确保二进制文件目录包含在您的系统路径中。安装到全局 python 环境中或通过您的包管理器安装是一个可接受的替代方案;与使用多种方法相比,保持工具安装方式的一致性更为重要。

当使用 Jupyter 进行原型开发时,您可以将我们的代码分成逻辑块,这些逻辑块既可以单独运行,也可以顺序运行。这些块是可编辑的和持久的,就像我们在使用脚本一样,但是我们可以控制哪些块运行和编写新代码,而不会丢弃变量的内容。在这种情况下,这类似于使用 REPL,因为我们可以在不中断代码流的情况下尝试运行脚本。

访问 Jupyter 工具有两种主要方式,要么通过 Web 使用 Jupyter 的笔记本服务器,要么作为标准 Python REPL 的替代品。每一个都基于单元的思想,单元是独立的执行单元,可以随时重新运行。笔记本和 REPL 都使用相同的 Python 底层接口,称为 IPython。IPython 在理解标准 REPL 的缩进方面没有任何困难,并且支持在会话早期轻松地重新运行代码。

该笔记本比 shell 更加用户友好,但缺点是只能通过 web 浏览器访问,而不能通过通常的文本编辑器或 IDE 访问。 8 我强烈推荐使用笔记本界面,因为在能够重新运行单元格和编辑多行单元格时,它通过更直观的界面大大提高了您的工作效率。

笔记本电脑

要开始原型制作,启动 Jupyter 笔记本服务器,然后使用 web 界面创建一个新的笔记本。

> jupyter notebook

笔记本加载后,在第一个单元格中输入代码,然后单击 run 按钮。代码编辑器中有许多常见的键盘快捷键,当一个新的块开始时会自动缩进(图 1-1 )。

img/481001_1_En_1_Fig1_HTML.jpg

图 1-1

Jupyter 笔记本里的 fizzbuzz

Pdb 通过 web 界面与 Jupyter 笔记本协同工作,中断执行并显示一个新的输入提示(图 1-2 ),其方式与在命令行中相同。所有标准的 pdb 功能都通过这个接口公开,所以本章 pdb 部分的提示也可以在 Jupyter 环境中使用。

img/481001_1_En_1_Fig2_HTML.jpg

图 1-2

Jupyter 笔记本中的 pdb

本章中的原型制作

我们探索的所有方法都有优点和缺点,但每种方法都有其独特之处。对于非常简单的一行程序,比如列表理解,我经常使用 REPL,因为它启动速度最快,而且没有难以调试的复杂控制流。

对于更复杂的任务,比如将外部库的函数放在一起,用它们做多种事情,一个更有特色的方法通常更有效。我鼓励你在设计原型时尝试不同的方法,以了解在便利性和个人偏好方面的最佳点在哪里。

不同方法的各种特性应该有助于明确哪种方法最适合您的特定用例。作为一般规则,我建议使用表 1-1 中最左边的条目,该条目满足您对想要的可用特性的要求。使用更靠右的东西可能不太方便;使用太偏左的工具可能意味着你在尝试执行其他工具更容易完成的任务时会感到沮丧。

表 1-1

原型开发环境的比较

|

特征

|

取代

|

脚本

|

脚本+ pdb

|

朱皮特

|

Jupyter + pdb

|
| --- | --- | --- | --- | --- | --- |
| 缩进代码 | 严格的规则 | 正常规则 | 正常规则 | 正常规则 | 正常规则 |
| 重新运行以前的命令 | 单一类型行 | 仅整个脚本 | 整个脚本或跳到上一行 | 逻辑块 | 逻辑块 |
| 步进 | 缩进的块作为一个整体运行 | 整个脚本作为一个整体运行 | 逐句通过语句 | 逻辑块作为一个整体运行 | 逐句通过语句 |
| 反省 | 可以在逻辑块之间自省 | 没有反省 | 可以在语句之间自省 | 可以在逻辑块之间自省 | 可以在语句之间自省 |
| 坚持 | 什么都没有保存 | 命令被保存 | 命令被保存,但 pdb 提示符下的交互没有被保存 | 命令和输出被保存 | 命令和输出被保存 |
| 编辑 | 必须重新输入命令 | 任何命令都可以编辑,但是整个脚本必须重新运行 | 任何命令都可以编辑,但是整个脚本必须重新运行 | 任何命令都可以编辑,但逻辑块必须重新运行 | 任何命令都可以编辑,但逻辑块必须重新运行 |

在这一章中,我们将原型化几个不同的函数,这些函数返回关于它们正在运行的系统的数据。它们将依赖于一些外部库,我们可能需要使用一些简单的循环,但不是广泛地使用。

由于我们不太可能有复杂的控制结构,缩进代码特性不是一个问题。当我们处理多个不同的数据源时,重新运行前面的命令会很有用。这些数据源中的一些可能会很慢,所以我们不希望在处理其中一个数据源时被迫总是重新运行每个数据源命令。这降低了 REPL,并且比基于脚本的过程更适合 Jupyter。

我们希望能够内省每个数据源的结果,但是我们不太可能需要内省单个数据源的内部变量,这表明基于 pdb 的方法是不必要的(并且,如果情况发生变化,我们总是可以添加一个breakpoint()调用)。我们会希望存储我们正在编写的代码,但这只会对已经贴现的 REPL 进行贴现。最后,我们希望能够编辑代码,并看到它带来的不同。

如果我们将这些需求与表 1-1 进行比较,我们可以创建表 1-2 ,这表明 Jupyter 方法很好地覆盖了我们需要的所有特性,而脚本方法足够好,但在重新运行之前的命令的能力方面不是很理想。

因此,在本章中,我们将使用 Jupyter 笔记本来进行原型制作。在本章的其余部分,我们将介绍 Jupyter 给我们带来的一些其他优势,以及一些在 Python 开发过程中有效使用它的技术,而不是创建作为笔记本分发的独立软件。

表 1-2

各种方法的特征是否符合我们要求的矩阵 9

|

特征

|

取代

|

脚本

|

脚本+ pdb

|

朱皮特

|

Jupyter + pdb

|
| --- | --- | --- | --- | --- | --- |
| 缩进代码 | ✔ | ✔ | ✔ | ✔ | ✔ |
| 重新运行以前的命令 | -好的 | ⚠ | ⚠ | ✔ | ✔ |
| 步进 | -好的 | -好的 | ⚠ | ✔ | ⚠ |
| 反省 | ✔ | ✔ | ✔ | ✔ | ✔ |
| 坚持 | -好的 | ✔ | ✔ | ✔ | ✔ |
| 编辑 | -好的 | ✔ | ✔ | ✔ | ✔ |

环境设置

也就是说,我们需要为这个项目安装库和管理依赖项,这意味着我们需要一个虚拟环境。我们使用 pipenv 来指定我们的依赖关系,pipenv 是一个工具,既可以创建隔离的虚拟环境,又可以进行出色的依赖关系管理。

> python -m pip install --user pipenv

Why Pipenv

用 Python 创建隔离环境的系统已经有很长的历史了。你之前最有可能用过的一个叫做 virtualenv。您也可能使用过 venv、conda、buildout、virtualenvwrapper 或 pyenv。您甚至可以通过在 Python 的内部目录中操作sys.path或创建lnk文件来创建自己的文件。

每种方法都有优点和缺点(除了手动方法,我只能想到缺点),但是 pipenv 对管理直接依赖关系有很好的支持,同时跟踪已知可以正确工作的一整套依赖关系版本,并确保您的环境保持最新。这使得它非常适合现代纯 Python 项目。如果您的工作流涉及构建二进制文件或使用过时的包,那么坚持使用现有的工作流可能比将其迁移到 pipenv 更适合您。特别是,如果您因为做科学计算而使用 Anaconda,就没有必要切换到 pipenv。如果您愿意,您可以使用pipenv --site-packages让 pipenv 包含通过 conda 管理的包以及它自己的包。

与其他 Python 工具相比,Pipenv 的开发周期相当长。它几个月或几年没有发布并不罕见。总的来说,我发现 pipenv 稳定可靠,这就是我推荐它的原因。发布频率更高的包管理器有时会不受欢迎,迫使您定期响应重大变更。

为了让 pipenv 有效地工作,它确实需要您声明依赖的包的维护者正确地声明它们的依赖。有些包没有很好地做到这一点,例如,当存在限制时,只指定一个依赖包,而没有任何版本限制。这个问题可能发生,例如,因为最近发布了一个新的子依赖关系的主要版本。在这些情况下,您可以添加自己的限制,限制您接受的版本(称为版本 pin )。

如果您发现自己的软件包缺少一个必需的版本 pin,请考虑联系软件包维护者来提醒他们。开源维护者通常非常忙,可能还没有注意到这个问题——不要因为他们有经验就认为他们不需要你的帮助。大多数 Python 包在 GitHub 上都有带有问题跟踪器的存储库。您可以从问题跟踪器中看到是否有其他人已经报告了这个问题,如果没有,这是一个简单的方法来为减轻您的开发任务的包做贡献。

设置新项目

首先,为这个项目创建一个新目录,并切换到它。我们希望将ipykernel声明为开发依赖项。这个包包含管理 Python 和 Jupyter 之间的接口的代码,我们希望确保它和它的库代码在我们新的、隔离的环境中可用。

> mkdir advancedpython
> cd advancedpython
> pipenv install ipykernel --dev
> pipenv run ipython kernel install --user --name=advancedpython

这里的最后一行指令隔离环境中的 IPython 副本将自身安装为当前用户帐户的可用内核,名称为 advancedpython。这允许我们选择内核,而不必每次都手动激活这个隔离的环境。安装的内核可以用jupyter kernelspec list列出,用jupyter kernelspec remove删除。

现在我们可以启动 Jupyter,查看针对我们的系统 Python 或我们的隔离环境运行代码的选项。我建议为此打开一个新的命令行窗口,因为 Jupyter 在前台运行,我们不久将需要再次使用命令行。如果你在本章前面已经打开了一个 Jupyter 服务器,我建议你在打开新的服务器之前先关闭它。我们想使用我们之前创建的工作目录,所以如果新窗口不在那里,就切换到那个目录。

> cd advancedpython
> jupyter notebook

web 浏览器会自动打开并显示 Jupyter 界面,其中列出了我们创建的目录。这将类似于图 1-3 。随着项目的建立,是时候开始构建原型了。选择“新建”,然后选择“高级 python”。

我们现在看到了笔记本的主编辑界面。我们有一个“细胞”不包含任何东西,还没有被执行。我们在单元格中键入的任何代码都可以通过单击上面的“运行”按钮来运行。Jupyter 显示下面单元格的输出,以及一个新的空单元格用于进一步的代码。你应该把一个细胞想象成一个功能体。它们通常包含您希望作为一个逻辑组运行的多个相关语句。

img/481001_1_En_1_Fig3_HTML.jpg

图 1-3

新 pipenv 目录中的 jupiter 主屏幕

原型化我们的脚本

合乎逻辑的第一步是创建一个 Python 程序,该程序返回关于运行它的系统的各种信息。稍后,这些信息将成为汇总数据的一部分,但目前一些简单的数据是合适的首要目标。

本着从小处着手的精神,我们将使用第一个单元来查找我们正在运行的 Python 版本,如图 1-4 所示。因为这是由 Python 标准库公开的,并且可以在所有平台上工作,所以它是更有趣的东西的一个很好的占位符。

img/481001_1_En_1_Fig4_HTML.jpg

图 1-4

显示 sys.version_info 的简单 Jupyter 笔记本

Jupyter 显示单元格最后一行的值,以及任何显式打印的内容。由于我们单元格的最后一行是sys.version_info,这就是输出中显示的内容。 10

另一个有用的信息是当前机器的 IP 地址。这不是在单个变量中公开的;这是一些 API 调用和信息处理的结果。因为这需要的不仅仅是简单的导入,所以在新的单元格中逐步构建变量是有意义的。当这样做时,您可以一眼看到您从上一个调用中获得了什么,并且您在下一个单元格中有那些可用的变量。这个循序渐进的过程允许你专注于你正在编写的代码的新部分,忽略你已经完成的部分。

在这个过程结束时,您将得到类似于图 1-5 中的代码,显示与当前计算机相关的各种 IP 地址。在第二阶段,很明显 IPv4 和 IPv6 地址都可用。这使得第三阶段稍微复杂一些,因为我决定提取地址类型和实际值。通过单独执行这些步骤,我们可以在编写下一个步骤时适应我们在一个步骤中学到的东西。能够在不改变窗口的情况下单独重新运行循环体是 Jupyter 在原型开发方面优势所在的一个很好的例子。

img/481001_1_En_1_Fig5_HTML.jpg

图 1-5

在多个单元格中原型化一个复杂的函数 11

此时,我们有三个单元来查找 IP 地址,这意味着单元和逻辑组件之间没有一对一的映射。要整理它,请选择顶部的单元格,然后从编辑菜单中选择“合并下面的单元格”。这样做两次,合并两个附加单元,完整的实现现在存储为一个逻辑块(图 1-6 )。该操作现在可以作为一个整体运行,而不是需要运行所有三个单元来产生输出。整理这个单元格的内容也是一个好主意:因为我们不再想打印中间值,我们可以删除重复的addresses行。

img/481001_1_En_1_Fig6_HTML.jpg

图 1-6

图 1-5 中单元格合并的结果

安装依赖项

更有用的是了解系统正在经历多少负载。在 Linux 中,这可以通过读取存储在/proc/loadavg中的值来找到。在 macOS 中这是sysctl -n vm.loadavg。这两个系统也将它包含在其他程序的输出中,比如 uptime,但这是一个如此常见的任务,毫无疑问有一个库可以帮助我们。如果可以避免,我们不想增加任何复杂性。

我们将安装我们的第一个依赖项,psutil。由于这是我们代码的一个实际依赖项,而不是我们碰巧希望可用的开发工具,我们应该省略前面安装依赖项时使用的--dev标志:

> pipenv install psutil

Note

我们对需要哪个版本的psutil没有偏好,所以我们没有指定版本。install 命令将依赖项添加到Pipfile中,并将特定版本添加到Pipfile.lock中。扩展名为.lock的文件通常被添加到版本控制中的忽略集中。您应该为Pipfile.lock破例,因为它有助于重建旧环境和执行可重复的部署。

当我们返回笔记本时,我们需要重启内核以确保新的依赖项可用。单击内核菜单,然后重新启动。如果你喜欢键盘快捷键,你可以按<ESCAPE>退出编辑模式(当前单元格的绿色高亮显示将变成蓝色以示确认)并按 0(零)两次。

完成后,我们可以开始探索 psutils 模块了。在第二个单元格中,导入 psutil:

import psutil

并点击运行(或从键盘点击<SHIFT+ENTER>运行单元格)。在新的单元格中,键入psutil.cpu<TAB>。您将看到 psutil 的成员,jupyter 可以为您自动完成。在这种情况下,cpu_stats似乎是一个很好的选择,所以输入它。此时,您可以按下<SHIFT+TAB>来查看关于cpu_stats的最小文档,这告诉我们它不需要任何参数。

完成该行,因此单元格现在显示为:

import psutil

psutil.cpu_stats()

当我们运行第二个单元时,我们看到cpu_stats给出了操作系统内部使用 CPU 的不透明信息。我们试试cpu_percent吧。在这个函数上使用<SHIFT+TAB>,我们看到它有两个可选参数。interval 参数决定函数返回前需要多长时间,如果非零,效果最好。因此,我们将修改代码如下,并获得一个介于 0 和 100 之间的简单浮点数:

import psutil
psutil.cpu_percent(interval=0.1)

Exercise 1-1: Explore the Library

psutil 库中的许多其他函数是很好的数据源,所以让我们为每个看起来有趣的函数创建一个单元格。在不同的操作系统上有不同的函数可用,所以请注意,如果您在 Windows 上学习本教程,您的函数选择会稍微有限一些。

尝试 Jupyter 的自动完成和帮助功能,感受一下哪些信息对您有用,并创建至少一个返回数据的单元格。

在每个单元格中包含psutil的导入会是重复的,对于 Python 文件来说不是好的做法,但是我们确实想确保单独运行一个函数是容易的。为了解决这个问题,我们将把导入移动到一个新的顶部单元格,这相当于标准 Python 文件中的模块范围。

一旦你为你的数据源创建了额外的单元格,你的笔记本将看起来如图 1-7 所示。

img/481001_1_En_1_Fig7_HTML.jpg

图 1-7

练习后完整笔记本的示例

当你这样做的时候,单元格旁边的方括号中的数字一直在增加。这个数字是已经运行的操作序列。第一个单元格旁边的数字保持不变,这意味着在我们用较低的单元格进行实验时,这个单元格没有运行过。

在单元格菜单中,有一个运行全部的选项,它将像标准 Python 文件一样按顺序运行每个单元格。虽然能够运行所有单元来测试整个笔记本很有用,但是能够单独运行每个单元可以让您从正在处理的内容中分离出复杂而缓慢的逻辑,而不必每次都重新运行它。

为了演示这是如何有用的,我们将修改对cpu_percent函数的使用。我们选择 0.1 的间隔,因为它足以获得准确的数据。更大的间隔虽然不太现实,但有助于我们了解 Jupyter 如何允许我们编写昂贵的设置代码,同时仍然允许我们重新运行更快的部分,而不必等待速度慢的部分。

import psutil
psutil.cpu_percent(interval=5)

导出到. py 文件

尽管 Jupyter 作为原型工具为我们提供了很好的服务,但它并不适合我们项目的主体。我们想要一个传统的 Python 应用,而 Jupyter 强大的表示功能现在并没有用。Jupyter 内置了从幻灯片到 HTML 等多种格式的笔记本导出支持,但我们感兴趣的是 Python 脚本。

执行转换的脚本是 Jupyter 命令的一部分,使用 nbconvert(笔记本转换)子命令。 十三

> jupyter nbconvert --to script Untitled.ipynb

我们创建的无标题笔记本保持不变,生成了一个新的Untitled.py文件(列表 1-4 )。如果您重命名了笔记本,则这些名称与您指定的名称相匹配。如果您没有,并且想现在重命名它,因为您没有注意到它以前只被称为Untitled.ipynb,请单击笔记本视图顶部的“无标题”并输入新标题。

#!/usr/bin/env python
# coding: utf-8

# In[1]:

import sys
sys.version_info

# In[4]:

import socket
hostname = socket.gethostname()

addresses = socket.getaddrinfo(hostname, None)

for address in addresses:
    print(address[0].name, address[4][0])

# In[5]:

import psutil

# In[6]:

psutil.cpu_percent()

# In[7]:

psutil.virtual_memory().available

# In[8]:

psutil.sensors_battery().power_plugged

# In[ ]:

Listing 1-4Untitled.py, generated from the preceding notebook

正如您所看到的,每个单元格都用注释与其他单元格分隔开,围绕文本编码和 shebang 的标准样板文件出现在文件的顶部。在 Jupyter 中而不是直接在 Python 脚本或 REPR 中开始原型制作,在灵活性或时间方面我们没有任何损失;相反,在我们探索的时候,它给了我们更多的控制权来控制我们如何执行单独的代码块。

我们现在可以把它整理成一个实用程序脚本,而不是简单的语句,方法是把导入移到文件的顶部,然后把每个单元格转换成一个命名函数。显示单元格开始位置的# In注释是关于函数应该从哪里开始的有用提示。我们还必须转换代码以返回值,而不仅仅是把它留在函数的末尾(或者在 IP 地址的情况下打印出来)。结果是清单 1-5 。

# coding: utf-8
import sys
import socket

import psutil

def python_version():
    return sys.version_info

def ip_addresses():
    hostname = socket.gethostname()

    addresses = socket.getaddrinfo(hostname, None)
    address_info = []
    for address in addresses:
        address_info.append(address[0].name, address[4][0])
    return address_info

def cpu_load():
    return psutil.cpu_percent()

def ram_available():
    return psutil.virtual_memory().available

def ac_connected():
    return psutil.sensors_battery().power_plugged

Listing 1-5serverstatus.py

建筑物

这些函数本身并不是特别有用,大多数只是包装了一个现有的 Python 函数。显而易见,我们想做的事情是打印他们的数据,所以你可能想知道为什么我们要不厌其烦地创建单行包装函数。随着我们创建更复杂的数据源和消费它们的多种方式,这一点将变得更加明显,因为我们将受益于不将最简单的数据源作为特例。现在,为了让这些有用,我们可以给用户一个简单的命令行应用来显示这些数据。

因为我们使用的是一个裸 Python 脚本,而不是可安装的东西,所以我们使用一个通常称为“ifmain”的习惯用法。这是内置在许多编码文本编辑器和 ide 中的一个片段,因为它很难记住并且非常不直观。看起来是这样的:

def do_something():
    print("Do something")

if __name__ == '__main__':
    do_something()

这真的很可怕。__name__ 14 变量是对一个模块的全限定名的引用。如果您导入一个模块,__name__属性将是它可以被导入的位置。

>>> from json import encoder
>>> type(encoder)
<class 'module'>
>>> encoder.__name__
'json.encoder'

但是,如果您通过交互式会话或者通过提供脚本运行路径来加载代码,那么它就不一定会被导入。因此,这种模块有一个特殊的名字"__main__"。ifmain 技巧用于检测情况是否如此。也就是说,如果模块已经在命令行上被指定为要运行的文件,那么块的内容将会执行。当模块被其他代码导入时,这个块中的代码将而不是执行,因为变量__name__将被设置为模块的名称。如果没有这种保护,命令行处理程序将在该模块被导入时执行,从而接管任何使用这些实用函数的程序。

Caution

因为 ifmain 块的内容只有在模块是应用的入口点时才能运行,所以应该注意使它尽可能短。一般来说,将它限制在调用一个实用函数的单个语句是一个好主意。这使得函数调用是可测试的,并且对于我们在下一章中将要看到的一些技术是必需的。

sys 模块和 argv

大多数编程语言都公开了一个名为argv,的变量,它代表程序名和用户在调用时传递的参数。在 Python 中,这是一个字符串列表,其中第一个条目是 Python 脚本的名称(但不是 Python 解释器的位置)以及其后列出的任何参数。

不检查argv变量,我们只能产生非常基本的脚本。用户期望命令行标志提供关于该工具的帮助信息。此外,除了最简单的程序之外,所有程序都需要允许用户从命令行传递配置变量。

最简单的方法是检查出现在sys.argv中的值,并在条件中处理它们。实现一个帮助标志可能看起来像清单 1-6 。

#!/usr/bin/env python
# coding: utf-8

import socket
import sys

import psutil

HELP_TEXT = """usage: python {program_name:s}

Displays the values of the sensors

Options and arguments:
--help:    Display this message"""

def python_version():
    return sys.version_info

def ip_addresses():
    hostname = socket.gethostname()
    addresses = socket.getaddrinfo(socket.gethostname(), None)

    address_info = []
    for address in addresses:
        address_info.append((address[0].name, address[4][0]))
    return address_info

def cpu_load():
    return psutil.cpu_percent(interval=0.1)

def ram_available():
    return psutil.virtual_memory().available

def ac_connected():
    return psutil.sensors_battery().power_plugged

def show_sensors():
    print("Python version: {0.major}.{0.minor}".format(python_version()))
    for address in ip_addresses():
        print("IP addresses: {0[1]} ({0[0]})".format(address))
    print("CPU Load: {:.1f}".format(cpu_load()))
    print("RAM Available: {} MiB".format(ram_available() / 1024**2))
    print("AC Connected: {}".format(ac_connected()))

def command_line(argv):
    program_name, *arguments = argv
    if not arguments:
        show_sensors()
    elif arguments and arguments[0] == '--help':
        print(HELP_TEXT.format(program_name=program_name))
        return
    else:
        raise ValueError("Unknown arguments {}".format(arguments))

if __name__ == '__main__':
    command_line(sys.argv)

Listing 1-6sensors_argv.py – cli using manual checking of argv

command_line(...)函数并不复杂,但这是一个非常简单的程序。您可以很容易地想象这样的情况,其中允许以任何顺序使用多个标志,并且可配置的变量要复杂得多。这实际上是可行的,因为不涉及值的排序或解析。标准库中提供了一些助手功能,使得创建更复杂的命令行实用程序变得更加容易。

抱怨吗

argparse 模块是解析命令行参数的标准方法,不依赖于外部库。它使得处理前面提到的复杂情况变得不那么复杂;然而,与许多为开发人员提供选择的库一样,它的接口很难记住。除非您经常编写命令行实用程序,否则每次需要使用它时,您都需要阅读文档。

argparse 遵循的模型是,程序员通过用程序的一些基本信息实例化argparse.ArgumentParser来创建一个显式解析器,然后调用该解析器上的函数来添加新选项。这些函数指定了选项的名称、帮助文本、默认值以及解析器应该如何处理它。例如,一些参数是简单的标志,如--dry-run;其他的是加性的,像-v-vv-vvv;还有一些采用显式值,如--config config.ini

我们还没有在程序中使用任何参数,所以我们跳过添加这些选项,让解析器解析来自sys.argv的参数。该函数调用的结果是它从用户那里收集的信息。一些基本的处理也在这个阶段完成,比如处理--help,它根据添加的选项显示一个自动生成的帮助屏幕。

当使用 argparse 编写时,我们的命令行程序看起来如清单 1-7 所示。

#!/usr/bin/env python
# coding: utf-8

import argparse
import socket
import sys

import psutil

def python_version():
    return sys.version_info

def ip_addresses():
    hostname = socket.gethostname()
    addresses = socket.getaddrinfo(socket.gethostname(), None)

    address_info = []
    for address in addresses:
        address_info.append((address[0].name, address[4][0]))
    return address_info

def cpu_load():
    return psutil.cpu_percent(interval=0.1)

def ram_available():
    return psutil.virtual_memory().available

def ac_connected():
    return psutil.sensors_battery().power_plugged

def show_sensors():
    print("Python version: {0.major}.{0.minor}".format(python_version()))
    for address in ip_addresses():
        print("IP addresses: {0[1]} ({0[0]})".format(address))
    print("CPU Load: {:.1f}".format(cpu_load()))
    print("RAM Available: {} MiB".format(ram_available() / 1024**2))
    print("AC Connected: {}".format(ac_connected()))

def command_line(argv):
    parser = argparse.ArgumentParser(
        description='Displays the values of the sensors',
        add_help=True,
    )
    arguments = parser.parse_args()

    show_sensors()

if __name__ == '__main__':
    command_line(sys.argv)

Listing 1-7sensors_argparse.py – cli using the standard library module argparse

点击

Click 是一个附加模块,它简化了创建命令行界面的过程,假设您的界面与人们期望的标准大体相似。当创建命令行界面时,它有助于更加自然的流程,并鼓励您使用直观的界面。

argparse 要求程序员在构造解析器时指定可用的选项,而 click 在方法上使用 decorators 来推断参数应该是什么。这种方法不太灵活,但是可以轻松处理 80%的典型用例。如果你正在编写一个命令行界面,你通常希望跟随其他工具的引导,这样对最终用户来说是直观的。

由于 click 不在标准库中,我们需要将它安装到我们的环境中。像 psutil 一样,click 是一个代码依赖,而不是一个开发工具,所以我们安装如下:

> pipenv install click

由于我们只有一个主要命令,没有选项,click 只需要添加两行代码,一个导入和@click.command(...)装饰。print(...)调用应该全部替换为click.echo(...),但这不是严格要求的。结果如清单 1-8 所示。click.echo是一个助手函数,其行为类似于 print,但也处理字符编码不匹配的情况,并根据调用程序的终端的功能以及输出是否被传送到其他地方,智能地去除或保留颜色和格式标记。

#!/usr/bin/env python
# coding: utf-8
import socket
import sys

import click
import psutil

def python_version():
    return sys.version_info

def ip_addresses():
    hostname = socket.gethostname()
    addresses = socket.getaddrinfo(socket.gethostname(), None)

    address_info = []
    for address in addresses:
        address_info.append((address[0].name, address[4][0]))
    return address_info

def cpu_load():
    return psutil.cpu_percent(interval=0.1)

def ram_available():
    return psutil.virtual_memory().available

def ac_connected():
    return psutil.sensors_battery().power_plugged

@click.command(help="Displays the values of the sensors")
def show_sensors():
    click.echo("Python version: {0.major}.{0.minor}".format(python_version()))
    for address in ip_addresses():
        click.echo("IP addresses: {0[1]} ({0[0]})".format(address))
    click.echo("CPU Load: {:.1f}".format(cpu_load()))
    click.echo("RAM Available: {} MiB".format(ram_available() / 1024**2))
    click.echo("AC Connected: {}".format(ac_connected()))

if __name__ == '__main__':
    show_sensors()

Listing 1-8sensors_click.py – cli using the contributed library click

它还有许多实用功能,使创建更复杂的界面变得更容易,并补偿了最终用户系统上的非标准终端环境。例如,如果我们决定在show_sensors命令中将标题加粗,在 click 中我们可以使用secho (...)命令,将样式信息回显到终端。一个样式化标题的版本如清单 1-9 所示。

@click.command(help="Displays the values of the sensors")
def show_sensors():
    click.secho("Python version: ", bold=True, nl=False)
    click.echo("{0.major}.{0.minor}".format(python_version()))
    for address in ip_addresses():
        click.secho("IP addresses: ", bold=True, nl=False)
        click.echo("{0[1]} ({0[0]})".format(address))
    click.secho("CPU Load: ", bold=True, nl=False)
    click.echo("{:.1f}".format(cpu_load()))
    click.secho("RAM Available: ", bold=True, nl=False)
    click.echo("{} MiB".format(ram_available() / 1024**2))
    click.secho("AC Connected: ", bold=True, nl=False)
    click.echo("{}".format(ac_connected()))

Listing 1-9Extract from sensors_click_bold.py

secho( ... )功能 n 以指定的格式 将一些信息打印到屏幕上。The??l=参数允许 u s 指定 i fy 是否应该打印新的一行。如果您不使用 click,最简单的方法是

BOLD = '\033[1m'
END = '\033[0m'
def show_sensors():
    print(BOLD + "Python version:" + END + " ({0.major}.{0.minor})".format(python_version()))
    for address in ip_addresses():
        print(BOLD  + "IP addresses: " + END + "{0[1]} ({0[0]})".format(address))
    print(BOLD + "CPU Load:" + END + " {:.1f}".format(cpu_load()))
    print(BOLD + "RAM Available:" + END + "{} MiB".format(ram_available() / 1024**2))
    print(BOLD + "AC Connected:" + END + " {}".format(ac_connected()))

Click 还为终端中的自动完成和许多其他有用的功能提供了透明的支持。我们将在本书后面扩展这个接口时重新讨论这些。

挑战极限

我们已经研究了使用 Jupyter 和 IPython 进行原型开发,但是有时我们需要在特定的计算机上运行原型代码,而不是我们用于日常开发工作的计算机。例如,这可能是因为计算机有我们需要的外围设备或某些软件。

这主要是舒适的问题;在远程机器上编辑和运行代码可能有些不方便,也可能非常困难,尤其是当操作系统存在差异时。

在前面的例子中,我们已经在本地运行了所有代码。然而,我们计划在 Raspberry Pi 上运行最终代码,因为那是我们附加专用传感器的地方。作为一个嵌入式系统,无论是性能还是外设,它都有显著的硬件差异。

远程内核

测试这段代码需要在 Raspberry Pi 上运行 Jupyter 环境,并通过 HTTP 连接到该环境,或者通过 SSH 连接并手动与 Python 解释器交互。这是次优的,因为它需要确保 Raspberry Pi 有开放的端口供 Jupyter 绑定,并且需要使用 scp 之类的工具在本地和远程主机之间手动同步笔记本的内容。这对于真实世界的例子来说更是一个问题。很难想象在服务器上打开一个端口,在那里连接 Jupyter 来测试日志分析代码。

相反,可以使用 Jupyter 和 IPython 的可插拔内核基础设施将本地运行的 Jupyter 笔记本连接到许多远程计算机中的一台。这允许在多台机器上透明地测试相同的代码,并且只需要最少的手工工作。

当 Jupyter 显示其潜在执行目标列表时,它列出了其已知的内核规范。当选择了一个内核规范后,该内核的一个实例被创建并链接到笔记本。可以连接到远程机器,并为本地 Jupyter 实例手动启动一个单独的内核。然而,这很少是对时间的有效利用。当我们在本章开始时运行pipenv run ipython kernel install时,我们正在为当前环境创建一个新的内核规范,并将其安装到已知内核规范的列表中。

要添加使用远程主机的内核规范,我们可以使用助手实用程序remote_ikernel。我们应该将它安装到与 Jupyter 相同的位置,因为它是 Jupyter 的助手,而不是该环境的特定开发工具。

> pip install --user remote_ikernel

然后,我们需要在远程主机上设置环境和内核助手程序。连接到 Raspberry Pi(或另一台我们希望向其发送代码的机器),并像我们前面所做的那样在该计算机上创建一个 pipenv:

rpi> python -m pip install --user pipenv
rpi> mkdir development-testing
rpi> cd development-testing
rpi> pipenv install ipykernel

Tip

一些低性能主机,如 Raspberry Pis,可能会使安装 ipython_kernel 慢得令人沮丧。在这种情况下,您可以考虑使用软件包管理器的 ipython_kernel 版本。ipython 内核确实需要许多支持库,这些库可能需要一些时间才能安装到低性能计算机上。在这种情况下,您可以将环境设置为

rpi> sudo apt install python3-ipykernel
rpi> pipenv --three --site-packages

或者,如果您使用的是 Raspberry Pi,在 https://www.piwheels.org 有一个预编译轮子的存储库,除了现有的之外,还可以通过向您的 Pipfile 添加以下新的源来启用它:

[[source]]
url = "https://www.piwheels.org/simple"
name = "piwheels"
verify_ssl = true

然后使用pipenv install像平常一样安装 ipython_kernel 包。如果您使用的是运行 raspbuin 的 Raspberry Pi,您应该总是将 piwheels 添加到您的 Pipfile 中,因为 raspbuin 预先配置为全局使用 PiWheels。不在 Pipfile 中列出它会导致安装失败。

这将在 Raspberry Pi 机器上安装 IPython 内核程序;然而,我们仍然需要在我们的主机上安装它。首先,我们将安装一个指向我们创建的 pipenv 环境的内核。在此之后,Raspberry Pi 将有两个可用的内核,一个用于系统 Python 安装,另一个用于我们环境的开发测试。安装内核后,我们可以查看规范的配置文件:

rpi> pipenv run ipython kernel install --user --name=development-testing
Installed kernelspec development-testing in /home/pi/.local/share/jupyter/kernels/development-testing
> cat /home/pi/.local/share/jupyter/kernels/development-testing/kernel.json
 {
 "argv": [
  "/home/pi/.local/share/virtualenvs/development-testing-nbi70cWI/bin/python",
  "-m",
  "ipykernel_launcher",
  "-f",
  "{connection_file}"
 ],
 "display_name": "development-testing",
 "language": "python"
}

这个输出向我们展示了如果 Jupyter 安装在那台计算机上,它将如何运行内核。我们可以使用这个规范中的信息在我们的开发机器上创建一个新的 remote_ikernel 规范,它指向与 Raspberry Pi 上的开发测试内核相同的环境。

前面的内核规范列出了如何在 Raspberry Pi 上启动内核。我们可以通过测试 SSH 到 Raspberry Pi 的命令来验证这一点,例如,将-f {connection_file}改为--help来显示帮助文本。

rpi> /home/pi/.local/share/virtualenvs/development-testing-nbi70cWI/bin/python -m ipykernel –help

我们现在可以返回开发计算机,创建远程内核规范,如下所示:

> remote_ikernel manage --add --kernel_cmd="/home/pi/.local/share/virtualenvs/development-testing-nbi70cWI/bin/python
-m ipykernel_launcher -f {connection_file}"
--name="development-testing" --interface=ssh --host=pi@raspberrypi --workdir="/home/pi/developmenttesting" --language=python

它看起来有点吓人,跨越五行文本,但它可以被分解:

  • --kernel_cmd参数是内核规范文件中argv部分的内容。每行都用空格分隔,没有单独的引号。这是启动内核本身的命令。

  • --name参数相当于原始内核规范中的display_name。当您选择这个内核时,这将显示在 Jupyter 中,旁边是 SSH 信息。它不必与您复制的远程内核的名称相匹配,这只是供您参考。

  • --interface--host参数定义了如何连接到远程机器。你应该确保无密码的 15 SSH 可以连接到这台机器,以便 Jupyter 可以建立连接。

  • --workdir参数是环境应该使用的默认工作目录。我建议将这个目录设置为包含您的远程 Pipfile 的目录。

  • --language参数是来自原始内核规范的语言值,用于区分不同的编程语言。

Tip

如果连接远程内核有困难,可以尝试在命令行上使用 Jupyter 打开一个 shell。这通常会显示有用的错误消息。使用jupyter kernelspec list找到内核的名称,然后使用jupyter console:

> jupyter kernelspec list
Available kernels:
  advancedpython       C:\Users\micro\AppData\Roaming\jupyter\kernels\advancedpython

  rik_ssh_pi_raspberrypi_developmenttesting        C:\Users\micro\AppData\Roaming\jupyter\kernels\rik_ssh_pi_raspberrypi_developmenttesting

> jupyter console --kernel= rik_ssh_pi_raspberrypi_developmenttesting
In [1]:

此时,当我们重新进入 Jupyter 环境时,我们看到一个新的内核与我们提供的连接信息相匹配。然后,我们可以选择该内核并执行需要该环境的命令, 16 ,Jupyter 内核系统负责连接到 Raspberry Pi 并激活~/development-testing中的环境。

开发不能在本地运行的代码

树莓 Pi 上有一些有用的传感器;这些提供了我们有兴趣收集的实际数据。在其他用例中,这可能是通过调用自定义命令行实用程序、自省数据库或进行本地 API 调用收集的信息。

这不是一本关于如何充分利用 Raspberry Pi 的书,所以我们将忽略它是如何工作的许多细节,但可以说有大量的文档和支持使用 Python 做令人兴奋的事情。在这种情况下,我们希望使用一个库,该库提供从可以添加到电路板上的传感器中检索温度和相对湿度的功能。像许多其他任务一样,这相对较慢(可能需要一秒钟的时间来测量),并且需要特定的环境(安装外部传感器)来执行。这样,它类似于通过管理端口进行通信来监控 web 服务器上的活动进程。

首先,我们将 Adafruit DHT 17 库添加到我们的环境中。我们目前在 Raspberry Pi 和本地都有 pipfile 的副本。远程副本只包含对 ipykernel 的依赖,而 ipykernel 已经在本地副本中,所以用我们在本地创建的文件覆盖远程文件是安全的。正如我们所知,DHT 库只在 Raspberry Pis 上有用,我们可以使用条件依赖语法: 18 对它进行限制,使它只安装在带有 ARM 处理器的 Linux 机器上

> pipenv install "Adafruit-CircuitPython-DHT ; 'arm' in platform_machine"

这导致PipfilePipfile.lock文件被更新以包含该依赖关系。我们希望在远程主机上利用这些依赖关系,因此我们必须使用 Pipenv 复制这些文件并安装它们。在两种环境下运行这个命令都是可能的,但是这样会有出错的风险。Pipenv 假设您使用相同版本的 Python 进行开发和部署,这符合其避免部署期间出现问题的理念。因此,如果您计划部署一套 Python 版本,您应该在本地使用它进行开发。

但是,如果您不想在本地环境中安装不寻常的 Python 版本,或者如果您的目标是多台不同的机器,则可以取消此检查。为此,从 Pipfile 的末尾删除python_version行。这允许您的环境部署到任何 Python 版本。然而,您应该确保您知道您需要支持什么版本,并相应地进行测试。

使用scp(或者您选择的工具)将PipfilePipfile.lock文件复制到远程主机,然后在远程机器上运行带有--deploy标志的pipenv install--deploy指示 pipenv 仅在版本完全匹配的情况下继续,这对于将已知良好的环境从一台机器部署到另一台机器非常有用。

rpi> cd /home/pi/development-testing
rpi> pipenv install --deploy

但是,请注意,如果您已经在不同的操作系统或不同的 CPU 架构上创建了您的Pipfile(例如在标准笔记本电脑上创建并安装在 Raspberry Pi 上的文件),那么在将它们部署到另一台机器上时,固定的包可能不适合。在这种情况下,可以通过运行pipenv lock --keep-outdated在不触发版本升级的情况下重新锁定依赖关系。

现在,您在远程环境中拥有了指定的依赖项。如果您已经重新锁定了文件,您应该将已更改的锁定文件转移回来并存储它,这样您就可以在将来重新部署而不必重新生成该文件。在这一阶段,您可以通过 Jupyter 客户机连接到远程服务器,并开始构建原型。我们希望添加湿度传感器,所以我们将使用我们刚刚添加的库,现在可以接收有效的湿度百分比。

我复制这些文件的树莓 Pi 有一个 DHT22 传感器连接到引脚 D4,如图 1-8 所示。这种传感器很容易从 Raspberry Pi 或通用电子供应商处获得。如果您手头没有这样的命令,那么尝试一个替代命令来演示代码正在 Pi 上运行,比如platform.uname()

img/481001_1_En_1_Fig8_HTML.jpg

图 1-8

Jupyter 连接到一个远程树莓码头

该笔记本存储在您的开发机器上,而不是远程服务器上。可以使用nbconvert将它移植到 Python 脚本中,方法和以前一样。然而,在我们这样做之前,我们也可以将内核改回我们的本地实例,以检查代码在那里的行为是否正确。目标是创建在两种环境下都能工作的代码,返回湿度或占位符值。

img/481001_1_En_1_Fig9_HTML.jpg

图 1-9

演示在本地机器上运行的相同代码

图 1-9 表明该规范并不适用于所有环境。我们非常希望至少能够在本地运行一些代码,这样我们就可以调整我们的代码来考虑其他平台的限制。当它被转换成更一般的函数形式时,看起来会像这样

def get_relative_humidity():
    try:
        # Connect to a DHT22 sensor on GPIO pin 4
        from adafruit_dht import DHT22
        from board import D4
    except (ImportError, NotImplementedError):
        # No DHT library results in an ImportError.
        # Running on an unknown platform results in a NotImplementedError
        # when getting the pin
        return None
    return DHT22(D4).humidity

这允许在任何机器上调用该函数,除非它有一个温度和湿度传感器连接到引脚D4并在任何其他地方返回一个None

完整的脚本

清单 1-10 显示了完整的脚本。要确保这是一个有用的库,仍然有一些障碍需要克服,最值得注意的是,show_sensors函数正在对值进行格式化。此时,我们不想将格式集成到数据源中,因为我们想确保原始值对其他接口可用。这是我们将在下一章中讨论的内容。

#!/usr/bin/env python
# coding: utf-8
import socket
import sys

import click
import psutil

def python_version():
    return sys.version_info

def ip_addresses():
    hostname = socket.gethostname()
    addresses = socket.getaddrinfo(socket.gethostname(), None)

    address_info = []
    for address in addresses:
        address_info.append((address[0].name, address[4][0]))
    return address_info

def cpu_load():
    return psutil.cpu_percent(interval=0.1) / 100.0

def ram_available():
    return psutil.virtual_memory().available

def ac_connected():
    return psutil.sensors_battery().power_plugged

def get_relative_humidity():
    try:
        # Connect to a DHT22 sensor on GPIO pin 4
        from adafruit_dht import DHT22
        from board import D4
    except (ImportError, NotImplementedError):
        # No DHT library results in an ImportError.
        # Running on an unknown platform results in a NotImplementedError
        # when getting the pin
        return None
    return DHT22(D4).humidity

@click.command(help="Displays the values of the sensors")

def show_sensors():
    click.echo("Python version: {0.major}.{0.minor}".format(python_version()))
    for address in ip_addresses():
        click.echo("IP addresses: {0[1]} ({0[0]})".format(address))
    click.echo("CPU Load: {:.1%}".format(cpu_load()))
    click.echo("RAM Available: {:.0f} MiB".format(ram_available() / 1024**2))
    click.echo("AC Connected: {!r}".format(ac_connected()))
    click.echo("Humidity: {!r}".format(get_relative_humidity()))

if __name__ == '__main__':
    show_sensors()

Listing 1-10The final version of our script from this chapter

摘要

关于原型的章节到此结束;在接下来的章节中,我们将基于我们在这里创建的数据提取函数来创建遵循 Python 最佳实践的库和工具。我们遵循的道路是从摆弄一个库到拥有一个真正有用的工作 shell 脚本。随着我们的继续,它将发展到更好地适应我们的分布式数据聚合的最终目标。

我们在这里讨论的技巧在软件开发生命周期的很多时候都是有用的,但是重要的是不要僵化,只遵循一个过程。虽然这些方法很有效,但有时打开 REPL 或使用 pdb(甚至简单的print(...)调用)会比设置远程内核更直接。除非你知道有哪些选择,否则不可能找到解决问题的最佳方法。

概括一下:

  1. Jupyter 是一个很好的工具,可以用来探索库并对它们的使用进行初步的原型设计。

  2. Python 有一些特殊用途的调试器,可以使用breakpoint()函数和环境变量轻松集成到您的工作流中。

  3. Pipenv 帮助您定义保持最新的版本需求,包含最少的规范,并促进可重复的构建。

  4. 库点击允许以惯用的 Python 风格实现简单的命令行界面。

  5. Jupyter 的内核系统允许将本地和其他计算机上运行的多种编程语言无缝集成到一个开发流程中。

额外资源

我们在这一章中使用的每一个工具都有很深的内涵,而我们只是浏览了表面来达到我们的目的。

  • https://pipenv.pypa.io/en/latest/ 的 pipenv 文档中有很多关于定制 Pipenv 以使其按照您的意愿工作的有用解释,特别是关于定制虚拟环境创建和集成到现有流程中。如果您是 pipenv 的新手,但是已经使用了很多虚拟环境,那么这里有很好的文档来帮助您弥合差距。

  • 如果你对用 Jupyter 开发其他编程语言的原型感兴趣,我建议你通读位于 https://jupyter.readthedocs.io/en/latest/ 的 Jupyter 文档——尤其是内核部分。

  • 关于树莓 Pi 和兼容传感器的信息,我推荐 CircuitPython 项目关于树莓 Pi 的文档: https://learn.adafruit.com/circuitpython-on-raspberrypi-linux

二、测试、检查、Lint

Python 以“鸭子”类型而闻名, 1 也就是说,你应该在没有显式类型检查的情况下编写代码。如果你写了一个在数字类型上实现某种算法的函数,那么当使用intfloatdecimal.Decimalfractions.Fractionnumpy.uint64时,它应该同样工作良好。只要对象提供了正确的功能,并且这些功能具有正确的含义,它们就能正常工作。

Python 通过后期绑定动态分派的相关特性来实现这一点。我们稍后将更深入地回到这个主题,但是可以说动态调度是能够运行

some_int + other_int
some_float + other_float

而不得不用 2

int.__add__(some_int, other_int)
float.__add__(some_float, other_float)

也就是说,通过对象来解析函数,以找到该类型的正确实现。后期绑定意味着这种查找发生在调用函数的时候,而不是在编写程序的时候。这两者的结合形成了我们所说的鸭子类型,并允许编写信任底层对象实现的函数,而无需事先知道它们是什么。然而,这也意味着 Python 程序无法从使用早期绑定 3 的语言提供的相同级别的自动检查中获益。

到目前为止,我们一直在编写操作 Python 内置数据类型的简单函数,比如 float。这对于琐碎的函数很有效,但是随着程序变得越来越复杂,编写与代码的其他部分没有正式关系的代码变得越来越困难。

在前一章中,我们向数据收集中添加了一个湿度值,但它来自一个也收集环境温度的传感器。传感器以摄氏度为单位返回这一信息。我们可以添加一个匹配的温度传感器,如清单 2-1 所示。

def get_temperature():
    # Connect to a DHT22 sensor on GPIO pin 4
    try:
        from adafruit_dht import DHT22
        from board import D4
    except (ImportError, NotImplementedError):
        # No DHT library results in an ImportError.
        # Running on an unknown platform results in a NotImplementedError
        # when getting the pin
        return None
    return DHT22(D4).temperature

Listing 2-1A simple temperature sensor function

然而,我们可能想让用户以不同的格式看到它。当我们编写一个转换函数时,从我们对程序功能的理解和我们给函数起的名字中我们知道,它对数字进行运算,从一个温度系统转换到另一个温度系统,但这种关系纯粹是开发人员的理解,并没有隐含在任何代码中。我们为此编写的代码如清单 2-2 所示。

to Kelvin

Listing 2-2Conversion functions for Celsius to Fahrenheit and Celsius

img/481001_1_En_2_Figa_HTML.jpg

正如你从截图中看到的,这些对于整数参数是正确的。如果为它们提供了Fraction、 4 、Decimalfloat参数,它们也会返回正确的值。我们的函数实际上会返回任何数值类型的值。如果我们调用celsius_to_fahrenheit("21"),Python 的类型系统会抛出TypeError,因为除法运算不是在字符串上指定的,但是我们的函数只对实数有意义,而不仅仅是实现除法方法的对象。我们在任何地方都没有捕捉到这个需求,所以如果有人传递一些我们不期望的数值,这些函数仍然会产生一个输出(清单 2-3 )。

to Fahrenheit

Listing 2-3The result of converting a complex number or a matrix from Celsius

img/481001_1_En_2_Figb_HTML.jpg

本章介绍的前两个概念在这些示例中有所体现。测试是确定功能是否正常工作的过程。检查,或者更确切地说是静态类型检查,是在编写函数时而不是运行时识别函数所操作的类型的过程。当编写库时,为代码编写测试是正常的。你可能是唯一运行这些测试的人;它们可以增强你对准则的信心,并有助于你做出贡献。

另一方面,您添加的任何类型检查对任何使用您的代码作为库函数来源的人都有直接的好处。您可能会从这些检查中对自己的代码缺乏信心(尽管它们确实有助于您发现错误),但是它们真正的作用是使您的代码更容易被那些不像作者那样熟悉它的人使用。这并不是说类型检查对你没有什么好处;他们提供的暗示对于澄清微妙的误解是非常宝贵的。许多 ide 甚至使用它们提供的额外信息来提供更加用户友好的编程体验。

测试

未经测试的代码就是断码。

Python 内置了对测试的支持,其形式是标准库中的 unittest 模块。这提供了一个TestCase类,它用安装和拆卸代码包装了单独的测试,并提供了用于断言值之间关系的帮助函数。虽然可以单独使用这个模块编写测试,但是我强烈建议使用附加模块 pytest。

Pytest 消除了在建立测试系统时对样板文件的需求。比较以下用 unittest 风格(清单 2-5 )和 pytest 风格(清单 2-6 )编写的测试。这些是测试我们之前原型化的温度转换函数,如清单 2-4 所示。

import pytest
from temperature import celsius_to_fahrenheit

def test_celsius_to_fahrenheit():
    assert celsius_to_fahrenheit(21) == 69.8

def test_celsius_to_fahrenheit_equivlance_point():
    assert celsius_to_fahrenheit(-40) == -40

def test_celsius_to_fahrenheit_float():
    assert celsius_to_fahrenheit(21.2) == 70.16

def test_celsius_to_fahrenheit_string():
    with pytest.raises(TypeError):
        f = celsius_to_fahrenheit("21")

Listing 2-6Pytest style of testing the conversion function

import unittest
from temperature import celsius_to_fahrenheit

class TestTemperatureConversion(unittest.TestCase):

    def test_celsius_to_fahrenheit(self):
        self.assertEqual(celsius_to_fahrenheit(21), 69.8)

    def test_celsius_to_fahrenheit_equivlance_point(self):
        self.assertEqual(celsius_to_fahrenheit(-40), -40)

    def test_celsius_to_fahrenheit_float(self):
        self.assertEqual(celsius_to_fahrenheit(21.2), 70.16)

    def test_celsius_to_fahrenheit_string(self):
        with self.assertRaises(TypeError):
            f = celsius_to_fahrenheit("21")

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

Listing 2-5Unittest style of testing the conversion function

def celsius_to_fahrenheit(celsius):
    return celsius * 9 / 5 + 32

def celsius_to_kelvin(celsius):
    return 273.15 + celsius

Listing 2-4temperature.py being tested

最明显的区别就是self.assertEqual(x, y)assert x == y之间的区别。这两者做同样的事情,但是 pytest 风格允许更自然的代码。unittest 风格将大多数操作包装在助手函数中,助手函数既执行比较,又在断言失败时生成适当的错误消息。例如,如果 x 和 y 是不同的列表,assertEqual调用assertListEqual来比较列表并生成缺失和附加元素的 diff,并将当前测试标记为失败。表 2-1 展示了 pytest 断言风格比 unittest 断言风格更清晰的方式。

表 2-1

unittest 和 pytest 风格中的一些常见断言格式

|

比较

|

单元测试

|

Pytest(测试)

|
| --- | --- | --- |
| 价值观是平等的 | self.assertEqual(x, y) | assert x == y |
| 价值观是不平等的 | self.assertNotEqual(x, y) | assert x != y |
| 值为无 | self.assertIsNone(x) | assert x is None |
| 列表包含 | self.assertIn(x, y) | assert x in y |
| 浮点数相差不到 0.000001 | self.assertAlmostEqual(x, y) | assert x == pytest.approx(y) |
| 引发了异常 | with self.assertRaises(TypeError):``doSomething() | with pytest.raises(TypeError):``doSomething() |

此外,unittest 有一个TestCase类,它被用作所有测试组的基类。这些测试用例可以具有通用的设置和拆卸功能,以确保通用的变量和数据就位。ifmain 块中调用的unittest.main()函数是测试系统的入口点。那个函数收集当前模块中的所有测试类并执行它们。对于较大的项目,通常会有多个包含测试的文件,这些文件由测试加载器发现,并且它们的内容被收集和运行。

Pytest 的行为有些不同;运行可执行文件开始测试发现,而不是依赖 Python 源文件来整理测试。一旦发现了测试,就应用作为命令行参数传递的任何过滤器,并运行剩余的测试。

定义测试的代码与执行设置和发现的独立可执行文件之间的分离允许对执行测试的 Python 环境进行更多的控制,例如,允许使用裸assert语句,而不需要断言的包装函数。

何时编写测试

在软件工程中有很多关于什么时候是写测试的合适时间,应该在代码写之前还是之后写的强烈意见。首先编写测试通常被称为测试驱动开发(TDD ),它有很多支持者。这是有原因的。在测试驱动的环境中工作是非常令人满意的,因为在开发一个特性的最后阶段,它会让你有一种胜利的感觉。如果您计划以后编写测试,它们可能会让人觉得是不必要的杂务。

在软件工程的许多情况下,对于任何给定的问题都有一个最佳选择,但是我相信 TDD 与稍后编写测试是一个更个人化的选择。我坚信无论哪种方式,开发人员都可以是高效的,但是有些人自然会被首先编写测试所吸引,而另一些人则觉得这会导致缓慢的开始,这是他们想要避免的。也很有可能你更喜欢哪一个取决于你的心情或者你对你正在工作的代码库的熟悉程度。

我通常更喜欢首先编写测试,因为我发现它有助于我在深入实现细节之前思考代码的含义,但我经常发现自己希望快速完成一些工作,然后再完善它。这两种方法都完全有效;在编写代码之前编写测试并不比之后编写更正确或合适。两种都试试,看看哪一种对你来说更自然。

在某些情况下,您甚至可能决定测试不值得编写,或者您可能有一个客户或经理为了节省时间而强迫您不要编写测试。我不会告诉你这是一个好主意,但同样,有时这也是一种有效的方法。如果您正在编写一个只运行一次的程序,或者正在使用一个未经测试的复杂的现有代码库,那么编写测试的成本/收益比就会偏离它通常所在的位置。在这些情况下,决定测试不是时间投资的优先事项是完全可以接受的。但是,如果发生这种情况,你要记住,决定不是不可逆的。如果你发现自己反复手动测试同样的东西并感到沮丧,这通常是你应该编写测试的信号。如果你认为增加测试可以节省时间,不要让你花费在手工测试上的时间的沉没成本阻止你花时间增加测试。

Exercise 2-1: Try Test-Driven Development

在这一章中,我们在编写完代码后,正在编写测试。这没有什么特别的原因。这一选择是为了使这一章的内容更加自然。如果您想先尝试编写测试,这是一个很好的机会。如果你更喜欢在这之后写测试,按照这一章的流程,那么可以跳过这个练习。

选择一个我们在前一章看到的传感器,并为其编写一些测试。在本章的支持代码中,您会发现一个使用上一章中的代码设置的环境。它还包含如何运行测试的文档。

如果你真的完成了这个练习,请注意你最终得到的代码结构可能与本章所建议的有很大的不同。请记住,未来的章节将建立在这一点上,你还不知道所有的要求。有许多方法可以解决这个问题;这个练习的目的是帮助你了解在 TDD 过程中,作为测试写作的一部分,你需要做什么样的决策;这里没有正确的答案。

创建格式化函数以提高可测试性

在前一章中,我们创建了一个简单的脚本,以简单的命令行脚本的形式打印各个传感器的值。这包括从预先编写的main()函数中手动调用多个函数,并独立处理它们的格式。尽管这是一个概念验证,但这不是构建大型系统的可持续方式。对于每个传感器值,我们需要一种方法来提取用于定量分析的原始值以及用于向最终用户显示的格式化值。

进行这种分离的另一个重要原因是确保功能有严格的关注点分离。我们希望能够测试是否提取了正确的值,以及值的格式是否正确,而不必同时进行这两项工作。如果我们有一个紧密耦合的数据提取和格式化函数,我们将无法检查一系列不同值的格式是否正确。我们将只能检查当前运行测试的机器的值,这些值可能会随着运行的不同而变化很大。

为了实现这一点,我们将把函数扩展到一个 Python 类中,该类既提供传感器检索到的原始值,又提供一个帮助函数来适当地格式化它(清单 2-7 )。这种方法使得在面向用户的环境(如命令行脚本)中显示传感器的当前值更加容易,因为在周围的脚本中没有单独传感器值的特殊大小写。

例如,确定有多少 RAM 可用的传感器应该显示格式化为适当单位的字节数。之前,我们假设兆字节 5 是一个合适的单位,并使用"{:.0f} MiB".format(ram_available() / 1024**2)静态缩放该数字。这既太复杂,不适合一行程序,又太简单,没有普遍意义。

class Temperature(Sensor[Optional[float]]):
    title = "Ambient Temperature"

    def value(self) -> Optional[float]:
        try:
            # Connect to a DHT22 sensor on GPIO pin 4
            from adafruit_dht import DHT22
            from board import D4
        except (ImportError, NotImplementedError):
            # No DHT library results in an ImportError.
            # Running on an unknown platform results in a
            # NotImplementedError when getting the pin
            return None
        try:
            return DHT22(D4).temperature
        except RuntimeError:
            return None

    @staticmethod
    def celsius_to_fahrenheit(value: float) -> float:
        return value * 9 / 5 + 32

    @classmethod
    def format(cls, value: Optional[float]) -> str:
        if value is None:
            return "Unknown"
        else:
            return "{:.1f}C ({:.1f}F)".format(value, cls.celsius_to_fahrenheit(value))

    def __str__(self) -> str:
        return self.format(self.value())

Listing 2-7New temperature sensor implementation from sensors.py

这个版本和原始版本之间最显著的区别是从函数到类的转换。这是一个简单的类,它不从基类继承,所以在包含基类的类名后面没有括号。最直接的方法, 6 value(),是原始ram_available()函数的直接模拟,因为它提取的数据没有任何格式。

format(...)方法相当于之前直接在命令行程序的显示逻辑中进行的格式化。通过使它成为 sensor 类上的一个方法,我们隐式地将格式化函数与它们使用的数据检索函数相关联。与几十个函数都在全局范围内相比,这使得理解相关的代码变得更容易,并且减少了理解模块整体的认知负荷。

Instance, Class, and Static Methods On Classes

函数celsius_to_fahrenheit(...)被定义为上面有装饰器的staticmethod,方法format(...)被定义为classmethod,第一个参数是cls而不是self

这些方法的行为与标准实例方法略有不同。当你在一个类上定义一个函数时,它把 self 作为第一个参数。这使它成为一个实例方法;它只能在类的实例上调用,并且可以访问在该实例上设置的属性以及其他方法。Temperature().value()会返回一个结果,但是Temperature.value()会引出一个TypeError

在典型情况下,当在 Python 中对一个对象定义一个函数时,它在第一个位置有一个参数self。这被绑定到该类的一个实例,因此每个函数都可以访问存储在该类中的数据,并且可以调用具有相同访问权限的函数。当用Temperature()调用类对象时,返回该类的一个实例,当调用该实例上的一个方法时,它会自动将该实例作为第一个参数传递。这意味着Temperature().value()是检索该值所需的全部内容。只要您通过实例访问方法来调用它,您就永远不需要显式传递self参数。

一个类方法以 cls 7 作为第一个参数,指向类而不是实例。该函数仍然可以访问该类上的其他函数以及存储在该类上的任何属性,但是它不能调用实例方法,因为它没有可用的类实例。类方法可以像平常一样在实例上调用,也可以直接在类上调用。它们对于编写自定义构造函数(比如from_json(...))或者使用类的其他函数或属性的实用函数非常有用。可以在类(Temperature.format(21))或实例(Temperature().format(21))上调用类方法;在这两种情况下,它都将接收类作为第一个参数。

最后,静态方法是没有隐式第一个参数的方法。与类方法相比,静态方法没有明显的优势,但是隐式参数的缺失让代码的读者清楚地看到,它是一个完全独立的方法,只是为了方便起见才与类组合在一起。它也可以在类或实例上调用,如Temperature.celsius_to_fahrenheit(21)Temperature().celsius_to_fahrenheit(21)

前面的传感器代码旨在检索和格式化传感器数据。一些传感器的__init__()方法可能会执行一些昂贵的 8 设置,这是使value()工作所需的。我们将format(...)方法标记为类方法的原因是为了确保我们仍然可以在不实例化类的情况下格式化数据。这允许我们在没有相关传感器实例的情况下格式化数据,只需要它的类。

__str__()方法是 Python 的内部约定;它确定如何将对象转换为字符串表示形式。 9 因为这只在类的实例中使用过,我们可以把它简化为“获取当前值并格式化它”因此,显示所有传感器值的代码大大缩短,更容易理解:

@click.command(help="Displays the values of the sensors")
def show_sensors():
    for sensor in [PythonVersion(), IPAddresses(), CPULoad(), RAMAvailable(),
                  ACStatus(), RelativeHumidity()]:
        click.secho(sensor.title, bold=True)
        click.echo(sensor)
        click.echo("")

显示传感器值的工作几乎完全委托给了传感器本身。只需要传感器有一个返回其当前值的格式化版本的__str__()方法,并且有一个包含显示标题的title属性。

现在我们已经重新组织了代码,使其具有独立的格式化和值提取功能,我们可以编写测试来确保值按照我们期望的格式进行格式化。和往常一样,你可以在这本书的网站上找到本章的支持文件中重新组织的代码。

pytest(测试)

为了能够运行我们的测试,首先要做的是我们需要安装 pytest 本身。我们认为这是一个开发包,因为它不是系统必须使用的,只是为了让开发人员确信它的行为符合预期。

pipenv install --dev pytest

这为我们的项目创建了一个新的 pytest 脚本。此时,我们可以运行pipenv run pytest并查看我们测试运行的结果,即运行了 0 个测试。为了测试我们有一个工作环境,我们可以创建一个样本测试。这通常由代码框架生成器来完成,其中的测试类似于assert 1 == 1。我们将断言包含 cli 脚本的文件中有一个我们期望看到的传感器。

为此,我们创建一个新的tests/目录,并添加一个空的__init__.py和一个test_sensors.py,如下所示:

import sensors

def test_sensors():
    assert hasattr(sensors, 'PythonVersion')

单元、集成和功能测试

编写测试最困难的部分是知道要编写哪些测试。编写运行整个应用并检查输出的测试很有诱惑力,可以像最终用户一样有效地与代码交互。这被称为功能测试。功能测试在 web 框架中尤其流行,其中可能有许多不同的代码层进行交互,以提供诸如身份验证、会话和模板呈现等服务。虽然这确实有效地测试了是否生成了正确的输出,但是编写比确认常见情况更进一步的测试可能会很困难。

如果我们在命令行脚本中采用这种方法,我们会看到脚本在运行时返回我们期望的值。我们将面临的直接问题是,知道我们期望的正确价值观是什么是具有挑战性的。我们的传感器中最容易预测的是 Python 版本,因为只有少数几个可能的值,但即使这样,也不可能预先知道使用的是哪个版本的 Python。

例如,下面的测试使用 click 中的CliRunner辅助工具来模拟运行命令行工具并捕获输出:

def test_python_version_is_first_two_lines_of_cli_output ():
    runner = CliRunner()
    result = runner.invoke(sensors.show_sensors)
    assert ["Python Version", "3.8"] == result.stdout.split("\n")[:2]

这看起来很好,直到有人第一次在 Python 3.7 上运行它并看到失败:

__________ test_python_version_is_first_two_lines_of_cli_output ___________

    def test_python_version_is_first_two_lines_of_cli_output():
        runner = CliRunner()
        result = runner.invoke(sensors.show_sensors)
>       assert ["Python Version", "3.8"] == result.stdout.split("\n")[:2]
E       AssertionError: assert ['Python Version', '3.8'] == ['Python Version', '3.7']
E         At index 1 diff: '3.8' != '3.7'
E         Use -v to get the full diff

tests\test_sensors.py:11: AssertionError

对于许多人来说,此时自然要做的事情是更改测试,以检测系统正在运行的 Python 版本,并使用它来确定预期的结果,类似于

def test_python_version_is_first_two_lines_of_cli_output():
    runner = CliRunner()
    result = runner.invoke(sensors.show_sensors)
    python_version = "{}.{}".format(sys.version_info.major, sys.version_info.minor)
    assert ["Python Version", python_version] == (result.stdout.split("\n")[:2])

它可以在任何 Python 版本上成功运行。这是一个完全合理的改变,但是重要的是要意识到你不再测试同样的东西。记住PythonVersion传感器的实现是

class PythonVersion:
    def value(self):
        return sys.version_info

    @classmethod
    def format(cls, value):
        return "{0.major}.{0.minor}".format(value)

因此,如果我们去掉传感器脚本中涉及的所有间接函数调用,我们的测试将有效地测试以下内容:

assert "{}.{}".format(sys.version_info.major, sys.version_info.minor) == "{0.major}.{0.minor}".format(sys.version_info)

编写一个测试,其中断言的结果是计算出来的,而不是事先知道的,这通常会导致一个同义反复的测试。它可能不那么明显,但在所有情况下它都是次优的。这不是错了,测试仍然在检查报头、传感器的顺序,并且显示的值是基于sys.version_info的,但是它看起来像是在测试版本检测,而不像是在检查传感器顺序。

这个测试现在只是测试 Python 版本“sensor”在列表中排在第一位,并且显示了适当的标题。它不再测试 Python 版本传感器的任何行为。

为了确保传感器正常工作,我们将测试分成更小的单元。我们想知道的关于PythonVersion传感器的事情有

  1. 传感器的值等于sys.version_info

  2. 传感器的格式化程序返回类似“3.8”的版本字符串,即 major.minor。

  3. 传感器的字符串表示是当前值的格式化版本。

  4. CLI 输出包含标题“Python version ”,然后是格式化输出前两行值的结果。这是我们开始的测试。

这些都应该是独立的测试,因为它们都是潜在的故障模式。如果我们只有检查脚本输出的功能测试,并且发现了一个错误,那么如果不调试失败的测试并了解工具的整个上下文,就不可能知道值、格式化程序或脚本集成是否不正确。

对于其中的一些测试,我们可以完全隔离地调用函数,并查看它们的输入和输出。例如,格式化程序接受一个输入并返回一个没有副作用的输出。 10 这种测试被称为单元测试,因为我们一次测试的是源代码的单个逻辑单元

单元测试是最难在复杂代码上编写的一种测试。如果代码的结构不利于测试,就不可能写出有用的单元测试。如果我们回想一下上一章末尾的脚本版本,逻辑单元并不像它们在基于类的实现中那样定义良好。

我们编写的每个函数都涉及到调用一些其他函数来获取它们的数据,格式化逻辑与命令行处理逻辑紧密结合在一起。单元测试也是一些最有用的测试类型,因为一个失败的单元测试可以非常准确地将损坏的代码缩小到一个位置。单元测试通常执行得非常快,并且需要最少的测试设置,这使得开发人员的体验更加令人满意。

其他函数,比如__str__()方法,更加复杂,需要调用其他函数来查找结果。查找字符串值包括获取值,该值委托给库方法,然后格式化该值。这种类型的函数需要一些设置来有效地测试,因为我们需要编写测试来覆盖代码调用的库函数的行为,以便它们返回已知的值。这些类型的测试最好被描述为集成测试,但是准确的含义很难定义。集成测试通常将少量相关的功能作为一个整体进行测试,但是不同的开发人员认为集成测试有一定的灵活性。

集成测试是介于单元测试和功能测试之间的一个很好的媒介。通过编写覆盖一组相关功能的测试,他们确保代码库的逻辑组件在给定的输入和输出上正确工作。使用集成测试很难真正检查边缘情况,但是对于正确处理已知好的或已知坏的数据的测试来说,这是一个很好的选择。

前面提到的四种计划的测试类型大致属于这三类测试。这些测试中的第一个测试是非常简单的函数行为是否正确。对于更复杂的传感器,这些可能更像集成测试,但区别是为了帮助我们对测试进行推理,这不是我们应该关心的事情。

第三个测试是集成测试的一个例子。字符串表示函数调用上一步中测试的两个函数,并确保它们一起正确工作。这些测试应该相互补充;对于一个集成测试来说,顺便测试多个东西是正常的,其中一些可能与已经编写的显式单元测试重叠。

最后,我们进行了功能测试,以确保在 CLI 程序的输出中使用了传感器。像集成测试一样,这不可避免地测试了在其他地方更适合测试的东西;你不应该试图把这件事最小化。重要的是,从功能测试的名称和注释中可以清楚地看出它要测试什么。通常,功能测试是故意广泛的,而不解释它们的逻辑,当它们由于后来的改变而失败时,这是适得其反的。如果不清楚测试在做什么,那么当它开始失败时,就不清楚错误是从哪里引入的。许多不同的问题都可能导致功能测试失败,其中一些乍看起来可能并不相关。

Tip

当集成或功能测试由于部分代码库的更改而失败时,编写一个更具体的测试来涵盖这种情况是一个好主意。也就是说,如果功能测试失败,尝试添加一个单元或集成测试来隔离问题。一个测试证明一个已被修复的 bug 比一张过时的 JIRA 票更有用,尤其是当那个 bug 被重新引入的时候。

Pytest 夹具

对于除了最基本的功能之外的所有功能,很可能会有一些不同的情况被测试,所有的情况都应该有它们自己的测试功能。很常见的情况是需要设置代码,例如,如果函数是类成员而不是可导入的函数,则需要实例化类。一种方法是将测试组织成包含所有相关测试和所有这些测试共享的设置代码的类。

所有的测试框架都有一些方法来提供通用的安装和拆卸代码来支持测试。在 pytest 中,这些被称为“fixtures ”,允许在不同的支持代码之间进行非常灵活的选择。Pytest fixtures 被自动调用来匹配测试函数的参数。

组织测试的一个好方法是定义一个类来包含相关的测试和任何特定于这些测试的固定装置,并保留更多的通用固定装置供其他测试使用。这允许使用一种通常被称为“被测试主题”的风格,或 SUT。你可能会看到 FUT(被测函数)、MUT(被测方法)、OUT(被测对象)等等。

在这个测试布局中,每个类都有一个类似于MUT()method()subject()的 fixture,它返回要测试的函数。11FUT 的 fixture 可能只是导入函数并返回它,而 MUT 作为一个类方法,可能涉及创建一个类的实例并从该实例返回特定的方法。这使得单个函数可以测试一个可调用函数,而不必担心该可调用函数是如何获得的,这在测试采用许多参数进行构造的类的方法时特别有用。

首先,我们将创建一个测试类,用于测试 Python 版本号传感器的格式化程序,并为其提供一系列要测试的值。这包括一个版本传感器的测试文件(列表 2-8 ),它提供了一个代表被测传感器的sensor fixture 和一个 TestPythonVersionFormatter 类,该类使用 subject fixture 将 MUT 定义为该传感器的格式方法。

from collections import namedtuple

import pytest

from sensors import PythonVersion

@pytest.fixture
def version():
    return namedtuple(
        "sys_versioninfo", ("major", "minor", "micro", "releaselevel", "serial")
    )

@pytest.fixture
def sensor():
    return PythonVersion()

class TestPythonVersionFormatter:
    @pytest.fixture
    def subject(self, sensor):
        return sensor.format

    def test_format_py38(self, subject, version):
        py38 = version(3, 8, 0, "final", 0)
        assert subject(py38) == "3.8"

    def test_format_large_version(self, subject, version):
        large = version(255, 128, 0, "final", 0)
        assert subject(large) == "255.128"

    def test_alpha_of_minor_is_marked(self, subject, version):
        py39 = version(3, 9, 0, "alpha", 1)
        assert subject(py39) == "3.9.0a1"

    def test_alpha_of_micro_is_unmarked(self, subject, version):
        py39 = version(3, 9, 1, "alpha", 1)
        assert subject(py39) == "3.9"

Listing 2-8Initial version of test_pythonversion.py

version fixture 提供了一个看起来类似于sys.version_info结果的结构,因为 Python 在内部使用的特定对象类型不能用新值实例化。这确保了我们可以创造与sys.version_info行为相同的价值观,但我们可以控制他们的价值观。

这些测试可以用pipenv run pytest tests来运行,并且它们通过了,但是任何使用过其他单元测试框架的读者可能会担心我们在夹具上移动了太多,并且可能很难调试问题。具体来说,看一眼代码就不清楚subject指的是什么。为了证明这没有问题,我们将添加一个新的失败测试来覆盖我们想要添加的特性。

我们的格式化程序在这里只显示了发行版的主要和次要组件,假设微版本不包含任何显著的变化来突出显示。然而,在我写这篇文章的时候,有一个新的 Python 版本处于 alpha 阶段,alpha 版本之间的差异在新特性的增加方面非常显著。为此,在新的次要行中的第一个微发布的特殊情况预发布版本可能是有用的。我将添加一对新的测试来证明我们期望 3.9.0a1 有不同的输出(但是要回到 3.9.1a1 的缺省值)。

    def test_prerelease_of_minor_is_marked(self, subject, version):
        py39 = version(3, 9, 0, "alpha", 1)
        assert subject(py39) == "3.9.0a1"

    def test_prerelease_of_micro_is_unmarked(self, subject):
        py39 = (3, 9, 1, "alpha", 1)
        assert subject(py39) == "3.9"

这两项测试中的一项失败,另一项通过。这里添加两个测试的原因是为了清楚地表明,alpha 标记只对 micro 版本为 0 的情况有影响。如果没有第二个测试,如果所有预发布版本都显示完整的版本字符串,我们就会有一个通过测试的套件,这不是我们要找的特性。

如果我们现在重新运行测试,我们将会看到test_prerelease_of_minor_is_marked测试的失败以及 pytest 自动包含的大量上下文信息:

________ TestPythonVersionFormatter.test_alpha_of_minor_is_marked ________

self = <tests.test_pythonversion.TestPythonVersionFormatter object at 0x03BA4670>
subject = <bound method PythonVersion.format of <class 'sensors.PythonVersion'>>
version = <class 'tests.test_pythonversion.sys_versioninfo'>

    def test_alpha_of_minor_is_marked(self, subject, version):
        py39 = version(3, 9, 0, "alpha", 1)
>       assert subject(py39) == "3.9.0a1"
E       AssertionError: assert '3.9' == '3.9.0a1'
E         - 3.9
E         + 3.9.0a1

tests\test_pythonversion.py:28: AssertionError
=============== 1 failed, 3 passed in 0.11 seconds ========================

报告的第一件事是失败的测试的名称,随后是正在使用的夹具的表示。这些都显示在失败信息的顶部,所以我们一眼就能看出subject fixture 是PythonVersion类的一个实例 12format方法。

接下来要显示的是测试方法的主体,直到导致错误的那一行,然后是格式化错误。在这种情况下,这是一个断言错误,因为断言行失败了。我们看到了扩展版本的断言,所以我们可以看到subject(py39)的计算结果,然后在下面我们看到了两个字符串的差异。在这种情况下,diff 不是特别有用,但是对于较长的字符串,逐行进行 diff 是很方便的。

如果我们要将格式化程序方法改为

    @classmethod
    def format(cls, value):
        if value.micro == 0 and value.releaselevel == "alpha":
            return "{0.major}.{0.minor}.{0.micro}a{0.serial}".format(value)
        return "{0.major}.{0.minor}".format(value)

并重新运行测试,我们将会看到一个确认,确认test_pythonversion.py中的所有测试都已通过。

分类测试功能

我们已经决定为我们的代码编写多种不同类型的测试,涵盖从单元测试到全栈功能测试的所有内容。由于功能测试比单元测试慢得多,我们可能希望不时地将它们排除在测试运行之外,只快速运行测试的快速子集。如果我们期望看到测试失败,这可以节省大量的时间,所以我们可以避免运行更长的验证测试,直到我们知道在快速的单元测试中没有失败。

这可以使用@pytest.mark装饰器来完成。我们将使用“功能”标记将我们的test_python_version_is_first_two_lines_of_cli_output标记为功能测试。

@pytest.mark.functional
def test_python_version_is_first_two_lines_of_cli_output():
    runner = CliRunner()
    result = runner.invoke(sensors.show_sensors)
    python_version = str(sensors.PythonVersion())
    assert ["Python Version", python_version] == result.stdout.split("\n")[:2]

这允许我们用pytest -m functional调用测试,只运行功能测试

============ 1 passed, 5 deselected, 1 warnings in 3.17 seconds ============

或者使用pytest -m "not functional"运行除功能测试之外的所有测试:

============ 5 passed, 1 deselected, 1 warnings in 0.11 seconds ============

运行功能测试的开销是巨大的,运行一个功能测试的时间是运行五个单元测试的 30 倍。3 秒钟的测试运行并没有慢到让你不敢运行测试,但是我们才刚刚开始编写测试套件。当它大 10 倍时,它将是 30 秒的测试和 1 秒之间的差异。如果你的测试对你来说太麻烦而不想运行,那么它们就不会那么有用。

仅仅通过使用@ pytest.mark.something作为修饰符就可以创建任意的标记,但是会生成一个警告,告诉您它还没有被显式声明。这些警告对于发现标记名中的印刷错误很有用,所以我们应该创建一个pytest.ini文件,声明我们使用了一个functional标记。

[pytest]
markers = functional: these tests are significantly slower

新闻报道

代码覆盖率是衡量测试套件范围的一个标准。它表示在测试运行期间执行的应用基本代码的比例。有些人强烈认为高水平的测试覆盖是必要的,甚至经常说所有软件都应该 100%覆盖。

我鼓励你采取更务实的观点。一个测试套件能做的最重要的事情是给你信心,让你相信软件正在按预期运行。高覆盖率通常与自信相关,我鼓励你以高覆盖率为目标,但这不应该给你一种错误的安全感。特别是,当您接近 100%的测试覆盖率时,确保覆盖最后几行变得越来越困难,但是好处是不变的。覆盖面较低、易于理解的测试套件比过于复杂、达到 100%的测试套件要好。

为了实现代码覆盖率,我们需要一个 pytest 插件来收集数据。最简单的方法是使用pipenv install --dev pytest-cov安装 pytest-cov 插件。一旦完成,pytest 可执行文件中的--cov参数就可用了。此参数将部分代码库的路径作为可选参数。如果提供了这个,那么覆盖率报告只显示这个子路径的覆盖率数据。要查看所有代码的覆盖范围,请使用 just - cov,如下所示:

> pipenv run pytest tests --cov

我们还应该创建一个.coveragerc文件来配置我们想要看到的覆盖报告。最重要的事情是排除测试目录,因为在运行测试时被执行的测试文件的比例不是一个有用的度量,并且扭曲了我们的平均值。

[run]
branch = True
omit = tests/*

我们还添加了分支配置参数,该参数更改了覆盖率的计算,以便在同时满足TrueFalse条件时,只考虑覆盖 if 语句。如果我们运行带有--cov标志的测试,我们可以看到我们的项目到目前为止的覆盖率:

----------- coverage: platform win32, python 3.8.0-alpha-1 -----------
Name         Stmts   Miss Branch BrPart  Cover
----------------------------------------------
sensors.py     121     17     22      7    83%

========================= 8 passed in 3.23 seconds =========================

这表明,我们的测试运行检测到 83%的代码已经被测试套件覆盖,这恰恰证明了为什么我们应该怀疑覆盖率数字作为测试质量的度量。请记住,我们只为脚本中的七个传感器中的一个编写了测试,所以 83%的代码都以任何有意义的方式进行了测试的说法显然是错误的。这是由运行脚本并查看输出的功能测试导致的,因为它会导致所有代码被执行。如果我们重新运行测试,排除功能测试,我们得到

----------- coverage: platform win32, python 3.8.0-alpha-1 -----------
Name         Stmts   Miss Branch BrPart  Cover
----------------------------------------------
sensors.py     121     62     22      1    43%

================== 7 passed, 1 deselected in 0.38 seconds ==================

考虑到我们所写的测试数量,43%似乎还是一个过高的估计,但是覆盖率选项可以让我们看到哪些行被覆盖了,哪些行被遗漏了。有几种不同的显示方式,但它们都由--cov-report标志控制。包括了许多机器可读的格式,比如 XML 格式,如果您使用持续集成,这是很有用的,但是对于直接的人类消费,两个最有用的是--cov-report html--cov-report annotate

HTML 报告格式创建了一个名为htmlcov的目录,其中包含一个 index.html 文件,该文件列出了总体覆盖率和每个文件的覆盖率。通过点击您感兴趣的文件名,您会看到一个文件内容列表,在覆盖率报告中,这些文件内容的线条用它们的状态进行了颜色编码,如图 2-1 所示。 13

img/481001_1_En_2_Fig1_HTML.jpg

图 2-1

不运行功能测试时,覆盖线和未覆盖线的可视化表示

带有绿色边框且没有阴影的线条是被覆盖的线条。测试套件已经执行了这些行。带有红色边框和红色阴影的线是未覆盖的线。这些没有被执行。假设启用了分支覆盖,一些线可能会有黄色边框,并带有黄色阴影。这些是被部分覆盖的行,例如文件底部的if __name__== "__main__"构造。因为 if 语句的主体是红色的,所以很明显包含了条件评估为False的情况,但是没有包含条件评估为True的情况。

或者,annotate报告类型在与 sensors.py 相同的目录下创建一个sensors.py,cover文件。前缀为>的行被覆盖或部分覆盖;前缀为!的线未被覆盖。与前面的 HTML 截图匹配的sensors.py,cover部分如清单 2-9 所示。

> class PythonVersion:
>     title = "Python Version"

>     def value(self):
>         return sys.version_info

>     @classmethod
>     def format(cls, value):
>         if value.micro == 0 and value.releaselevel == "alpha":
>             return "{0.major}.{0.minor}.{0.micro}a{0.serial}".format(value)
>         return "{0.major}.{0.minor}".format(value)

>     def __str__(self):
>         return self.format(self.value())

> class IPAddresses:
>     title = "IP Addresses"

>     def value(self):
!         hostname = socket.gethostname()
!         addresses = socket.getaddrinfo(socket.gethostname(), None)

!         address_info = []
!         for address in addresses:
!             value = (address[0].name, address[4][0])
!             if value not in address_info:
!                 address_info.append(value)
!         return address_info

Listing 2-9sensors.py,cover representing coverage when not running functional tests

我发现 HTML 报告更容易使用,但您的偏好可能会有所不同。无论哪种方式,我们都可以看到除了PythonVersion之外的各种传感器的函数体都没有覆盖,但是类和函数定义都覆盖了。这是有意义的,因为 Python 解释器必须执行声明行来知道哪些函数、类和类属性是可用的。由于我们的函数体相对较短,测试过的函数体加上类和函数声明几乎占了语句行的一半。

Exercise 2-2: Expanding The Test Suite

我们已经为一个最简单的传感器编写了测试,但是还有其他几个传感器没有经过测试。通过向其他传感器添加测试来练习编写一些测试。

大多数传感器都遵循相同的模式,只有温度和湿度传感器例外,这种传感器要编写覆盖 value 方法的测试有些困难。

如果您可以编写一个测试套件,在使用-m "not functional"运行时覆盖 75%的 sensors.py,那么您就有了一个测试套件,它应该会让您对整个程序充满信心。

类型检查

我们在测试套件上所做的工作给了我们很大的信心,让我们相信我们所写的代码会如我们所愿的那样运行,但它对我们正确使用它的信心没有太大的帮助。我们在许多传感器中大量使用了 psutil 库,但没有为此编写任何直接测试。一些程序员陷入了编写测试的陷阱,这些测试更多的是测试他们的依赖库,而不是他们自己的代码。

如果您觉得自己需要编写测试来涵盖您的代码所依赖的库的工作方式,那么您应该后退一步,考虑最佳的行动方案是什么。作为测试套件的一部分,为库编写测试要比在消费者应用的测试中容易得多。

当人们使用第三方库时,他们通常需要的是正确使用它的信心:一致地传递参数,处理异常和不寻常的返回值,以及理解函数打算做什么。没有自动的方法来检查我们的理解,但是类型检查确实允许我们检查一些其他的情况。

如果您使用过像 Java 这样的编程语言,您就会熟悉彻底的类型检查器对代码的影响:不可能忽略异常或调用带有无效值的函数,但对其他人来说,这可能会有很大的限制。

Python 最近获得了可选地用类型注释变量的语法,以允许在基本 Python 语言之上构建类型检查。Python 本身并不为您做任何类型检查,但是 mypy 项目提供了一个对您的 Python 代码运行静态类型检查的程序。

安装 mypy

Mypy 是作为 Python 模块分发的,因此它的安装方式与我们的其他开发依赖项一样,使用

>  pipenv install --dev mypy

这将 mypy 可执行文件添加到我们的环境中,并安装 mypy 类型检查库和类型定义的 typeshed 集合。Python 标准库不包含类型检查提示,在撰写本文时,大多数第三方库也不包含。类型注释本来就是一个可选的特性,所以许多开发人员选择不使用它们也就不足为奇了。Typeshed 是 Python 软件基金会的一个项目,为标准库和各种常用的第三方库维护一组类型声明。

也就是说,许多库既不提供类型注释,也没有类型化的条目,所以当我们对使用它们的代码运行类型检查时,它会生成类型警告。如果我们在代码中调用 mypy,我们会看到关于psutil的错误,以及可选的依赖项adafruit_dhtboard

> pipenv run mypy sensors.py
sensors.py:9: error: No library stub file for module 'psutil'
sensors.py:9: note: (Stub files are from https://github.com/python/typeshed)
sensors.py:116: error: Cannot find module named 'adafruit_dht'
sensors.py:116: note: See https://mypy.readthedocs.io/en/latest/running_mypy.html#missing-imports
sensors.py:117: error: Cannot find module named 'board'

解决这个问题有两种方法:忽略它和修复它。几乎在所有情况下,将 mypy 配置为忽略这些问题比向代码使用的所有依赖项添加类型提示更能有效地利用时间。为此,我们需要添加一个 mypy 配置文件,或者作为mypy.ini或者作为setup.cfg文件的一部分,它可以包含多个不同工具的配置。添加以下内容作为setup.cfg,并重新运行 mypy,查看它是否完整,没有任何警告:

[mypy]
ignore_missing_imports = True

添加类型提示

由于我们的代码目前相对简单,所以检查传感器和添加类型提示并不困难。Python 使用的格式是

def function_name(argument: type, other: type) -> type:

所以,我们的CPULoad传感器看起来会像

class CPULoad:
    title = "CPU Usage"

    def value(self) -> float:
        return psutil.cpu_percent(interval=3) / 100.0

    @classmethod
    def format(cls, value: float) -> str:
        return "{:.1%}".format(value)

    def __str__(self) -> str:
        return self.format(self.value())

value函数的返回值总是与format函数的value参数相同。一旦添加了这个,我们就可以直接用 mypy 做实验了。例如,我们可以创建一个误用传感器的新文件,如清单 2-10 所示。

import sensors

sensor = sensors.CPULoad()
print("The CPU load is " + sensor.value())

Listing 2-10incorrect.py

Mypy 可以通过分析包含错误代码的文件和 sensors.py 文件来发现此错误,从而导致以下错误:

> pipenv run mypy incorrect.py
incorrect.py:4: error: Unsupported operand types for + ("str" and "float")

然而,有些传感器更复杂。ACStatusTemperatureRelativeHumidity传感器都有一个值,如果由于某种原因无法确定该值,则该值可以是None。对于这些,我们需要声明不同的返回类型。Python 的类型允许将类型包装到容器中,与其他语言中的泛型类似。typing.Union类型定义了一个类型,它是许多不同选项中的一个。在我们的例子中,ACStatus.value返回typing.Union[bool, None],温度传感器返回typing.Union[float, None]

我们可以通过使用Optional类型来进一步简化。OptionalUnion的特例,它接受一个类型参数,而Union不接受任何类型参数。它的行为并没有什么不同;只是更容易阅读。因此,我们的ACStatus.value()功能就变成了

    def value(self) -> typing.Optional[bool]:
        battery = psutil.sensors_battery()
        if battery is not None:
            return battery.power_plugged
        else:
            return None

最后,IPAddresses传感器的值是一个更复杂的对象。每个 IP 地址由一个二元元组表示,包含地址族和地址本身的字符串表示。传感器返回这些二元组的列表。我们可以将此声明为

    def value(self) -> typing.List:
        ...

但是如果我们这样做了,返回值[None, None, None]将被认为是有效的。我们可以声明更多的列表内部结构,以确保 mypy 能够严格执行检查。声明List内部的语法与Union相同。对于(str, str)二元组的列表,我们将使用

    def value(self) -> typing.List[typing.Tuple[str, str]]:
        ...

这并不能防止数据结构与预期相匹配的任何错误,因为我们仍然不能自动检查语义,但它确实防止了几类打字错误和疏忽。例如,我们无法防止在元组中混合两个值,但是我们无法假设元组是直接返回的,或者返回类型是包含 IP 地址的字符串列表,而没有地址族信息。

对于这个传感器,我们可能希望在格式上放松返回值的返回类型和参数类型之间的对称性。在所有其他传感器中,这些都是完全相同的,因为我们只想能够格式化我们收到的数据。在某些情况下,在格式化程序中更加灵活可能会有用。格式化程序的类型定义应该表示可能被格式化的数据,而不是我们期望的数据。我们可以格式化任何包含至少两个元素的可索引序列的 iterable,这两个元素都是字符串。如果我们传入一个列表元组,我们的格式化程序代码就像传入一个元组列表一样有效。

以下类型都是有效的选择:

  • List[Tuple[str, str]]

  • List[Sequence[str]]

  • Sequence[Tuple[str, str]]

  • Sequence[Sequence[str]]

  • Iterable[Tuple[str, str]]

  • Iterable[Sequence[str]]

语义略有不同。使用Sequence而不是List允许外部变量类型是列表或元组,使用Iterable作为外部类型允许它是列表、元组、集合或生成器。如果我们对内部类型使用Sequence[str]而不是Tuple[str, str],我们获得了内部类型成为列表的灵活性,但是我们失去了关于序列内部结构的断言。其中,我认为最好的选择是

def format(cls, value: Iterable[Tuple[str, str]]) -> str:

因为它是不允许无效数据的限制性最小的类型提示。

Tip

您可能希望先使用import typing as t,然后使用t.Union[...]t.Sequence[...]等等,而不是单独导入所有这些标记类型。这使得这些类型是类型提示的一部分这一事实对于偶尔阅读源代码的人来说更加清楚,并且避免了在添加具有新类型签名的函数时必须管理导入。

子类和继承

对于不习惯在编写由 mypy 检查的代码时使用类型提示的 Python 开发人员来说,最令人困惑的事情可能是,与他们习惯的相比,使用了更严格的类型继承视图。到目前为止,在我们的传感器文件中,我们有许多共享相同的__str__()方法实现的类。想要将这些移动到一个超类是很自然的。您可能很自然地认为这对类型提示有很大的好处,因为它允许编写显式操作Sensor的子类的代码。

这样做的问题是我们没有一个用于Sensor的公共接口。我们有几个行为相似的子类,但是它们不能互换。如果你知道你有一个 Sensor 的实例,那么你知道你有一个 value 函数,但是你没有更具体的保证关于这个函数的输出是什么。

如果我们要向超类添加一个__str__()方法,那么这个方法需要在超类本身上进行类型检查。如果value()format(...)方法缺失,那么类型检查将会失败,不管这些方法是否在子类上实现。类型检查肯定会失败,因为基类本身不会孤立地工作。同样,如果我们在超类上定义了 stub value()format(...)方法,那么这些定义将用于确定__str__()方法是否正确,而不是单个子类上的定义。

这就是静态类型化和动态类型化区别的症结所在。在动态类型的语言中,你可以相信事情发生为真,而在静态类型的环境中,你的断言必须必然为真。

想象一下我们在这里定义的超类。我们编写的非类型化 Python 的基本代码是

class Sensor:
    def __str__(self):
        return self.format(self.value())

__str__(self) -> str在一个类型化的上下文中写这个,会导致函数被类型检查,从而产生错误"Sensor" has no attribute "format"。所以,我们需要添加占位符format(...)value()方法。问题是,value 方法应该返回什么类型?我们有返回floatOptional[bool]Optional[float]List[Tuple[str, str]]的传感器。存根方法不能使用这些返回类型中的任何一种,因为它们与其他选项不兼容。如果我们使用特殊的typing.Any类型,它有效地禁用了这个方法的类型检查。如果我们对value()方法使用极其冗长的Union[float, Optional[bool], Optional[float], List[Tuple[str, str]]],那么我们说所有这些类型作为任何给定传感器的输出都是同样有效的。

如果我们试图使用相同的Union作为format(...)方法的参数类型,那么我们会遇到一个更微妙的错误。所有的子类都受到它们的超类的类型限制,但是这以不同的方式表现出来。当指定一个函数的输出时,子类必须返回一个和超类定义一样具体或者比超类定义更具体的值。因此

class Sensor:
    ...
    def value(self) -> Union[float, Optional[bool], Optional[float], List[Tuple[str, str]]]:
        raise NotImplementedError

class ToySensor(Sensor):
    ...
    def value(self) -> Optional[bool]:
        return True

是完全有效的,因为任何期望一个Sensor并被给予一个ToySensor的消费者总是会找到一个值方法,当它期望几个可能的值之一时,返回Optional[bool],包括Optional[bool]

这与处理函数参数的方式相反。在format(...)函数的情况下,超类的类型定义向该类的用户保证传递的任何值类型都是可接受的;子类不能对此进行限制,因为这意味着调用代码必须明确知道正在使用哪个传感器。因此,下面的代码会失败:

class Sensor:
    ...
    def format(self, value:Union[float, Optional[bool], Optional[float], List[Tuple[str, str]]]) -> str:
        raise NotImplementedError

class ToySensor(Sensor):
    ...
    def format(self, value: Optional[bool]) -> str:
        return "Yes"

有错误

Argument 1 of "format" incompatible with supertype "Sensor".

这里有两种方法可以采用,选择哪一种将在很大程度上取决于您从类型检查中获得多少好处。最简单的方法是让一些函数保持非类型化,无论是隐式的还是显式的。保持这种非类型化将意味着我们在处理一般的传感器时,只有在处理特定的单个传感器时,才能获得类型检查的显著好处。对于许多应用来说,这可能就足够了,而且肯定会更简单。为此,我们将创建一个传感器超类,如下所示:

class Sensor:

    def value(self) -> Any:
        raise NotImplementedError

    @classmethod
    def format(cls, value: Any) -> str:
        raise NotImplementedError

    def __str__(self) -> str:
        return self.format(self.value())

我们未来的所有代码都将限制类型检查,因为__str__()format(...)方法总是返回字符串。不会对值类型进行任何检查。

泛型类型

另一种方法是全力进行类型检查。我们已经看到,typing.List类型可以接受参数来指定列表的内容。同样,我们可以告诉类型系统,Sensor基类接受一个类型参数,该参数表示传感器操作的类型。

增加指定包含类型的能力被称为使类型通用化。我们需要将 Sensor 转换为具有单一类型变量的泛型类型,该类型变量既用作值函数的返回类型,也用作超类的参数类型。

T_value = TypeVar("T_value")

class Sensor(Generic[T_value]):

    def value(self) -> T_value:
        raise NotImplementedError

    @classmethod
    def format(cls, value: T_value) -> str:
        raise NotImplementedError

    def __str__(self) -> str:
        return self.format(self.value())

在这里,T_value不是一个类型;它是一个占位符,代表使用方括号语法在Sensor上指定的值的类型。如果有一个类型为Sensor[str]的变量,那么 mypy 将该变量的T_valuestr相关联,因此value()format(...)方法都与str相关联。重要的是,与T_value相关的类型因传感器而异;它不局限于单个类型,而是与任何特定代码段声明的Sensor的子类型动态关联。

传感器本身使用Sensor[type]作为它们的基类,但是仍然需要在函数上声明它们自己的类型提示。当 mypy 分析父类的类型提示时,它要求所有子类都必须定义类型提示才能参与类型检查。这看起来像是浪费时间,但这让任何阅读代码的人都清楚地知道所需的类型,而不必阅读超类。它还允许检查代码在子类中的内部一致性,以及它与超类的断言的一致性。结果是真实的传感器实现看起来像清单 2-11 。

class CPULoad(Sensor[float]):
    title = "CPU Usage"

    def value(self) -> float:
        return psutil.cpu_percent(interval=3) / 100.0

    @classmethod
    def format(cls, value: float) -> str:
        return "{:.1%}".format(value)

Listing 2-11Typed version of sensors

Caution

在前面的CPULoad传感器示例中,我们有value(self) -> float,但是我们可以将其更改为value(self) -> int甚至value(self) -> bool,而不会看到任何错误。这是一个支持更简单的 duck 类型的不幸的设计决定。论点是,任何接受浮点数的函数都可以接受整数,虽然不完全正确,但对于大多数目的来说已经足够接近了。此外,在 Python 中boolint的子类,所以接受浮点数的函数也可以接受布尔值而不会引发错误。因此,一个预期返回一个float但返回一个bool的函数被视为返回兼容的东西。我希望这可能会在未来产生警告。现在,你应该记住这个限制。

T_value绑定到指定的子类型这一事实的一个令人惊讶的结果就是Sensor[Any]的含义。这似乎意味着任何有效的Sensor,但实际上它意味着一个其值未经类型检查的Sensor。使用Sensor[Any]仍然比完全不使用类型检查有好处。虽然类型检查器无法检查在处理 value 参数时运行在循环Iterable[Sensor[Any]]上的代码的类型安全性,但是关于存在title属性和公共__str__()方法的断言在所有传感器类型中都是通用的,因此仍然可以检查。

调试和过度使用打字

使用 mypy 时,查看调试信息有时会很有用。Mypy 没有交互式调试器,所以如果我们在理解错误发生的原因方面有问题,我们必须通过 reveal_type 函数求助于 printf 风格的调试。

例如,让我们创建一个测试脚本,它以不正确的方式使用 sensors.py 中的一些代码:

from sensors import CPULoad

sensor = CPULoad()
print(sensor.format("3.2"))

如果我们调用 pipenv run mypy broken.py,我们将得到以下预期的错误:

broken.py:4: error: Argument 1 to "format" of "CPULoad" has incompatible type "str"; expected "float"

但是如果我们把 broken.py 更新得更复杂一点

from sensors import CPULoad, ACStatus

two_sensors = [CPULoad(), ACStatus()]
print(two_sensors[0].format("3.2"))

然后重新运行 mypy,我们看到的错误更基本:

broken.py:4: error: "object" has no attribute "format"

在这种情况下,mypy 似乎推断出了two_sensors列表的错误类型。我们可以将reveal_type(two_sensors)添加到定义好的源文件中,看看 mypy 发现了什么。请注意,reveal_type不是一个真正的函数。它不需要导入,因为它是 mypy 解析器的构造,而不是 Python 代码。如果您将它留在代码中,那么当代码运行时,它将导致错误。仅在运行 mypy 时将其添加为临时调试辅助工具。添加了reveal_type(two_sensors)后,我们会在 mypy 输出中看到以下附加行:

broken.py:4: error: Revealed type is 'builtins.list[builtins.object*]'

显示 mypy 已经将变量解释为对象列表,而不是传感器列表。如果我们从键入模块导入适当的名称,并向two_sensors行添加一个显式类型,比如

two_sensors: List[Sensor[Any]] = [CPULoad(), ACStatus()]

那么 mypy 的输出变成了

broken.py:6: error: Revealed type is 'builtins.list[sensors.Sensor[Any]]'

如前所述,typing.Any的使用是鱼龙混杂。这个定义意味着从这个列表中检索到的任何传感器都属于类型Sensor[Any],因此two_sensors[0].format("3.2")将不再被 mypy 检测为错误。

在当前的例子中,我们有两个传感器,一个返回一个浮点数,另一个返回一个Optional[bool],所以我们可以将列表声明为

two_sensors: List[Union[Sensor[float], Sensor[Optional[bool]]]] = [CPULoad(), ACStatus()]

这意味着two_sensors将只包含那些类型的传感器,但这仍然不是特别有用。我们现在得到一对错误线:

broken.py:7: error: Argument 1 to "format" of "Sensor" has incompatible type "str"; expected "float"
broken.py:7: error: Argument 1 to "format" of "Sensor" has incompatible type "str"; expected "Optional[bool]"

显示 mypy 确实已经确定调用不正确,但是基于信息,它无法知道floatOptional[bool]是否是正确的选择。我们可以通过返回的reveal_type(two_sensors[0].format)看到更多关于它所抱怨的格式方法的信息

broken.py:6: error: Revealed type is 'Union[def (value: builtins.float*) -> builtins.str, def (value: Union[builtins.bool, None]) -> builtins.str]'

也就是说,mypy 知道它是两个函数签名中的一个,一个接受称为 value 的 float,另一个接受称为 bool 或 None 的 value,两者都返回 str。根据输入提示,这两种情况都同样有效。我们无法让 mypy 检测到正确的函数,除非我们将类型声明为

two_sensors: Tuple[Sensor[float], Sensor[Optional[bool]]] = (CPULoad(), ACStatus())

这些都是荒谬的长度去。这说明了如果你过于教条地对待输入,你的代码会变得多么不可维护。在这种情况下,您可以选择接受较低级别的类型检查,或者完全重新构建您的程序,以通过避免混合类型的情况来允许更容易的类型检查。就个人而言,我会选择少检查。

何时使用打字,何时避免打字

一般来说,类型提示在 Python 中是一个非常可选的特性。有些人喜欢静态类型鼓励的更严谨的风格,但是如果这种风格对你来说不自然,那么我不建议仅仅因为它使工具更容易就进行转换。

将类型检查作为一种帮助自己的方式,而不是检测所有可能错误的方式。你必须在你的编码中判断每一个正确输入的好处,权衡增加代码复杂性的坏处。通常有一个明确的中间地带,任何额外的输入都很难正确表示,任何更少的输入都不会显著简化代码。

例如,在这个项目的后期,我们将需要绘制一些传感器的输出随时间变化的图表。返回 float 或 int 的传感器很容易绘制图表,因为它们是一个定量值。返回字符串列表或sys.version_info列表的传感器没有被转换成图表的自然方法。

对于这些,我们可以想象编写使用一系列传感器的代码,这些传感器都使用数字类型(或者可选的数字类型)作为它们的输入。这将允许我们限制从 value 函数返回的预期类型,并确保该函数的其余部分是类型安全的,而无需我们确保在整个代码库中的所有变量中维护每个传感器的确切类型。

更一般地说,并不是所有的项目都从静态类型中获益匪浅。如果一个项目有一组相对简单的返回已知类型的函数,这将是一个真正的好处。一旦你开始需要大量使用Union或定制的泛型类型,支持类型化的论点就变得越来越弱。

在我看来,最重要的考虑是开发软件的人是否想使用静态类型。如果您和您的同事喜欢这种工作方式带来的严格性,那么使用静态类型可能是个好主意。如果你在代码审查和测试上花费了大量的时间和精力,那么增加测试的好处可能会少得多。

如果您正在编写一个供他人使用的库,那么最好至少有外部接口类型提示,因为它允许您的用户使用类型提示,而不会将您的库标记为排除在类型检查过程之外。

在本书中,我们将包含代码的类型提示。因为代码是由一个不反对类型提示的人编写的,所以没有特别的理由去避免它。好处是双重的。首先,如果在代码示例中发现一个小的 bug,书籍很难更新。使用类型提示使得第一次获得正确的代码更加容易。其次,如果你以前使用过这个特性,你会更容易凭直觉判断它是否会在你的项目中有用。当我们在章节中构建这个例子时,你可能会发现自己不同意我选择的类型提示。不要忽视这些想法,知道什么对你来说是自然的,是设计测试套件和静态检查器的成功的一半。

将类型提示与代码分开

在代码中使用类型提示的另一种方法是在 pyi 文件中与代码一起定义它们。这些行为就像。对于熟悉 C 编程的人来说。代码的结构保持不变,但是没有实现。如果从事软件工作的大多数开发人员不使用类型提示(例如,如果它们是为代码的外部消费者设计的),或者如果您的类型结构非常复杂,以至于代码看起来很混乱,那么这可能是有益的。它的部分实现如清单 2-12 所示。

#!/usr/bin/env python
# coding: utf-8
import math
import socket
import sys

import click
import psutil

class Sensor:

    def value(self):
        raise NotImplementedError

    @classmethod
    def format(cls, value):
        raise NotImplementedError

    def __str__(self):
        return self.format(self.value())

class PythonVersion(Sensor):
    title = "Python Version"

    def value(self):
        return sys.version_info

    @classmethod
    def format(cls, value):
        if value.micro == 0 and value.releaselevel == "alpha":
            return "{0.major}.{0.minor}.{0.micro}a{0.serial}".format(value)
        return "{0.major}.{0.minor}".format(value)

Listing 2-12Partial sensors.py file without inline type definitions

为前面的部分文件匹配 sensors.pyi

from typing import Any, Iterable, List, Optional, Tuple, TypeVar, Generic

T_value = TypeVar('T_value')

class Sensor(Generic[T_value]):
    title: str
    def value(self) -> T_value: ...
    @classmethod
    def format(cls: Any, value: T_value) -> str: ...

class PythonVersion(Sensor[Any]):
    title: str = ...
    def value(self) -> Any: ...
    @classmethod
    def format(cls: Any, value: Any) -> str: ...

这些存根文件可以由 mypy 从标准 Python 文件中生成。这些生成的文件在使用之前必须进行编辑,因为它们不包含除typing.Any之外的任何类型声明。这些文件是使用 stubgen 工具生成的,如下所示:

> pipenv run stubgen sensors.py
> cp out/sensors.pyi ./sensors.pyi

在我看来,除非有充分的理由,否则应该避免使用这种格式。这更难维护,因为新的函数需要添加到pyi文件和py文件中,并且在某些情况下使用类型注释会稍微困难一些。比如在组合语法中,Sensor[float]是有效的 Python,但是在这种拆分形式中,Sensor基类没有从Generic继承的__getitem__方法,所以Sensor[float]只在 pyi 文件中有效,在 py 文件中无效。如果我们想在py文件中使用Sensor[float],而不仅仅是在pyi文件中,我们必须使用遗留注释语法来定义类型:

sensor = [CPULoad(), ]  # type: List[Sensor[float]]

Exercise 2-3: Expanding The Typing Coverage

我们已经有了一个传感器的基类,并研究了如何将它应用到一个传感器上。

浏览 sensors.py 文件中的其余传感器,并更新它们以使用带有适当类型提示的传感器基类。

您可能希望尝试对 mypy 使用- strict 命令行标志,以查看默认情况下不会引发的其他警告,例如,因为我们忽略了外部模块。

您必须做出一些选择,特别是关于如何处理来自psutil的非类型化变量和一个难以类型化的特定传感器。

Lint

Lint 是许多不同类型的代码静态分析的通称。在某种程度上,mypy 在上一节中所做的静态分析是一种非常技术性的、计算机科学驱动的 Lint。与类型检查相比,我们将在本节中讨论的 Lint 涉及的内容要少得多,并且更容易引入到现有的项目中。

我选择的 linter 是 flake8,这是对 Python 增强建议(PEP8)的参考,它定义了 Python 代码的风格指南。 14 Flake8 和其他 linters 比这个风格指南走得更远,以产生符合最佳实践和一些备受尊敬的 Python 开发人员的意见的代码。您可能会发现不同的 linter 与您选择的代码编辑器集成得特别好,在这种情况下,我会推荐使用那个。

您会不可避免地发现,一些 linters 执行您认为不重要的检查,或者可能会遗漏一些您认为应该在代码中强制执行的检查。由于这个原因,flake8 是非常可定制的,允许一个软件的作者定义如何检查它的代码。作为一个软件的作者或维护者,您可以随意设置这些值,这样您就可以从 linter 中获得最大的好处。如果您发现自己为他人维护的代码做贡献,那么他们对 flake8 配置的选择可以帮助您在提交补丁之前就知道您是否编写了一些他们可能不喜欢的代码。不得不调整代码来传递过分热心的 linter 可能会令人沮丧,但这比不得不在 pull 请求上一个接一个地检查注释要令人沮丧得多,因为维护人员会注意到它们。

由于 linters 的许多抱怨都是基于格式的,因此有一种趋势是 linters 自己解决格式的一致性问题。Python 社区这个领域的暴走领袖是黑人。 15 Black 自动以一致的方式重新格式化您的代码。使用黑色棉绒比使用其他棉绒有许多优点。最主要的一点是,从情感上来说,接受对代码格式没有控制权比处理大量看似无关紧要的变更要容易得多。使用黑色的一个很大的优势是不用去安抚空白的短绒。

Caution

如果你正在为一个不使用 black 的代码库做贡献,确保你只贡献你想要的改变。git 命令git add --patch是一个很好的工具,可以准确地选择提交哪些变更。如果您提交了一个项目,该项目重新格式化了与您的更改无关的代码,那么提交很可能会被恢复,人们会感到不安。

安装 flake8 和黑色

我们将安装并设置 flake8 和 black 来运行我们的代码。这两者都是开发依赖项,而不是核心依赖项,因此安装时带有--dev标志。 16

> pipenv install --dev flake8 black

修复现有代码

然后,我们可以对我们的代码(或测试)运行 flake8

> pipenv run flake8 sensors.py
> pipenv run flake8 tests

如果您运行其中任何一个,您将看到几个必需的更改。其中许多是空格的改变,但其他的是与代码格式有关的。我们不想手动进行所有这些更改,所以让我们使用 black 来重新格式化我们的代码。 17

> pipenv run black sensors.py tests

现在这些文件已经被重新格式化,我们希望 flake8 只报告不是由格式化引起的错误。然而,我们还需要做几件事情。首先,black 的默认行长度是 88 个字符,但是 flake8 的是 80 个字符。我们需要更新薄片配置,以使用与黑色相同的值。这是通过在现有的 mypy 配置旁边向setup.cfg添加一个[flake8]部分来实现的。

[mypy]
ignore_missing_imports = True

[flake8]
max-line-length = 88

当我们运行pipenv run flake8 sensors.py时,我们仍然会看到一些错误。这是因为我们有过长的注释,并且注释是为人类而不是 Python 解释器准备的,black 没有为我们拆分它们。使 sensors.py 通过 flake8 测试所需的更改是最小的,但是当我们运行测试时,我们看到了几个需要修复的真正错误。 18

> pipenv run flake8 tests
tests\test_acstatus.py:2:1: F401 'socket' imported but unused
tests\test_acstatus.py:41:26: E712 comparison to True should be 'if cond is True:' or 'if cond:'
tests\test_acstatus.py:46:26: E711 comparison to None should be 'if cond is None:'
tests\test_acstatus.py:51:26: E711 comparison to None should be 'if cond is None:'
tests\test_cpuusage.py:2:1: F401 'socket' imported but unused
tests\test_dht.py:2:1: F401 'socket' imported but unused
tests\test_dht.py:57:13: F841 local variable 'f' is assigned to but never used
tests\test_ramusage.py:2:1: F401 'socket' imported but unused
tests\test_sensors.py:1:1: F401 'sys' imported but unused

在这个输出中,我们得到了文件名,后面是行号,再后面是该行中的列号(如果不适用,则为 1)。最后,我们给出了样式错误的 flake8 代码和一个易读的解释。通过将检查添加到setup.cfg中的ignore=行,代码号用于排除正在运行的检查。

这些抱怨中的每一个都很清楚;一行一行地检查并做出建议的更改是一项相对机械的任务。我建议从错误列表的底部开始,向上努力。如果从顶部开始,一路向下,那么行号可能不正确,因为删除了不需要的导入行来修复F401错误。

自动运行

手动运行 linters 当然是可能的,但是我们现在有四种不同的检查要记住运行,以确保代码是可接受的。很容易错过其中的一个,并意外地犯下不符合标准的事情。一旦它被提交,修复就变得更加困难;要么需要编辑提交以包括修复,要么需要仅包含那些修复的新提交。在使用 linters 的项目中,看到类似“PEP8”、“Fixes”或“Flake8”这样的提交消息是很常见的,但是并没有一致地使用它们。

使用 linter 的一个主要原因是第一次就把事情做好,所以为了获得最大的好处,它应该在每次提交时运行,而不仅仅是每次推送或者当作者喜欢时运行。如果代码库接受外部贡献或来自不止一个开发人员的工作,就好像有些开发人员没有运行 linter 一样,那么这一点就特别重要,因为它所发现的错误不能保证与您正在进行的更改有关。

因此,我在本章推荐的最后一个工具叫做预提交。这是一个管理 Git 提供的钩子的工具,用于确定是否应该允许提交。它是用 Python 编写的,所以可以很容易地用和我们所有其他开发工具一样的方法安装。

> pipenv install --dev pre-commit

我们需要配置预提交,通过将它们输入到.pre-commit-config.yaml配置文件中来了解我们想要运行的三件事。预提交广泛支持通过 GitHub 使用社区编写的配置,这是官方推荐的配置钩子的方式。然而,我发现对许多人来说,直接在存储库中编写一个手动挂钩更快,如清单 2-13 所示。如果您愿意,有许多外部维护的钩子可供选择,但是这种显式的方法通常就足够了。

repos:

- repo: local
  hooks:
  - id: black
    name: black
    entry: pipenv run black
    args: [--quiet]
    language: system
    types: [python]

  - id: mypy
    name: mypy
    entry: pipenv run mypy
    args: ["--follow-imports=skip"]
    language: system
    types: [python]

  - id: flake8
    name: flake8
    entry: pipenv run flake8
    language: system
    types: [python]

Listing 2-13.pre-commit-config.yaml

我们没有将 pytest 作为该套件的一部分自动运行,因为我们预计 pytest 会随着项目的进展而变慢。随着代码库的增长,静态分析工具不应该变得太慢,但是测试可能会做得很好。

一旦该文件就位,就配置了预提交。每个用户都需要在签出时启用预提交,这是通过

> pipenv run pre-commit install

从这一点开始,所有的提交都由这三个检查器保护。可以跳过检查(例如,如果进行一个快速的正在进行的工作提交,并且您打算在以后进行更改)。跳过检查是通过 git commit 调用中的--no-verify参数或者通过将SKIP环境变量设置为要跳过的检查器的名称来完成的。 19

Tip

我经常使用git add --patch来交互地放置我作品的“大块头” 20 ,而不是一次添加整个文件。如果您也这样工作,您可能会对 linters 和 formatters 有所顾虑,因为当您提交时,可能已经为您打算提交的下一个提交准备好了代码。

预提交程序很好地处理了这个问题。任何未暂存的更改都将存储在由预提交管理的独立存储中(它不会干扰您现有的存储),因此验证器和重新格式化器只对您已暂存的代码起作用。在我看来,这就是预提交的“杀手锏”特性。

根据拉取请求运行

像 GitHub 和 GitLab 这样的版本控制软件的现代前端支持持续集成挂钩。这些允许外部服务对您的提交、分支和拉请求运行验证,并在用户界面中用结果对它们进行注释。许多不同的产品都提供这种功能,所有产品都有不同的功能集和定价结构。

Github 提供了一个简单的基于 docker 的 CI 运行程序,以及许多商业产品。GitLab 方法反映了 GitLab 本身,因为它们都是开源的,可以根据您的要求进行配置。这里有许多不同的方法,我不可能给出一个对每个人都有用的建议,所以本节只讨论一般的方法。我个人一般用 Github 的动作。

持续集成软件提供的信息有两个目标用户。显而易见的是包的维护者。如果您有一些其他人可以访问的代码,无论是公开的补丁还是仅仅来自您的同事,您都会想知道建议的补丁中是否有任何明显的错误。维护一个软件可能是非常辛苦的工作;如果您必须检查一个分支,并在您的本地计算机上构建它,然后发现提交的内容有一个打字错误,这使它无法工作,那么它会变得更加困难。持续集成通过执行您通常会做的常见检查来减少您的工作量,并让您专注于审查代码。

信息的不太明显的用户是变更的作者。每当你第一次为某个软件做贡献的时候,确保你没有犯一个小错误是很伤脑筋的。没有人喜欢犯错,尤其是在公共场合或在你的同伴面前。持续集成有助于在没有其他人积极参与的情况下,警告您是否出现了问题。当你提交一个拉取请求时,你可以看着一个个检查通过,并确保你的贡献不会因为一个简单的错误而被视为浪费某人的时间。

这对于拥有非常慢的测试套件或依赖于特定操作系统或依赖版本的测试套件的项目尤其有用。可以设置持续集成来在 Linux、Windows 和 macOS 上运行您的软件。Django 测试套件可以在每一个支持的数据库架构上运行,包括 Oracle 这样的非免费数据库。要求所有提交补丁的人针对所有这些不同的配置运行测试是不可行的,所以 CI 服务器会处理它。

摘要

在这一章中,我们已经将我们的工作示例从一些基本函数扩展到了实现这些功能的类,使得构建即将到来的特性变得更加容易。我们已经实现了自动化测试,因此我们可以确信我们所做的更改不会破坏整个过程,以及类型检查和 Lint 来捕捉潜在的基本错误。

我们已经看了三大类软件(测试、类型检查和 Lint),它们帮助作为软件工程师的你写出你有信心的代码。您可能经常看到有人提倡这三种方法,以及应该应用于它们的使用的特定哲学,例如 100%测试覆盖率。这些方法的价值在于它节省了你和为你的软件做出贡献的人的时间,这应该是你判断如何使用它们的标准。

一般来说,最努力的方法会有最大的回报。因此,测试您的代码具有最高的潜在回报,并且被广泛认为是一个好主意。测试驱动开发和在主要开发之后编写测试的相对优势,以及 100%测试覆盖率的相对优势,以及不同类型测试的好处就不那么显著了。对于任何比玩具项目更复杂的事情,我强烈建议至少编写一些测试。它们不一定是很棒的测试,但是随着时间的推移,某种程度的测试通常会对你有所帮助。

静态类型检查有很大的好处,尤其是在编写大型复杂代码时。它还需要一些关于如何处理过程的决策,并且有一个重要的学习曲线。不精通测试的开发人员不会每次都面对测试套件的细节;静态类型就不是这样了。在整个代码库中都有键入工作的证据,编写一个新函数需要考虑静态类型。出于这个原因,如果你有充分的理由,我建议只使用静态类型。在我看来,最好的原因是开发团队认为它是有帮助的。其他因素,比如对高代码复杂性的预期,或者未来用户可能希望对他们的代码使用类型检查,也很有说服力。

最后,Lint 很容易实现,但好处相对较少。这当然会节省你一些时间(也许还会节省一些时间),但它只会发现相对较浅的错误和风格上的改进。这值得去做,但不值得为此而紧张。我强烈建议所有 Python 项目使用某种 linter,并且我鼓励任何多人参与的项目使用代码格式化程序。也就是说,如果你觉得某些警告没有帮助,不要害怕忽视它们。

在下一章中,我们将以可安装的方式打包这个软件,并提供一种通过插件架构将额外的传感器添加到可用集合的方式。

额外资源

以下资源提供了本章所涵盖主题的附加信息:

三、打包脚本

我们希望到目前为止我们开发的 Python 代码可以在几台不同的计算机上运行,但是由于它当前存储为 Python 文件的目录,因此很难部署更新的版本并确保所有部署都是同步的。在前两章中,我们已经通过使用 pipenv 脚本与 Python 中的包管理进行了交互,但是下一步是我们自己使用这个系统,而不仅仅是依赖它。

Python 中使用的打包过程几年来一直在变化。整体流程一直在稳步提升,变化还在频繁落地。多年来,设置过程一直通过一个名为setup.py的文件进行,该文件在函数调用中声明依赖关系和元数据。该函数是从几个助手库中的一个导入的(通常是setuptools,但不总是如此)。

这种方法最大的问题可能是一些包想要利用依赖库来计算setup.py中的元数据(例如,从版本控制中提取版本信息),但是这种依赖需要在setup.py本身中指定。这导致了先有鸡还是先有蛋的情况,在这种情况下,不可能确定运行声明依赖关系的脚本所需的依赖关系。

这不是一个很好的情况,但是由于大多数软件没有利用这个特性,这在某种程度上是一个学术问题。也有大量的发行格式,常见的有多年的tar.gzzip,它们是源代码的简单存档。它们最容易创建,但是存在循环依赖问题,并且需要执行代码来完成安装。如果安装到系统 Python 环境中,这就意味着以 root 身份运行从网上下载的代码,这足以吓退大多数信息安全团队。

出于这个原因,2012 年开发了一个名为 wheel 的基于 zip 的标准格式。Wheel 允许在不执行任何定制代码的情况下安装 Python 包。事实上,安装 Python wheel 所需要的只是将内容提取到正确的目录中。 1 Wheel 类似于一种早期的发行版格式 egg,它也允许在安装时不执行任意代码地安装 Python 代码,但是做出了一些不同的技术选择。您通常不需要与 egg 文件进行交互,但是如果您遇到 egg 文件,最好知道它们是什么。

多年来,Python 文件的打包方式发生了许多变化。事实上,多年来对 Python 的持续批评之一与打包故事有关。几乎每个专业 Python 开发人员都经历过打包不能按预期工作的问题。尽管如此,在过去几年中,软件包安装的可靠性似乎有所提高。现在,大多数创新似乎都围绕着更好的环境管理用户体验,而不是修复损坏的系统。要解决打包 Python 软件的问题,还有一段路要走,还有几种不同的方法,其中一些很可能在未来几年超越本章推荐的方法,成为当前的最佳实践。目前还不清楚这些软件中的哪一个(如果有的话)会赢得这场竞赛。

术语

本章中使用的一些术语有时会在日常用语中被误用,这种情况比大多数编程术语更甚。从上下文中通常可以清楚地看出含义,每个术语的具体含义不是开发人员每天都需要关心的事情,但是确保文档中使用的含义清晰是很重要的。

文件脚本模块在谈论 Python 代码时经常用来表示同一个意思。Python 文件是文件系统中包含代码的foo. py 文件。脚本是可以作为逻辑单元直接执行的文件。模块是从 Python 环境中导入代码时得到的东西。

同样,目录(或文件夹)和合并。目录是文件系统上存储文件的位置;模块的重要容器。如果import foo.bar有效,那么foo必须是,但是bar可以是或者是模块。在这种情况下,执行import foo的代码将foo绑定为由文件foo/__init__.py支持的模块。如果需要区分包和它们所包含的包,它们被称为顶层包子包

最令人困惑的是,准备一组文件文件夹分发给用户的行为被称为打包。这样的结果,一个zip,一个tar.gz,或者一个wheel文件,被称为分布。一个发行版可以直接包含多个顶层包(及其包含的子包和模块)和/或模块

在非正式用语中,通常将独立分发的库或应用称为包,使用顶层包作为分发本身的占位符。

目录结构

我们打包代码要做的第一件事是将它移动到一个目录中来存放相关的代码。这不是严格要求的,有些包,比如 Python 2/3 兼容性填充程序 six 是作为单个six.py文件分发的,而不是作为一个six/目录,但这是目前最常见的方法。大多数 Python 包安装在一个平面名称空间中,其中一个目录包含 Python 文件和子目录,该目录被添加到导入名称空间中。例如,django 被打包在一个名为django/的目录中,因此可以作为import django导入。导入 django 的结果是一个对应于django/__init__.py的模块对象,存储在 Python 环境的内部site-packages/目录中。一般来说,这是你的软件应该采用的结构。

另一种方法是使用名称空间包。命名空间包是模块命名空间中的目录,保证不包含任何代码,只包含其他包。这允许开发人员创建多个不同的代码发行版,将他们的软件安装到一个位置。对于简单的程序来说,这通常是多余的,但是非常大的应用可能有多个松散耦合的组件,这非常适合。这种多重包装方法既有优点也有缺点。它允许对应用的不同逻辑组件进行独立的版本控制和发布,如果所有组件都将一起发布,则会增加发布过程的大量开销。

如果将您的代码发布为多个发行版确实有意义,那么有几种不同的方法来命名它们。名称空间包本身并没有很多固有的优势;import apd_sensorsimport apd.sensors实际差别很小;名称空间布局看起来稍微干净一些,所以我通常在处理作为多个包分发的代码时使用它。

Tip

根据经验,如果您希望创建foo.barfoo.bazfoo.xyzzy,但从不创建foo,那么foo应该是一个名称空间包。

为我们的示例创建一个apd名称空间是有意义的。这允许我们的apd.sensors包与我们将在后面的章节中创建的apd.collector包并排放置,以整理和分析我们找到的数据。

我们需要将我们的sensors.py移动到一个新的目录结构中,以匹配我们想要提供的包,所以它变成了apd/sensors/sensors.py。这个apd/sensors目录需要一个__init__.py作为有效的包,但是我们可以让它为空。要求名称空间包不包含__init__.py(因为多段代码可以在同一个名称空间中,否则可能有多个同样有效的__init__.py)。 2

这种目录布局在 Python 项目中很普遍,但是我强烈推荐一种替代方案,通常称为“src 布局”。使用这种布局时,apd/目录存储在src/目录中,所以在src/apd/sensors/sensors.py可以找到sensors.py文件。原因是 Python 允许从当前工作目录导入代码,所以如果可用的话,import apd.sensors会自动从apd/sensors/__init__.py读取代码。src/结构确保这种情况不会发生,因此导入的版本始终是环境中安装的版本。

到目前为止,我们一直依靠这个技巧来使我们的代码可导入。sensors.py文件在工作目录中,所以测试代码可以导入它。因此,能够从当前工作目录导入代码似乎是一个好处。这意味着您正在处理的代码对于 Python 来说总是可用的,但是在某些情况下,这可能会导致令人困惑的错误。

Pipenv 支持一个标志-e,意思是“可编辑的”,它提供了一种结构化的方式来实现同样的事情。当我们将代码安装到一个环境中时,相关的文件被复制到该环境的内部目录中,因此 Python 可以在一个一致的地方找到所有的文件。当安装了带有这个标志的东西时,代码不会被保存在虚拟环境中。相反,在 Python 文件的内部目录和您的工作目录中的文件之间建立了一个链接(或者从版本控制系统中签出,如果给定的是 VCS URL 而不是文件系统路径-请参见表 3-1 了解该标志如何影响不同安装类型的详细信息)。这意味着对这些文件所做的任何更改都会立即反映在虚拟环境中。

表 3-1

使用和不使用 editable 标志从不同来源安装软件包的行为

|

安装源

|

使用-e

|

不带-e

|
| --- | --- | --- |
| 文件系统路径./six | 安装脚本中定义的软件包作为参考安装到位。 | 安装脚本中定义的包被复制到虚拟环境中。 |
| VCS path【3】git+ssh://git@github.com/benjaminp/six.git#egg=six | 仓库被签出到$(pipenv --venv)/src并作为参考安装到位。 | 下载存储库,然后复制到虚拟环境中。 |
| 来自 PyPI 的分布six | 不支持。软件包被正常下载和安装。 | 软件包被正常下载和安装。 |

这种方法使我们能够确保我们正在编辑的代码被 Python 解释器使用,而且通过使用最终用户将使用的相同的依赖和环境管理系统,使我们对代码的打包充满信心。

假设我们有办法确保在环境中使用本地文件,就没有理由依赖当前的工作目录技巧。事实上,在极少数情况下,它会引起混乱。如果安装到虚拟环境中出现问题,例如,由于代码的元数据文件中的错误,可能会导致安装部分工作(而不是像我们预期的那样根本不工作)。这种行为通常是不一致的,根据发出哪个工作目录命令而表现不同。

我们对代码的重新安排给出了以下目录结构:

apd.sensors/
├── src/
│    └── apd/
│         └── sensors/
│              ├── __init__.py
│              └── sensors.py
├── tests/
│    ├── __init__.py
│    ├── test_acstatus.py
│    └── ...
├── .pre-commit-config.yaml
├── Pipfile
├── Pipfile.lock
├── pytest.ini
└── setup.cfg

安装脚本和元数据

在本章的介绍中,我们提到 Python 包的元数据传统上存储在一个setup.py文件中。这个文件包含对一个特殊的setup(...)函数的调用,关于这个包的各种元数据作为参数给出。对于我们的包装,我们需要以下setup.py:

from distutils.core import setup

setup(
    name="apd.sensors",
    version="1.0",
    packages=["apd.sensors"],
    package_dir={"": "src"},
    license='MIT'
)

有了这个文件,我们对代码的打包就处于最低功能阶段。我们可以将当前目录中的包安装到我们的隔离环境中,并运行在apd.sensorssensors模块中定义的脚本:

> pipenv install -e .
> pipenv run python -m apd.sensors.sensors

属国

我们现在有了一个环境,它包含了我们所有的依赖库和安装到环境中的代码,就像 PyPI 上的任何可用包一样。然而,依赖关系仍然由 Pipenv 管理,而不是通过apd.sensors包来解决。我们总共只向我们的环境添加了八个开发依赖项,但是它们的依赖项,包括直接的和间接的,已经向我们的环境添加了 70 个包。我们不希望用户不得不手动安装apd.sensors正常工作所需的库;为了实现这一点,我们将库的硬依赖项转移到了setup.py

Pipfile 中[packages]的内容是我们的非开发需求,看起来像

[packages]
psutil = "*"
click = "*"
adafruit-circuitpython-dht = {markers = "'arm' in platform_machine",version = "*"}
apd-sensors = {editable = true,path = "."}

我们可以看到声明了三个依赖项。在这三个版本中,没有一个设置了任何版本限制,正如版本设置为"*"的事实所示,但是其中一个有一个平台标记。如果我们将它转换成 setup.py 期望的格式,它就变成了

from setuptools import setup

setup(
    name="apd.sensors",
    version="1.0",
    packages=["apd.sensors"],
    package_dir={"": "src"},
    install_requires=[
        "psutil",
        "click",
        "adafruit-circuitpython-dht ; 'arm' in platform_machine"
    ],
    license='MIT'
)

此时,我们可以手动或使用pipenv uninstall psutilPipfile中移除多余的线条(以此类推)。

Caution

Pipfile中定义的条件依赖总是被添加到Pipfile.lock文件中,不管它们在当前平台上是否需要。只有当前平台需要时,才会添加您安装的软件包的条件依赖项。对我们来说,这意味着我们需要在 Raspberry Pi 上重新运行pipenv lock来锁定特定于 ARM 的依赖关系。一般来说,Pipfile.lock文件在给定的计算机上创建可重复的构建。并不保证能够产生一个可重现的构建,在一系列不同的 Python 版本、操作系统或处理器硬件上工作(尽管它经常这样)。

这是setup.py文件的最小可用形式,用于生成供其他人使用的发行版。命令pipenv run python setup.py sdist生成一个可以与其他人共享的源代码发行版,以方便代码的安装。源代码发行版是 Python 软件发行版最常见的格式。该文件存储在dist/目录中,可以在线共享,用户可以通过 URL 安装。

声明性配置

到目前为止,我们一直遵循大多数 Python 包使用的setup.py方法,但是 setuptools 确实允许使用setup.cfg的更具声明性的配置方法。这种方法比较新,我更喜欢它,因为它为人们通常希望用于元数据管理的各种特性提供了助手功能。

下一节解释了包元数据的三个常见需求,当使用setup.py样式时,所有这些都会导致问题。虽然有些是可以用setup.py实现的,但是所有的都是在setup.cfg风格中微不足道的,在随后的章节中会详细介绍。

setup.py 中要避免的事情

最好避免在setup.py中出现任何逻辑,因为环境管理工具会做出一些假设,期望setup.py的行为就像它只调用setup(...)一样。任何额外的逻辑都会导致这些假设是错误的。

条件依赖

过去常见的模式是根据对主机状态的检测,有条件地包含依赖关系。例如,我们只需要树莓 Pi 上的温度传感器代码。我们通过使用带有内置条件的依赖关系定义实现了这一点。考虑以下(虚构的)示例,该示例显示了使用条件依赖的手动系统:

if sys.platform == "win32":
    dependencies = [
        "example-forwindows"
    ]
else:
    dependencies = [
        "example"
    ]
setup(
    ...
    install_requires=dependencies
)

对于大多数人来说,这种方法基本上是可行的。前面的代码清单表示在 Windows 计算机上安装时使用作为example-forwindows分发的example包的一个分支。虽然不太常见,但当用户希望在一个非常不同的平台上使用一个包时,它会被分叉,但维护者不想保持这种兼容性。

这种方法的问题是没有保证setup.py在安装目标机器上执行(或者,事实上,它不在其他机器上执行)。如果我们同时在 Windows 开发环境和 Linux 生产环境中使用这些代码,我们将会看到这样的后果。当开发人员运行pipenv lock时,Pipenv 会执行每个依赖项的setup.py脚本,以找到所需的全部依赖项。 4 因此,它会确定这里的包依赖于example-forwindows,并且会锁定example-forwindows的最新版本(包括保存所有允许的安装文件的验证哈希),而不会查看example。这种程序性的条件依赖声明使得用户可以声明条件依赖,而setup(...)函数(以及包管理器)并不知道它们是有条件的。

如果这个Pipfile.lock用于在生产主机上安装软件,那么 pipenv 安装的就是 windows fork 库。在最好的情况下,这是行不通的,但是也可能会产生不一致的安装环境。如果其他包使用适当的条件依赖依赖于example库,那么两个发行版可以同时安装。

这些分支通常在全局包名称空间中使用相同的名称,因此无论您使用的是哪个版本的example,代码都可以无缝地工作。如果同时安装两个版本,则一个版本会覆盖另一个版本的文件。 5 Pipenv 在安装时禁用依赖解析,仅在生成锁文件时执行, 6 表示只能安装锁文件中提到的包。

正如我们在前面的章节中所看到的,表示这一点的正确方式是无条件地声明依赖关系,这些依赖关系本身是有条件的,例如

dependencies = [
    "example-forwindows ; sys_platform == 'win32' "
    "example ; sys_platform != 'win32' "
]
setup(
    ...
    install_requires=dependencies
)

这将导致 Pipenv 检查两个包的适当版本,并锁定它们的版本,同时对适当的元数据进行注释,以确保在安装时只使用正确的版本。通过查看我们的运行示例的Pipfile.lock,您已经可以看到这一点,因为其中一个包仅在运行于 ARM 处理器上时使用。

元数据中的自述文件

setup.py而不是setup(...)调用中使用代码的一个更常见的原因是为了避免重复,尤其是在long_description字段中。这通常是README文件的内容,或者是READMEHISTORY文件的连接,或者类似的内容。开发人员有时通过读取setup.py中的那些文件来实现这一点:

with open("README") as readme_file:
    readme_text = readme_file.read()
setup(
    ...
    long_description=readme_text
)

这个例子有几个问题。首先,open(...)有两个需要指定的可选参数。这些是modeencoding。由于我们没有传递显式的mode,我们有效地使用了模式rt,因此 Python 为我们处理了字符串到字节的编码和解码。由于我们没有指定编码,这取决于我们使用的计算机的设置。此函数的两个默认值导致不同计算机之间的行为不一致。我们添加了一个隐含的假设,即该文件只能在默认编码与保存该文件的编码相匹配的系统上读取。

File Modes

默认情况下,对于只读文本,打开文件的模式是rt。代替r,可以使用以下内容:

  • w(以只写模式打开文件并丢弃任何内容,如果有的话)

  • x(以只写模式打开一个文件,如果该文件已经存在,则引发一个异常)

  • a(以只写模式打开一个文件,并将文件指针定位在现有内容之后)

  • r+(以读写模式打开文件,但将文件指针放在文件的开头)

  • w+(以读写模式打开一个文件,并丢弃任何存在的内容)

可以将b修饰符添加到这些访问模式中的任何一种来代替t,以指示文件正在以二进制模式打开,这意味着读写调用应该使用字节而不是字符串。省略t是很常见的,因为这是默认模式,但是为了清楚起见,我建议保留r,尽管它也是默认模式。

随着表情符号越来越受欢迎,编码问题已经变得越来越普遍。许多说欧洲语言的人已经能够忽略编码,让文本处理看起来正常工作。他们现在遇到了表情符号破坏应用的错误,因为这些字符没有被系统的默认编码正确处理。

这主要是因为 Latin-1 编码(以及非常相似的 Windows-1252 )和UTF-8编码使用相同的字节来表示欧洲语言中常用的大多数字符。因此,在这三种编码之间切换仍然可以为欧洲语言使用的大多数字符产生正确的值。

由于 Windows 中的默认编码是 Windows-1252,而 Linux 中的默认编码是 UTF-8,因此任何在这两种操作系统上运行的程序都会产生不一致的输出文件,除非指定了编码。

Windows-1252 和 UTF-8 在编码上的一个不同之处是,英镑的符号。表 3-2 显示了在涉及该符号的文件操作中未包含编码的影响。

表 3-2。跨操作系统的隐式编码问题

|

"£100"写入文件并读回的结果

|

Windows 阅读

|

Linux 阅读

|
| --- | --- | --- |
| Windows 编写 | "£100" | UnicodeDecodeError |
| Linux 写作 | "£100" | "£100" |

当使用相同的系统读取和写入文件时,这个字符没有问题。 7 当出现不匹配时,就有可能出现错误。这可以采取读取乱码的形式(例如" £ "而不是" £ "),引发异常,或者可以按预期工作。确切的结果取决于两种默认编码的组合。

回到我们之前的long_description例子,如果我的README文件包括“感谢 X 公司捐赠 1000 英镑支持这个包的开发”,我可能会遇到这个问题。如果我在一台以默认 Windows 编码保存数据的 Windows 计算机上写这篇文章,那么setup.py将无法在 Linux 主机上执行。

这意味着我们为这个包创建的源代码分发文件对大多数用户都不起作用,并且那些将这个包指定为依赖项的用户会发现他们对pipenv installpipenv lock的调用在 Linux 主机上会失败。

通过纠正开放调用的使用,有可能纠正这些缺点并拥有一个可靠的setup.py。下面是加载READMElong_description的改进示例:

with open("README", "rt", encoding="utf-8") as readme_file:
    readme_text = readme_file.read()
setup(
    ...
    long_description=readme_text
)

open(...)仍然有可能引发异常,比如说如果README文件丢失了。尽管如此,在这种情况下出现的任何异常都可能是暂时的,或者是导致安装失败的潜在问题的征兆。

有些人对setup.py中的输入文件进行了更复杂的处理,例如,在不同的标记语言之间进行转换,但是这增加了意外引入错误代码的可能性,这些错误代码会导致在其他硬件上运行时出现异常。

版本号

最后,许多包以 Python 代码可以访问的方式包含了它们的版本号。这通常存储为包中最高级__init__.py__version__VERSION。我们先前让apd/sensors/__init__.py空着;现在让我们添加一个版本号:

VERSION = "1.0.0"

这个版本号可以作为apd.sensors.VERSION导入。在代码中提供版本号对我们库的用户很有用。这意味着他们可以轻松地记录生成数据的库的版本,甚至可以在交互式会话或调试器中查看值,以确认给定环境中安装的依赖项的版本。

Tip

如果您想在您的__init__.py文件中包含许多其他内容,您可能想在一个version.py文件中设置版本。然后,为了方便起见,您可以在__init__.py中导入该值,或者从version.py中访问它,以确保没有来自__init__.py中其他代码的副作用。

问题是添加这个属性意味着每次发布新版本时都需要更新两个地方,即setup.pysrc/apd/sensors/__init__.py。这可能会导致错误,一个版本更新了,而另一个版本没有。如果这两个数字不同步,那么提供这两个数字就没有用了,因为用户无法信任它们。因此,它们必须保持绝对同步。

该属性需要从setup.py脚本中访问,但是setup.py脚本是在代码安装之前执行的(除了升级时,在这种情况下,以前的版本是可用的),所以它不能只是import apd.sensors

虽然这是一个非常有用的特性,但是当使用setup.py风格的元数据时,没有什么可取的方法来实现这一点。有一些方法可以解决这个问题,比如自动同步版本号的工具。

使用 setup.cfg

通过在setup.cfg文件中声明通常作为参数传递给setup.py的信息,我们无需在setup.py中编写任何代码就可以获得相同的结果。

将我们现有的setup.py转换成setup.cfg中的声明非常简单。不是所有的值都存储在setup(...)函数参数的平面名称空间中,而是存储在 ini 文件的部分中。我们之前看到的两个更复杂的模式已经融入到清单 3-1 的配置语言中(以粗体突出显示)。

[mypy]
ignore_missing_imports = True

[flake8]
max-line-length = 88

[metadata]
name = apd.sensors
version = attr: apd.sensors.VERSION
description = APD Sensor package
long_description = file: README.md, CHANGES.md, LICENCE
keywords = iot
license = MIT
classifiers =
    Programming Language :: Python :: 3
    Programming Language :: Python :: 3.7

[options]
zip_safe = False
include_package_data = True
package-dir =
    =src
packages = find-namespace:
install_requires =
    psutil
    click
    adafruit-circuitpython-dht ; 'arm' in platform_machine

[options.packages.find]
where = src

setup.py

from setuptools import setup

setup()

pyproject.toml and PEP517

这种方法特定于 setuptools,它是 Python 打包的默认和推荐的构建系统,但这是打包过程中不断变化的领域之一。一对名为 PEP517 和 PEP518 的标准定义了pyproject.toml可以用于在许多不同的包装工具之间进行选择。这是重要的一步,因为它澄清了一些关于如何构建 Python 包的隐含理解。

PEP517 已经启用了一些 setuptools 的替代品,如poemflit 。这两个工具中的一个或两个可能在未来成为明确的最佳实践,但是在撰写本文时,它们是有希望的少数方法。

但是 PEP517 提出的一些重要问题还没有解决。影响我们的是我们使用pipenv install -e .来安装我们的代码作为一个可编辑的依赖项。这指示 setuptools 构建系统在我们的环境中创建到我们代码的链接,因此代码是直接加载的,而不需要复制。

这个特性是 setuptools 特有的,虽然其他构建工具也提供了相同的特性,但是它们还没有被标准化。任何包含pyproject.toml文件的代码库都被认为选择了只使用 PEP517 构建系统,因此不能保证pipenv install -e .会按预期工作。

一些工具(比如 mypy)使用setup.cfg作为存储它们配置的地方,但是其他工具(比如 black)将它们的配置存储在pyproject.toml中。随着越来越多的工具开始将它们的配置存储在这个文件中,您很可能需要创建它,因此选择使用 PEP517。

现在,我必须建议您避免向任何使用 setuptools 的代码库添加pyproject.toml文件。但是,如果您尝试使用其他构建系统(比如 flit 和 poem),它们会生成一个pyproject.toml文件,您一定不能删除它,否则用户将无法安装您的包。希望,可编辑安装的问题将很快得到解决,但在此期间,我们将简要看看这个新功能的一般结构。

该文件的[build-system]部分声明了哪个工具负责构建该软件的发布,并且包括一个requires行和一个build-backend行。以下是使用 setuptools 的pyproject.toml。它声明构建需要 setuptools 和 wheel 格式支持,并使用现代的 setuptools 构建器(相对于传统的 setuptools 构建器)。

[build-system]
requires = [
    "setuptools >= 40.6.0",
    "wheel"
]
build-backend = "setuptools.build_meta"

有了这个文件,setup.py文件是完全可选的,但是不保证可以进行可编辑的安装。

自定义索引服务器

使用一个索引服务器来允许人们下载你的代码是一个好主意。Python 软件基金会提供了一个名为 PyPI 的索引服务器。 8 PyPI 由 Python 软件基金会为了所有 Python 开发者的利益而管理,其资金来源于捐赠,包括现金和实物捐赠,如大型技术公司的虚拟主机。这适用于任何人都可以依赖的开源库,但不适用于私人项目。如果您乐于让其他人使用 PyPI,就不应该害怕在 PyPI 上发布代码。

有几个开源项目作为 PyPI 的替代方案,允许您按照 PEP503 中记录的要求存储您的私有包。运行 pypi.org 的代码叫做 Warehouse,是一个开源项目。这似乎是一个有吸引力的起点,但是很可能您的需求与 PyPI 的需求完全不同。

PyPI 提供了同一个接口的另一个开源实现,被形象地称为pypiserver。需要说明的是,pypiserver 不是用来托管 pypi.org 的;更确切地说,它是一个为 pypi.org 提供替代方案的服务器。

这两种实现都提供了通过 Web 浏览项目和按名称下载发行版的功能。人类访问的站点版本(通过 https://pypi.org )与 pip 和 setuptools 在查找包时访问的版本不同。“简单”索引由依赖关系管理工具使用,它也是基于 HTTP 的,但不适合人类使用。你可以在 https://pypi.org/simple/ 看到它:这是一个无样式的页面,只包含 PyPI 上大约 20 万个软件包的名称。如果你点击其中的一个链接,你会看到每个已经上传的发行版的文件名和一个下载链接。

这个简单的清单是发行版本库的最低要求。Warehouse 和 pypiserver 还提供了两个系统通用的上传 API。可以使用 twine 工具访问 API,该工具将您提供的任何发行版上传到您选择的索引服务器。

使用 twine 上传包时,您可能需要提供凭据。Warehouse 检查您是否可以验证自己是一个被授权上传新版本到相关项目的用户。只有获得项目初始上传者授权的用户(直接或由初始上传者授权访问控制的其他人)才可以上传新版本。这可以防止公众成员进行恶意更改。

对于只有少数相互信任的人使用的私有索引服务器来说,每个包的委托权限太多了。Pypiserver 保护扁平层次结构中的动作;如果您被允许上传一个发行版到 pypiserver 的一个实例,您可以上传任何发行版,而不需要为每个单独的项目明确授权。

这更适合商业环境,因为所有开发人员(或者负责处理发布的子集)可以添加任何内部包的新发行版,而不用担心协调访问级别。如果你和一群同事定期创建你写的包的新版本,那么 pypiserver 是一个很好的管理方式。

有一些替代方案提供的功能较少,但设置起来更容易。由于索引服务器只需要按名称列出包的列表,每个包链接到文件列表,所以提供文件目录并被配置成生成目录列表的 web 服务器就足够了。这可以用 Apache、Nginx,甚至简单地从适当的目录中选择python -m http.server来完成。

这不能支持直接上传,因为没有逻辑支持服务器,但它允许在任何标准的 web 服务器上托管您的依赖项,代价是使上传过程更加复杂。这种方法不提供与完整索引服务器相同的元数据信息,因此像 Pipenv 锁依赖项这样的任务需要更长的时间。因此,我不推荐这种方法。

设置 pypiserver

我们将为我们正在开发的代码创建一个索引服务器,这样我们就可以将它发布到一个存储库中,而不必将 PyPI 与该工具的多个版本混在一起。此索引服务器应设置在新的隔离环境中;您不应该将索引服务器安装为apd.sensors开发环境的一部分。

我将在 Raspberry Pi 4B 上安装索引服务器。为此,我连接到 Raspberry Pi 并为索引服务器创建一个新的用户帐户,然后按照屏幕上的提示进行操作。不同的用户帐户可以更好地将系统的主要用户与其作为索引服务器的角色分开。

rpi> sudo adduser indexserver

我们还应该运行sudo apt install apache2-utils来安装htpassword实用程序,因为我们稍后将需要它来配置认证信息。

现在,将用户更改为 indexserver 用户,或者使用sudo -iu indexserver或者作为 indexserver 通过 SSH 重新连接。我们现在可以为这个用户安装 pipenv,将它添加到用户的路径中,并设置我们的新环境。

rpi> sudo -iu indexserver
rpi> pip install --user pipenv
rpi> echo "export PATH=/home/indexserver/.local/bin:$PATH" >> ~/.bashrc
rpi> source ~/.bashrc
rpi> mkdir indexserver
rpi> mkdir packages
rpi> cd indexserver
rpi> pipenv install pypiserver passlib>=1.6
rpi> htpasswd -c htaccess your_desired_username

然后我们需要配置 Raspberry Pi 在启动时自动运行这个服务器,我们用一个 systemd 文件来完成。 9 这应该作为默认的 pi 用户来完成,因为它涉及到使用sudo来编辑系统文件。创建清单 3-2 中的文件来配置系统。

[Unit]
Description=Custom Index Server for Python distributions
After=multi-user.target

[Service]
Type=idle
User=indexserver
WorkingDirectory=/home/indexserver/indexserver
ExecStart=/home/indexserver/.local/bin/pipenv run pypi-server -p 8080 -P htaccess ../packages

[Install]
WantedBy=multi-user.target

Listing 3-1setup.cfg

Listing 3-2/lib/systemd/system/indexserver.service

然后,我们可以启用并启动服务

$ sudo systemctl enable indexserver
$ sudo service indexserver start

从这一点开始,当机器加电时,服务将自动启动,并监听http://rpi4:8080或与您网络上的 Raspberry Pi 相关联的任何主机名或 IP 地址。

持久性

当运行您自己的索引服务器时,重要的是要考虑如果您的基础设施发生灾难性的硬件故障会发生什么。发行版本身并不存储在版本控制中,尽管生成它们的源代码版本应该被标记出来,以便于将来访问;从同一标签重新生成分布可能会导致生成具有不同检查哈希的文件。确保完全相同的文件始终可用是能够重建旧版本软件的关键。

Pipenv 自动记录上次锁定时可用的所有发行版的散列,因此只要将来有相同的文件可用,就可以重建相同的环境。

因此,存储在索引服务器上的分发文件应该与主源树一样重要。由于完全重建环境需要所有依赖项,许多 Python 开发人员选择在他们的私有索引服务器上保留所有依赖项分布的备份。这允许在不访问 PyPI 的情况下构建应用,比如在专用网络上或在计划的维护窗口期间。

有许多方法可以做到这一点,比如专门的代理服务器在下载包时缓存它们。然而,这很容易变得过于复杂。我推荐使用类似于wget的工具为您所依赖的包创建 pypi 的部分镜像。

可以使用pipenv lock -rpipenv lock -r --dev来提取给定环境所需的全套包。这将输出依赖包的列表,以及所选择的版本和适用于该依赖的任何条件。您可以使用这些命令输出来创建所需包的列表。

或者,开源项目 jq 提供了一种从 JSON 文件中提取数据的简单方法,比如Pipenv.lock文件。命令jq ".default + .develop | keys" Pipfile.lock提取主和开发依赖列表中引用的每个包的名称及其依赖项。

机密

在运行自己的索引服务器的情况下,您几乎肯定会有不希望公开的包。一般来说,这些都是基于商业开发的封闭源码包,它们的发布对相关版权所有者来说是个问题。它们也可能是非常特殊的工具,以至于一般不会有用。它们甚至可以是已经分叉的开源包,只要相关的许可条款得到维护;即使您有法律义务与请求代码的人共享代码,也没有必要提供对索引服务器的访问以供他们使用。

机密性是索引服务器的属性,它确保未经授权的人无法访问它存储的分发内容。这通常还包括防止人们访问它存储的包的名称,除非得到授权。

解决这个问题的最佳方式在很大程度上取决于您对风险的偏好以及您对什么样的人可能会试图找到您的代码的预期。对于大多数公司来说,针对基础设施的旨在提取源代码的直接、有针对性的攻击的风险相对较低。对于这些公司来说,使用 pypiserver 或 Apache 或 Nginx 等 web 服务器提供的安全特性可能是可以接受的。

通过使用专用网络可以实现更高级别的控制,例如从物理办公室或云托管提供商的虚拟网络产品中运行索引服务器,确保只有通过公司控制的网络连接的计算机才能访问索引服务器。基于网络的安全通常与更传统的认证系统相结合,以提供额外的保护。

重要的是要记住,开发人员不是索引服务器的唯一消费者;生产部署通常被授权访问同一个索引服务器,以便自动下载和安装应用代码。

我发现保密性通常是这三个支柱中最不重要的,因为对大多数开发人员来说缺乏令人信服的潜在威胁。您当然应该对您的索引服务器应用至少一个级别的保护,既要防止蜘蛛索引您的代码,又要防止人们的随意窥探,但是您绝对应该在评估有人试图获得访问权限的可能性(以及它对您的业务的影响)与设置一个更安全的系统所需要的工作量和麻烦之间进行平衡。

完整

三大支柱的最后一个是完整性,也就是说,你能确定一个发行版没有被恶意的第三方改变。这通常通过记录当包被添加到依赖集或其版本被更新时可用的加密散列的列表来实现。安装软件包时,会检查下载的文件并计算它们的哈希。如果哈希与允许的哈希列表不匹配,那么文件将被视为不正确而被拒绝。

重要的一点是我们期望分布永远不变。如果我们正在安装一个软件的 1.0.3 版本,那么它应该总是有与 1.0.3 的其他版本相同的错误。不幸的是,在 PyPI 之外,情况并非总是如此。众所周知,一些开发人员在发现他们认为是一个令人尴尬的简单错误时,会偷偷替换他们已经公开的发行版。这些“棕色袋子”版本是非常危险的,因为除了通过检查你下载的发行版本的散列(或者手动审计代码)之外,不可能知道你是否有固定的或者损坏的版本。

完整性检查还有另一个不常用的方面:分发签名。PyPI 服务器支持在添加发行版时上传加密签名。这些签名可通过与分发文件本身相同的界面获得,并可用于检查分发文件是否由特定的可信方上传。

只有当您使用的威胁模型是您的索引服务器不被信任只允许授权人员上传时,这才有意义。极少数人有理由不信任像 PyPI 这样的公共索引服务器。然而,风险偏好不包括信任 PyPI 的人不太可能乐于信任 PyPI 的个人贡献者。我不使用签名功能。

车轮格式和安装时执行代码

一般来说,你不应该在安装任何东西的时候使用sudo pipenv(或者sudo pipsudo easy_install或者curl ... | sudo ...),因为这允许以根用户身份执行看不见的下载代码。如果所有开发人员在信任第三方代码之前总是审核它,那将是最好的,但这对绝大多数人来说是不切实际的。如果您足够幸运,能够在这样一个环境中高效地工作,那么运行索引服务器是确保只有通过了组织把关的代码才可以安装的最佳方式。

如果您在允许安装第三方代码之前审核了第三方代码,或者如果您的组织的安全策略不允许在安装期间运行代码,您应该确保所有依赖项在 wheel 格式中都可用。 10 许多软件作者强调将 wheel 格式的发行版上传到 PyPI,因为这对于纯 Python 包来说非常容易。

Warning

虽然创建 wheel 发行版对于纯 Python 包来说是微不足道的,但是如果您使用的代码在其安装过程中涉及到库的编译,那么您应该记住,您将需要为您想要在其中使用 wheel 的每个环境生成一个 wheel。轮子被贴上了它们所支持的环境标签,其中-manylinux标签是一个流行的标签,表示它可以在大多数 GNU/Linux 操作系统发行版上工作。

如果您正在使用这样的包,您将需要在一个与将要安装它的目标非常匹配的系统上生成 wheel。我建议您为生产环境和开发环境生成轮子,如果它们不同的话。与包含编译的发行版相比,轮子的安装速度要快得多;你的开发伙伴会感谢你的。

从现有发行版创建轮子

将现有的发行版转换成 wheel 格式是可能的,即使它是一个您不维护的包的发行版。这个可以通过重新创建包的开发环境来完成,但是这并不总是一个简单的任务,所以我不推荐这么做。相反,您可以使用现有的包安装基础设施来构建轮子。这使用工具 pip(Pipenv 就是围绕它构建的)来下载和构建轮子。

首先,我们应该创建一个新的 Pipenv 环境,因为构建到 wheels 中的任何包都可能定义构建或设置需求。

> cd ~
> mkdir wheelbuilding
> cd wheelbuilding
> pipenv install

Warning

Pipenv 不允许嵌套环境。如果您已经在主目录中创建了 Pipenv 环境,那么您不能在子目录中有其他环境。这不应该发生,因为您希望每个环境都是独立的,而不是浮动在您的主目录中,但是如果它确实从主目录运行pipenv --rm,并将PipfilePipfile.lock文件移动到一个更合适的位置。

使用新的 pipenv 来运行工具将确保这些构建需求不会污染我们的其他环境。构建给定包的轮子的命令是pipenv run pip wheel packagename11 你可能还需要先运行pipenv install wheel,这取决于你的 Python 版本和安装方法。

如果我们想要构建所有依赖项的轮子,我们可以使用其他环境中的一个 Pipfile.lock 文件。Pip 本身不能读取Pipfile.lock文件格式,所以我们需要提取信息。正如我们在耐久性部分看到的,这可以通过pipfile lock -r > ~/wheelbuilding/requirements.txt来实现。

Exercise 3-1: Extract a Better Requirements.txt

Pipfile.lock文件比pipenv lock -r导出的信息更多,特别是散列信息。

例如,我明白了

adafruit-pureio==0.2.3

而不是

adafruit-pureio==0.2.3 --hash=sha256:e65cd929f1d8e109513ed1e457c2742bf4f15349c1a9b7f5b1e04191624d7488

所以我生成的需求列表没有启用散列检查。编写一个小的 Python 脚本来提取这些附加数据,并将其保存到一个requirements.txt文件中。这是一个练习原型和测试的好机会,就像前面的章节一样。本章附带的代码中有一个示例实现,供您检查您的工作。

一旦您有了您的需求列表文件,您就可以将它传递给 pip 工具来生成轮子。这已经结束了

> cd ~/wheelbuilding
> pipenv run pip wheel -r requirements.txt -w wheels

生成的 wheel 文件存储在wheels/目录中,准备上传到您的定制索引服务器。

在本书的第一章中,我们将 PiWheels 服务器添加到了我们的 Pipfile 中。我们刚刚完成的流程与 PiWheels 非常相似。PiWheels 自动下载 PyPI 上所有可用的发行版,并将其转换成一个 Wheels,使其在他们的备用索引服务器上可用。

PiWheels 过程稍微复杂一点,因为他们有一个定制的 wheel 构建过程来生成文件,这些文件很可能在安装了不同软件版本的许多不同的 Raspberry Pi 主机上工作,但想法是相同的。只使用 Python 代码的发行版很容易转换成 wheel 格式,但是可以添加编译后的组件,这需要安装适当的库和工具。

我们从中得到的好处是,像sysv_ipcpsutil这样的包,在每个 Raspberry Pi 安装目标上都需要长时间的构建步骤,安装起来要快得多。一般来说,如果一个包有一个适合您的目标环境的轮子,那么您就不再需要在生产服务器上安装编译器和构建链。对于许多系统管理员来说,能够在非生产服务器上预先进行任何编译是一个非常有吸引力的好处。

使用入口点安装控制台脚本

我们现在能够构建可以在其他用户的环境中干净无误地安装的发行版,但是我们对命令行工具的调用又发生了变化。随着时间的推移,我们已经使用了python sensors.pypython src/apd/sensors/sensors.pypython -m apd.sensors.sensors来调用脚本。这些都不是用户可以接受的解决方案,这种变化是我们的设置缺乏间接性的表现。

我们希望用户能够运行该脚本,就好像它是安装到他们环境中的任何二进制文件一样。Python 使用包的 console_scripts 特性来实现这一点。当安装的分发版在 console_scripts 元数据字段中有值时,这些值将作为可执行文件在安装位置的二进制文件目录中创建。

例如,在第一章中,我们将 pipenv 安装到我们的全局环境中。这将 Python 代码放到典型的 Windows 机器上的C:\Users\micro\AppData\Roaming\Python\Python38\site-packages\pipenv\__init__.py中。当在命令行上调用 pipenv 时,shell 执行的文件是C:\Users\micro\AppData\Roaming\Python\Python38\Scripts\pipenv.exe。这是一个真正的本地运行的可执行文件,而不是批处理文件。也就是说,它不是独立的;这只是一个用适当的选项调用 Python 的包装器,代码本身并没有编译成可执行文件。如果我们看看 Pipenv 的setup.py,我们可以看到

    entry_points={
        "console_scripts": [
            "pipenv=pipenv:cli",
            "pipenv-resolver=pipenv.resolver:main",
        ]
    },

作为setup(...)通话的一部分。这声明了两个 python 调用,它们应该被包装成可直接运行的可执行文件。这些行的格式以应该公开为可执行文件的名称开始。在这里第一个条目的情况下,那就是pipenv。然后一个=将可执行文件的名称与应该被调用的可调用文件的引用分开。这是一个带点的模块名,后跟一个冒号,然后是该模块中可调用的名称。在这种情况下,cli可以作为from pipenv import cli从 Python 代码中获得。

我们希望使apd.sensors.sensors中的show_sensors可作为命令行脚本调用,所以我们将把以下内容添加到我们的setup.cfg文件中,它相当于前面的setup.py示例中的列表字典:

[options.entry_points]
console_scripts =
  sensors = apd.sensors.sensors:show_sensors

这些可执行文件仅在安装时创建,因此我们需要重新运行安装过程,以便处理这个新脚本。对于大多数更改来说,这是不必要的,因为我们已经在可编辑模式下安装了目录,这意味着 Python 代码中的更改会立即生效。这是setup.cfg方法相对于setup.py的另一个优势,因为对setup.py的修改需要重新安装,这可能是违反直觉的,因为它也是一个 Python 文件。将元数据放在setup.cfg中可能更容易记住这是安装元数据,而不是普通的 Python code

为了触发这个安装,我们运行pipenv install。此时,脚本现在可以作为pipenv run sensors运行。我们几乎处于拥有完整的第一版软件的阶段;缺少的只是文档文件。

READMEDEVELOPCHANGES

如果您编写这些文件的本能是它们没有打包系统的其他部分重要,那么您作为开发人员已经非常幸运了。当着手一个新项目时,手头有足够的文档来开始是非常宝贵的。最佳实践会随着时间的推移而改变,而关于如何使用不再常用的工具的知识也会逐渐消失。更重要的是,希望其他开发人员以最小的麻烦开始开发一个软件是很常见的。

有时候,开始一个新项目最具挑战性的部分是理解开发人员遵循了什么模式。您是使用 pipenv 安装依赖项,还是使用 virtualenv 和 pip 之类的旧系统?您运行什么命令来启动测试或程序?您需要配置 API 访问或加载样本数据吗?所有这些信息对于在新环境中高效工作是绝对必要的。

我们需要为我们的apd.sensors包写一个README文件,解释这个包是什么,如何安装它,以及如何使用它。这个文件将是用户访问 GitHub 12 库和 PyPI 信息页面时看到的第一样东西,因为它被用来构成long_description。大多数用户永远不会提取归档文件来查看发行版中的其他文件。事实上,在一些发行格式中,README甚至不会被包括在内。内容可能仅作为包的元数据存在。

PyPI 支持纯文本、重构文本和 Markdown 格式的README文件。许多人都熟悉重构文本,因为它是流行的 Sphinx 文档所使用的格式,许多站点都使用 Markdown,比如 GitHub、Bitbucket 和 Stack Exchange。由于 git 主机提供商倾向于使用 Markdown,并且 Markdown 作为纯文本比重构文本更容易阅读,我通常推荐选择 Markdown 作为用于README文件的格式。

通过将setup.cfg文件的long_description_content_type参数填写为text/plaintext/x-rsttext/markdown来声明选择。

降价格式

Markdown 格式的自述文件存储在。md 扩展名,所以我们将首先在项目目录的根目录下创建一个README.md文件。然后我们可以开始在一个标题下写一个简单的项目描述。在 Markdown 中,标题由前导#符号表示,所以最小的README.md应该是

# Advanced Python Development Sensors

This is the data collection package that forms part of the running example
for the book Advanced Python Development.

这种格式的其他方面对许多读者来说可能非常熟悉,因为它们现在在网上普遍使用。清单 3-3 显示了一个扩展示例。

# Header 1
## Header 2
### Header 3
#### Header 4

_italic_ **bold** **_bold and italic_**

1\. Numbered List
2\. With more items
    1\. Sublists are indented
    1\. The numbers in any level of list need not be correct
3\. It can be confusing if the numbers don't match the reader's expectation

* Unordered lists
* Use asterisks in the first position
    - Sublists are indented
    - Hyphens can be used to visually differentiate sublists
    + As with numbered lists, * - and + are interchangeable and do not need to be used consistently
* but it is best to use them consistently

When referring to things that should be rendered in a monospace font, such as file names or the names of classes, these should be surrounded by `backticks`.

Larger blocks of code should be surrounded with three backticks. They can optionally have a language following the first three backticks, to facilitate syntax highlighting
\`\`\`python
def example():
    return True
\`\`\`

> Quotations are declared with a leading right chevron
> and can cover multiple lines

Links and images are handled similarly to each other, as a pair of square brackets that defines the text that should be shown followed by a pair of parentheses that contain the target URL.

[Link to book's website](https://advancedpython.dev)

Images are differentiated by having a leading exclamation mark:

![Book's cover](https://gitee.com/OpenDocCN/vkdoc-python-zh/raw/master/docs/adv-py-dev/img/cover.png)

Finally, tables use pipes to delimit columns and new lines to delimit rows. Hyphens are used to split the header row from the body, resulting in a very readable ASCII art style table:

| Multiplications | One | Two |
| --------------- | --- | --- |
| One             |  1  |  2  |
| Two             |  2  |  4  |

However, the alignment is not important

. The table will still render correctly even if the pipes are not aligned correctly. The row that contains the hyphens must include at least three hyphens per column, but otherwise, the format is relatively forgiving.

Listing 3-3cheatsheet.md

重构的文本格式

重新构建的文本格式的自述文件存储在。rst 扩展名,所以如果我们使用这种格式,我们将创建一个README.rst文件。这可能不在根目录中,因为 rst 格式的README文件通常用于配合 Sphinx 文档系统的使用。在这种情况下,它们很可能存储在项目中的一个docs/目录中。前面的 markdown 自述文件的等效文件如清单 3-4 所示。

Header 1
========

Header 2
--------

Header 3
++++++++

Header 4
********

*italic* **bold** Combining bold and italic is not possible.

1\. Numbered List
2\. With more items

   #. Sublists are indented with a blank line surrounding them
   #. The # symbol can be used in place of the number to auto-number the list

3\. It can be confusing if the numbers don’t match the reader’s
   expectation

-  Unordered lists
-  Use asterisks in the first position

   -  Sublists are indented with a blank line surrounding them
   -  Hyphens can be used to visually differentiate sublists
   -  As with numbered lists, \* - and + are interchangeable but must be used consistently

-  but it is best to use them consistently

When referring to things that should be rendered in a monospace font,
such as file names or the names of classes. These should be surrounded
by ``double backticks``.

Larger blocks of code are in a named block, starting with ``.. code ::``. They
can optionally have a language following the double colon, to
facilitate syntax highlighting

.. code:: python

   def example():
       return True

..

   Quotations are declared with an unnamed block, declared with ``..``
   and can cover multiple lines. They must be surrounded by blank lines.

Links have a confusing structure

. The link definition is a pair of backticks
with a trailing underscore. Inside the backticks are the link text followed by
the target in angle brackets.

`Link to book's website <https://advancedpython.dev>`_

Images are handled similarly to code blocks, with a ``.. image::`` declaration followed by the URL of the image. They can have indented arguments, such as to define alt text.

.. image:: https://advancedpython.dev/cover.png
   :alt: Book’s cover

Finally, tables use pipes to delimit columns and new lines to delimit
rows. Equals signs are used to delimit the columns as well as the top
and bottom of the table and the end of the header.

=============== === ===
Multiplications One Two
=============== === ===
One             1   2
Two             2   4
=============== === ===

The alignment here is essential

. The table will not render unless the equals signs
all match the extent of the column they define, with no discrepancy. Any text that extends
wider will also cause rendering to fail.

Listing 3-4cheatsheet.rst

自述文件

我们不是在编写一个大的文档,所以 Markdown 是对README最合适的选择。我们应该包括一个简单的描述包是做什么的,以及潜在用户应该知道的任何重要信息(列表 3-5 )。

# Advanced Python Development Sensors

This is the data collection package that forms part of the running example
for the book [Advanced Python Development](https://advancedpython.dev).

## Usage

This installs a console script called `sensors` that returns a report on
various aspects of the system. The available sensors are:

* Python version
* IP Addresses
* CPU Usage
* RAM Available
* Battery charging state
* Ambient Temperature
* Ambient Humidity

There are no command-line options, to view the report run `sensors` on the
command line.

## Caveats

The Ambient Temperature and Ambient Humidity sensors are only available on
Raspberry Pi hosts and assume that a DHT22 sensor is connected to pin `D4`.

If there is an entry in `/etc/hosts` for the current machine's hostname that
value will be the only result from the IP Addresses sensor.

## Installation

Install with `pip3 install apd.sensors` under Python 3.7 or higher

Listing 3-5README.md

CHANGES.md 和版本控制

我们还应该创建一个CHANGES.md文件,指出apd.sensors包中不同版本之间的变化。这将让人们了解他们何时需要升级版本,以获得他们想要的新功能或他们需要的错误修复。

我们的setup.cfg被配置为连接README.mdCHANGES. md 文件的内容以形成将在 PyPI 上显示的long_description,所以我们需要匹配格式并使其成为一个降价文件。我们还应该注意确保标题水平是一致的。

文件有非常标准的格式;每个版本都应该有一个标题(适当的级别,在我们的例子中是 3 个),后面是版本号和发布日期。然后,应该有一个无序的变更列表,可以选择将变更的作者放在括号中,如下所示:

## Changes

### 1.0.0 (2019-06-20)

* Added initial sensors (Matthew Wilkes)

版本号本身没有内在的意义;他们确实需要遵循 PEP440,它定义了 Python 代码应该如何解析版本字符串。一般来说,版本号是由句点分隔的整数序列,如1.0.02019.06。通常使用一些其他的附加物,如a1b1rc1后缀,来标识一个发行版是预先发布的 13 版本号。

语义版本控制

一个好的经验法则是遵循语义版本化的原则( https://semver.org/ )。这是一个面向库的版本控制策略,但是也可以使用与应用版本控制大体相似的策略。Semver 假设三个位置版本号,这些位置被称为主要版本次要版本、补丁版本。

每当 API 出现向后不兼容的变化时,不管变化有多小,主版本号都应该增加。这方面的示例包括函数的任何新的必需参数、函数被重命名或移动到不同的模块,或者组成公共 API 的函数的预期返回值或异常行为的变化。对不属于公共 API 的函数进行任何这些更改都不需要增加 major 标签,只要清楚哪些函数构成了公共 API。改变函数的行为来修复 bug 也很好,只要不可预见人们会依赖于被破坏的行为。其目的是消费者可以升级到主要版本系列中的任何更高版本,并保证代码可以工作。

只要公共 API 中有不破坏向后兼容性的变化,次版本号就会增加。这包括正在添加的新函数或现有函数的新可选参数。这允许用户以两位数的版本号指定提供所需功能集所需的最低版本。

最后,如果软件中存在错误修复,而没有向公共 API 添加任何功能,则补丁版本号会递增。补丁发布是最小的增量;对于没有触发错误的最终用户来说,它们应该是完全不可见的。

日历版本控制

另一种流行的版本号方案是使用发布日期作为版本号。日历版本化( https://calver.org/ )使得决定版本号变得更加容易,因为不需要考虑变化对用户的影响。缺点是版本号不能很好地预测两个版本之间的差异。

Calver 对于发布总是一个大的调整或者总是非常小的项目非常有用。如果有大的和小的变化,那么它不是一个好的选择。calver 版本号的日期格式有一些变化,但是它们通常很容易识别,因为它们是以年份而不是主版本号开始的。

上游依赖版本引脚

库的消费者希望对他们愿意接受的库版本设置一个限制,从他们需要的最低版本开始,直到下一个主要版本之前。为了证明这一点,我们将查看 apd.sensors 中的直接依赖关系,并确定哪些引脚范围是合适的。

最终用户很难覆盖在 install_requires 行中设置的版本号,因此您应该宁可选择宽松的版本规范。您当然应该排除任何您知道不会工作的版本,但是您的应用的最终用户也会固定版本。一些库开发人员在钉住方面走得太远了,他们会钉住已知可以工作的单个版本或者他们期望可以工作的很小范围。这比根本不固定版本会导致更多的问题。

为了演示这一点,假设我们将 psutil 库固定到版本5.6.3,这是撰写本文时的最新版本。然后,过了一段时间,有人想要构建一个应用,使用我们开发的传感器函数以及其他一些库的函数,这些库本身依赖于 psutil 的更高版本。然后会有冲突的版本需求,应用开发人员必须手动解决,自己判断哪个版本的 psutil 是正确的。

如果我们使用了比==5.6.3限制更少的版本规范,那么依赖关系解析系统可能已经能够为两个库找到一个双方都同意的版本,而不需要下游开发人员的手动干预。

松动的针脚

松散版本锁定策略包括设置版本锁定,只排除您知道不起作用的版本。这包括搜索特定的版本或者保留一个版本不固定。由于所需的工作量,后者比前者更常见。

确定这些引脚的一种方式是运行pipenv install psutil==4.0.0和类似的程序,记下通过测试套件的最早版本。由于最新版本的软件工作,我们不能设置一个我们知道不兼容的上限。在我目前使用的机器上,psutil==5.5.0是最早干净安装的(虽然早期版本可能在不同系统上工作,但它在 MS Windows 上提供了 Python 3.7 的预编译轮子),click==6.7未能完成测试套件,adafruit_circuitpython_dht似乎在任何版本上都可以工作。我们有相当微弱的信心,版本引脚psutil >= 5.5click >= 7.0将是适当的。

鉴于我们不知道有任何版本绝对不能工作,在我们意识到真正的局限性之前,将所有这些依赖项都列为不固定的可能更合适。在这种情况下,记录一组已知良好的依赖关系是很重要的,比如提交一个Pipfile.lock。这将允许将来的用户有一个起点,如果他们必须为将来的、不需要维护的版本构造 pin,他们可以从这个起点知道工作的版本。以下是松散方案中推荐的引脚版本:

install_requires =
    psutil
    click
    adafruit-circuitpython-dht ; 'arm' in platform_machine

严格的引脚

一种替代方法是利用正在使用(或者假设正在使用)的版本控制方案的知识来设置相对较宽范围的 pin,这些 pin 肯定可以工作。这是语义版本控制如此有用的一个原因;它允许开发人员推断什么是安全的锁定范围,而不必检查代码或 changelog 来解码您的意图。

click库不使用 semver 然而,从变更日志来看,他们似乎使用了一个 major.minor 版本方案,在含义上与 semver 相对接近。因此,我们将假设更新次要版本是安全的,而不是主要版本。由于我们目前使用的是版本7.0,我们将设置一个包含>=7.0的版本 pin。我们还希望允许版本7.17.2等等,但不允许8.0。您可能想指定<8.0,但是8.0a1会被这个问题困扰(因为8.0是比8.0a1更晚的版本)。相反,我们需要版本号>=7.0,==7.*,意思是至少是7.0的任何7.x版本。这是一个如此常见的模式,它有自己的别名:~=7.0

psutil类似;它也没有遵循 semver,但似乎没有在次要版本中引入向后不兼容的更改。同样,这是一个判断调用,但是感觉比5.6.0更晚的5.x版本使用起来可能是安全的,所以我们将使用~=5.6作为版本说明符。

最后,我们的第三个依赖项是adafruit-circuitpython-dht。这是最棘手的一个,因为它没有声明它遵循 semver,也没有包含 changelog。最早发布的版本是3.2.0,而在撰写本文时最新的版本是3.2.3,这使得推理作者的意图相当困难。在这种情况下,我的直觉是3.2.x很可能是安全的。以下是严格方案中推荐的引脚版本:

install_requires =
    psutil ~= 5.6
    click ~= 7.0
    adafruit-circuitpython-dht ~= 3.2.0 ; 'arm' in platform_machine

要使用哪种固定方案

每个方案都有优点和缺点;在 Python 的生命周期中,每一个都曾经流行过,而不是其他。在我写作的时候,宽松的风格正在流行,我倾向于同意这一点。如果您正在编写一个作为多个包分发的非常大的应用,那么您可能会发现 strict 风格更适合您的需要,但是使用 strict 模式意味着更频繁地测试版本和发布新的补丁,这些补丁只更新上游依赖项的版本 pin。

除非你有令人信服的理由,否则我不推荐使用严格的方案;像 Pipenv 这样的环境管理工具的出现允许最终用户轻松地管理他们的依赖版本集。设置版本 pin,防止安装您知道不会工作的版本,但让最终用户来处理未来的版本。

上传版本

我们现在有了完整的 1.0.0 版本的apd.sensors包,所以是时候把它上传到我们的定制索引服务器了。我还将把它上传到 PyPI,因为这将允许在现实世界中使用这些代码,同时也使本书后面的一些例子更容易理解。我还确保将它上传到 PyPI,因为我想确保遵循本书中示例的人获得我分发的正确代码,因此需要确保该名称在 PyPI 上保留。

Note

你不应该试图以apd.sensors或其他名字将你的版本上传到 PyPI。假设许可允许,并且你已经做出了一些普遍有用的东西,那么为了添加特性或者修复 bug 而使用别人的包是可以接受的,但是你不应该仅仅为了个人学习而上传你自己创建的包。 https://test.pypi.org/ 是学习分发工具的好帮手。它是专门用于试验的,数据会定期删除。

我将使用工具 twine 上传到 PyPI。Twine 是上传包的首选方法,可以与pipenv install --dev twine一起安装。您也可以考虑像安装 Pipenv 一样安装 twine,因为它对所有 Python 开发人员来说都是一个非常有用的包。在这种情况下,应该是pip install --user twine

我们现在需要构建我们计划安装的发行版。这是用pipenv run python setup.py sdist bdist_wheel完成的。该命令生成一个源分布和一个轮分布。上传一个源发行版和一个轮子被认为是最佳实践,即使轮子应该是通用的。这确保了与不同 python 版本的互操作性。

我们现在在 dist 目录中有两个文件,apd.sensors-1.0.0.tar.gzapd.sensors-1.0.0-py3-none-any.whl。轮子上的标签表明这是一个兼容 Python 3 的轮子,它没有指定 Python ABI 14 要求,并且可以在任何操作系统上工作。

Twine 包括一个基本的 linter,以确保生成的发行版在 PyPI 上显示时不会有任何渲染错误。您可以使用twine check命令来完成此操作,如下所示:

> pipenv run twine check dist\*
Checking distribution dist\apd.sensors-1.0.0-py3-none-any.whl: Passed
Checking distribution dist\apd.sensors-1.0.0.tar.gz: Passed

如果合适的话,这些文件可以上传到 PyPI。这样做的命令是

> pipenv run twine upload dist\*

在此过程中,系统会提示您输入认证信息(您可以在 https://pypi.org 注册账户)。一旦此过程完成,您将无法覆盖发行版;任何改变都需要你增加补丁版本,即使只是很小的改变。 15

配置双胞胎

您可能会发现 twine 有几个有用的配置选项。首先,如果你安装了库keyring,你可以配置 twine 在你的操作系统的凭证管理器中记住你的凭证,比如 macOS 的 Keyring,Windows 的 Windows Credential Locker,或者 KDE 的 KWallet。如果您使用的是受支持的操作系统,则可以使用存储您的凭据

> keyring set https://upload.pypi.org/legacy/ your-username

如果您的风险偏好允许,您也可以以纯文本的形式设置这些值。如果是这样,您应该在~/.pypirc文件中设置它们,该文件也用于配置定制索引服务器数据。

[distutils]
index-servers =
  pypi
  rpi4

[pypi]
username:MatthewWilkes

[rpi4]
repository: http://rpi4:8080
username: MatthewWilkes
password: hunter2

然后,您将能够将文件上传到您的本地存储库服务器,就像使用 PyPI 一样。为此,您需要指定您的目标指数,在本例中是rpi4:

> pipenv run twine upload -r rpi4 dist\*

您可以使用 twine 将任何包上传到您的本地索引服务器,包括我们之前生成的轮子,如下所示:

> pipenv lock -r requirements.txt
> pipenv run pip wheel -r requirements.txt -w wheels
> pipenv run twine upload --skip-existing -r rpi4 wheels\*

如果您已经构建了自己的轮子并将它们上传到索引服务器,您将需要重新运行pipenv lock来确保新的散列被记录为有效的安装选项。

摘要

除了最简单的 Python 项目,我建议您使用 setuptools 打包代码。声明式格式比旧的setup.py格式有显著的优势,并得到了广泛的支持。使用打包系统非常有用,即使对于小型概念验证风格的项目也是如此,因为它有助于避免与 Python 代码位于正确的导入位置相关的错误。

对于商业环境,我强烈建议使用 pypiserver 建立一个私有索引服务器,并通过使用内置的身份验证机制和 IP 过滤(如果适合您的系统的话)来保护它。我还建议将您的依赖项镜像到私有索引服务器中,可能作为构建在您的基础设施上的 wheel 文件。

额外资源

打包工具的前景变化非常快,但是如果您对这个主题感兴趣,我建议您通读以下链接,并尝试一些其他工具:

四、从脚本到框架

到目前为止,我们创建的包只有一个相对基本的脚本接口,没有可扩展性。大多数应用不需要某种方式来扩展;将所有可选代码打包在一起通常比麻烦地维护独立于主代码库的插件更容易。然而,使用插件架构来管理(例如)应用的可选特性可能非常有吸引力。

如果你的直接用户是其他程序员,那么提供一个插件架构来简化他们的工作可能是个好主意。开源框架通常就是这种情况,外部开发人员可能会创建额外的特性,或者供他们自己使用,或者通过咨询协议为他们的客户服务。如果你正在做一个开源项目,并且不确定是否应该使用插件架构,我宁愿选择包含它。人们会以任何方式扩展你的代码;包含定义良好的插件的错误报告比添加额外功能的软件分支更容易理解。

我们传感器工具的用户不一定是程序员;他们是想获得特定系统信息的人。然而,有可能他们想要特定用例的定制信息,在这种情况下,他们可能会雇佣程序员来添加新特性。

我们已经在提供插件架构的道路上走得很好了;我们有一个定义良好的类,以通用基类的形式描述传感器的行为。除了定义良好的接口之外,我们还需要一种列举可用传感器的方法。我们在show_sensors函数中这样做,它对文件中的所有传感器进行硬编码。这对于不需要插件架构的应用来说非常有效,在这些应用中,所有的传感器都由相同的开发人员编写,并作为一个组分发。一旦我们期望第三方编写自定义传感器,它就会失败。

编写传感器插件

让我们考虑一下,作为用户,我们希望从这个工具中得到什么。除了许多人可能使用的温度和湿度传感器,我还想监控一些其他人很少会觉得有用的东西。其中一个是我安装在屋顶的太阳能电池板的输出。我有一个脚本可以通过蓝牙从我的逆变器中读取数据,它使用现有的开源命令行工具来完成收集和解释数据的艰苦工作。我希望能够将此纳入我的数据收集中。

由于集成特定品牌和型号的太阳能电池板逆变器对大多数人来说不是一个有用的组件,我不打算将其集成到核心apd.sensors封装中。相反,我将创建一个独立的插件,因为用户可能需要自定义逻辑。

如果我设想这是一个普遍有用的传感器,我可能会将这个传感器添加到与现有传感器相同的文件中,并将其与其他传感器一起列在show_sensors中。这意味着该软件的所有其他用户都会看到以下内容作为脚本输出的一部分:

> pipenv run sensors
...
Solar panel cumulative output
Unknown

对绝大多数人来说,太阳能电池板的输出并不是一个有用的附加功能;最好是作为可选组件,用户可以根据需要安装。我甚至不会在我设置的所有 Raspberry Pi 节点上运行它,因为只有一个节点连接到太阳能电池板逆变器。

如果您正在用这段代码构建一个服务器监控设置,您可能需要几组不同的插件。虽然您可能有所有计算机上的 CPU 和 RAM 使用情况数据,但有些服务器角色有特定于应用的指标,例如,处理异步任务的计算机的作业队列长度、web 应用防火墙服务器的阻塞主机数量或数据库服务器的连接统计数据。

对于如何处理这需要外部工具的事实,有两种广泛的方法。首先,我可以创建一个 Python 发行版,其中包含我需要的工具的 C 代码。然后,当我的 Python 包安装好后,我必须安排编译和链接。我需要包括这个工具不可安装的问题的错误处理,并记录其要求。一旦安装完毕,我就可以使用该二进制文件,既可以使用它现有的脚本接口,也可以直接使用 Python 对调用本地代码的支持。

或者,我可以记录我的传感器只有在安装了该工具的情况下才能工作,并让代码假设它存在。这对我这个开发人员来说极大地简化了过程,但对最终用户来说却增加了安装难度。由于我并不认为这是普遍有用的,这是目前为止最吸引人的选择。在足够好的东西上构建完美的东西是没有意义的,尤其是当用户很少的时候。

我选择的方法是假设现有的工具已经就位,如果程序丢失,我的代码不会返回结果。标准库函数subprocess.check_output(...)对此非常有用,因为它使得调用另一个进程、等待它完成、读取它的输出状态和打印内容变得简单。

开发插件

开发这种传感器是使用 Jupyter 笔记本进行原型制作的另一个好机会。正如在第一章中所讨论的,我们需要一个在 Raspberry Pi 服务器上的远程环境,其中安装了apd.sensors包。这允许我们通过本地 Jupyter 实例进行连接,并且能够从安装在服务器上的版本apd.sensors中导入传感器基类。

然后,我们可以开始原型制作,从一个 Jupyter 单元开始,它只从反相器中获取数据,另一个底层单元根据我们的需要对数据进行格式化,如清单 4-1 所示。

information

Listing 4-1Prototype for extracting solar power

img/481001_1_En_4_Figa_HTML.jpg

然后我们可以构建一个包含整个传感器子类的单元,然后通过检查str(SolarCumulativeOutput)和类似的函数调用是否如预期的那样运行来“踢轮胎”。你可能也想借此机会在 Jupyter cells 里写一些测试体。有一些项目试图在 jupyter 中直接集成 pytest,比如 ipytest,但是很少需要在目标主机上运行测试。任何需要特定主机硬件的文件在转换成标准 Python 文件时都应该用@pytest.mark.skipif(...)decorator 标记。您应该只在笔记本上写足够的测试代码,以确保您没有在原始数据收集中出错。

原型的相关单元可以被带入一个sensor.py文件,如清单 4-2 所示。

import typing as t
import subprocess
import sys

from apd.sensors.sensors import Sensor

bt_addr = "00:80:25:00:00:00"

class SolarCumulativeOutput(Sensor[t.Optional[float]]):
    title = "Solar panel cumulative output"

    def value(self) -> t.Optional[float]:
        try:
            output: bytes = subprocess.check_output(
                ["opensunny", "-i", bt_addr],
                stderr=subprocess.STDOUT,
                timeout=15,
            )
        except subprocess.CalledProcessError:
            return None

        lines = [line for line in output.split(b"\n") if line]
        found = {}
        # Data format: datetime:INFO:[value] timestamp=0000 key=value
        for line in lines:
            start, value = line.rsplit(b"=", 1)
            _, key = start.rsplit(b" ", 1)
            found[key] = value

        try:
            yield_total = float(found[b"yield_total"][:-3].replace(b".", b""))
        except (ValueError, IndexError):
            return None
        return yield_total

    @classmethod
    def format(cls, value: t.Optional[float]) -> str:
        if value is None:
            return "Unknown"
        return "{} kWh".format(value / 1000)

Listing 4-2apd/sunnyboy_solar/sensor.py

即使对于这种一次性传感器,我也强烈建议按照第三章中的方法创建一个包。一个软件包可以很容易地将传感器代码分发到我们的服务器,并使它们保持最新。如果您想减少相关的开销,您可以编写一个包含多个自定义传感器的包,但是不要试图绕过打包系统,只使用自由浮动的 Python 文件。

一旦我们编写了我们的传感器,我们将相关的细节包含在它的setup.cfg和来自我们的apd.sensors包和构建的同一个setup.py中,并且可以将发行版发布到我们的本地索引服务器。或者,如果我们不完全确信我们已经在开发过程中涵盖了所有的边缘情况,我们可能会选择在有问题的服务器上安装一个来自版本控制的可编辑签出。这将允许我们运行它的测试,并可能进行调整,而不必将代码从本地机器往返于远程主机。

添加新的命令选项

我们刚刚创建了一个包含单个传感器的新包,但是我们无法从上一章创建的命令行工具查看它的数据。该工具有几个内置传感器,在生成输出时会对它们进行迭代。我们需要修改脚本,以便它也可以显示其他 Python 文件中传感器的值。

首先,我们可以向apd.sensors添加一个新选项,通过 Python 导入位置加载传感器。也就是说,给定传感器的名称和定义它的模块,它将加载该传感器并显示其结果。这是受预提交脚本中的--develop选项的启发,通过路径加载一个钩子,以便于测试。

有了这个选项,我们将能够指定我们想要太阳能传感器的值,而不是内置传感器的值,这意味着我们不必编写特殊的命令来专门处理这个传感器。

子命令

我们目前有一个show_sensors函数,它包含了以硬编码列表显示的传感器。在这种情况下,我们想要做同样的处理,但是改变列表的生成方式以接受命令行参数。我们可以采取两种广泛的方法,要么创建子命令,要么添加命令行标志。

子命令可能不是您以前听说过的术语,但是您肯定用过它们。像 git 这样的工具大量使用子命令,Git 命令本身没有任何意义。事实上,命令gitgit --helpgit help是同义词:它们都打印终端的使用指南。更常见的 git 调用,比如git addgit clonegit commit,都是子命令的例子。Git 进程没有实现程序所有行为的单一函数;它使用子命令将相似的功能组合在一起。一些 git 命令甚至使用多级子命令,比如git bisect start1

我们可以采用这种方法,将现有的show_sensors(...)函数移动成一个名为show的子命令,并添加一个新的develop子命令。

Click 为此提供了称为参数的基础结构;您可以向函数添加选项和/或参数,这些选项和/或参数作为命令行界面的一部分公开。您应该认为参数总是存在的,即使最终用户可能没有为它们指定值。如果用户没有提供值,那么将使用默认值。参数是函数操作的核心数据。

另一方面,选项是不总是被传递的标志。它们可以仅仅通过存在来改变行为,或者它们可以包含类似于参数的可选值。

这个子命令使用@click.argument来指定一些数据作为命令行上的必需参数传递。@argumentmetavar=参数是用户使用--help时显示给用户的值的占位符。

@click.argument("sensor_path", required=True, metavar="path")

在下面的例子中,我还没有包含一个get_sensor_by_path(...)的实现;目前,它只能返回太阳能传感器的硬编码实例。我们稍后将提供一个实现;现在,我们关注的是我们是否应该使用子命令。以下是使用 click 创建子命令的示例:

@click.group()
def sensors() -> None:
    return

@sensors.command(help="Displays the values of the sensors")
def show() -> None:
    sensors = get_sensors()
    for sensor in sensors:
        click.secho(sensor.title, bold=True)
        click.echo(str(sensor))
        click.echo("")

@sensors.command(help="Displays the values of a specific sensor in" "development")
@click.argument("sensor_path", required=True, metavar="path")
def develop(sensor_path) -> None:
    sensor = get_sensor_by_path(sensor_path)

    click.secho(sensor.title, bold=True)
    click.echo(str(sensor))
    click.echo("")

if __name__ == "__main__":
    sensors()

这里,进入系统的入口点不再是一个show_sensors()命令,而是一个sensors()组。show_sensors()函数已经被重命名为show(),并且现在用@sensors.command而不是@click.command来声明。command装饰器中的变化将这个命令与名为sensors的组联系起来。

console_scripts入口点也必须改变以匹配这个重构:

[options.entry_points]
console_scripts =
  sensors = apd.sensors.sensors:sensors

Tip

就像我们第一次添加console_scripts声明时一样,这种改变只在包的安装过程中生效。您可以通过运行pipenv install -e .来强制这样做,这在您尝试不同的方法时很有用。一旦您增加了__init__.py中的版本号并重新运行pipenv lock,Pipenv 就会注意到这一变化并自动重新安装软件包。你可以利用这一点,设置一个类似1.1.0dev1的版本号。dev标记让您可以增加版本号,而不会有任何使用版本号的风险,因为您稍后会将其用于真正的发布。

对于这样的特性,我建议在开发版本中增加VERSION属性,除非只有少量开发人员在开发代码,并且他们没有沟通障碍(比如时区差异)。

一旦完成这些更改,就可以执行子命令来显示我们拥有的开发中传感器的值。因为我创建了一个包含一个sensor.py文件和一个SolarCumulativeOutput类的apd.sunnyboy_solar包,所以代表我的传感器的字符串是apd.sunnyboy_solar.sensor:SolarCumulativeOutput2 我可以用下面的命令检查输出:

> pipenv run sensors develop apd.sunnyboy_solar.sensor:SolarCumulativeOutput
Solar panel cumulative output
14070.867 kWh

然而,转换到子命令并不意味着命令pipenv run sensors不再像以前那样运行。为了获得预设传感器的预期数据,我们现在需要运行pipenv run sensors show。由于这种变化,如果不改变用户与软件交互的方式,用户就无法安全地从旧版本升级到新版本。这样做的结果是,我们需要大幅提高版本号,以便向用户传达这一变化的重要性。

如果我们考虑语义版本化策略的原则,我们正在考虑增加一个特性和打破向后兼容性的变化。打破向后兼容性意味着我们应该改变主版本号,使得任何带有这个新的子命令布局的软件版本都是版本2.0.0。一些开发者可能会觉得这不直观,因为在版本1.0.02.0.0之间没有大的概念上的变化。然而,这通常是出于从美学角度避免大的主版本号的愿望。我强烈建议当有向后兼容的变化时,不要回避增加版本号,因为这确实有助于用户判断应用什么样的升级是安全的。

命令选项

看待这个特性的另一种方式是,显示单个传感器的输出与显示所有传感器的输出基本上是相同的任务,尽管有一些不同的偏好。这是您在决定子命令和选项时需要做出的决定的核心:您正在添加的特性是应用的另一个逻辑特性,还是现有特性的不同行为?

对于如何区分这两者,没有硬性规定;在我们的例子中,每种方式都有论据。在我看来,改变正在被读取的传感器或输出的格式都是同一个底层“显示”功能的参数。我的实现使用“选项”方法,但这是一个微妙的区别,很大程度上取决于您如何看待您正在创建的工具。

要使用 option 方法,我们需要在现有的show_sensors(...)函数中添加一行@click.option来表示我们应该使用的传感器的路径,而不是硬编码的传感器列表。

在我们的例子中,我们将添加一个名为--develop的选项,它不是必需的,然后使用一个 if 语句来决定我们是否应该加载 develop 选项所引用的传感器,或者我们是否应该照常使用我们的硬编码列表。

@click.command(help="Displays the values of the sensors")
@click.option(
    "--develop", required=False, metavar="path", help="Load a sensor by Python path"
)
def show_sensors(develop: str) -> None:
    sensors: Iterable[Sensor[Any]]
    if develop:
        sensors = [get_sensor_by_path(develop)]
    else:
        sensors = get_sensors()
    for sensor in sensors:
        click.secho(sensor.title, bold=True)
        click.echo(str(sensor))
        click.echo("")
    return

这与子命令方法非常相似,默认语法不变,新的代码路径可用于

> pipenv run sensors --develop=apd.sunnyboy_solar.sensor:SolarCumulativeOutput
Solar panel cumulative output
14070.867 kW

错误处理

到目前为止,我们编写的程序还没有真正实现get_sensor_by_path(...),这对它在现实世界中的使用是至关重要的。我们可以编写一个简单的函数来实现这一点,例如:

get _ sensor _ by _ path 的不安全版本

def get_sensor_by_path(sensor_path: str) -> Any:
    module_name, sensor_name = sensor_path.split(":")
    module = importlib.import_module(module_name)
    return getattr(module, sensor_name)()

这个实现有一些明显的缺陷。首先,我们假设sensor_path总是包含一个冒号。如果这不是真的,那么第一行就会出现一个没有足够的值来解包的ValueError。然后,下一行可以提高一个ImportError,第三行可以提高一个AttributeError。这些错误将以回溯的形式显示给用户,这对用户来说不是很友好。我们想提供给用户的有用的错误信息越多,我们需要添加的条件就越多。

无论如何,这都不是这个实现的最大问题。在这个函数的最后一行,我们想要实例化用户选择的传感器,但是我们不知道它是一个传感器子类。如果用户运行pipenv run sensors --develop=sys:exit,那么命令将调用sys.exit()并立即终止。如果他们运行pipenv run sensors --develop=http.server:test,那么这个命令将会阻塞,一个未配置的 HTTP 服务器将会开始监听所有地址的端口 8000。

这些都不是严重的安全漏洞,因为任何可以运行传感器脚本的人都可以自己运行 Python 并调用这些函数。然而,没有好的理由允许用户去做明显错误的和有潜在危害的事情。每次编写这样的代码时,考虑其安全性是非常重要的,因为权衡总是不同的。

下面的get_sensor_by_path(...)实现捕获了所有可能由不良用户输入导致的常见错误,并使用适当的用户消息作为RuntimeError 3 重新提交。

get _ sensor _ by _ path 的实现,可选地引发 RuntimeError

def get_sensor_by_path(sensor_path: str) -> Sensor[Any]:
    try:
        module_name, sensor_name = sensor_path.split(":")
    except ValueError:
        raise RuntimeError("Sensor path must be in the format " "dotted.path.to.module:ClassName")

    try:
        module = importlib.import_module(module_name)
    except ImportError:
        raise RuntimeError(f"Could not import module {module_name}")

    try:
        sensor_class = getattr(module, sensor_name)
    except AttributeError:
        raise RuntimeError(f"Could not find attribute {sensor_name} in " f"{module_name}")

    if (isinstance(sensor_class, type) and

issubclass(sensor_class, Sensor) and sensor_class != Sensor):
        return sensor_class()
    else:
        raise RuntimeError(f"Detected object {sensor_class!r} is not " f"recognised as a Sensor type")

AUTOMATIC TYPE INFERENCE

值得注意的是这个函数两个版本的类型注释。第一个版本没有检查指定的组件是否是传感器,所以我们将其声明为返回Any

如果我们在src/apd/sensors/mypyexample.py中创建以下测试代码,然后通过 mypy 类型检查器运行,我们会发现它不能识别传感器的类型:

import importlib

module = importlib.import_module("apd.sensors.sensors")
class_ = getattr(module, "PythonVersion")
sensor = class_()
reveal_type(sensor)

结果

mypyexample.py:6: note: Revealed type is 'Any'

解析器无法判断class_变量中的类是什么类型,因为它需要执行import_modulegetattr(...)中的特定代码来找到返回的对象。在前面的例子中,这两个都是硬编码的,但是如果这些字符串中的一个或两个都是由用户输入提供的,那么事先不知道用户输入是什么是不可能的。所以就 mypy 而言,class_sensor可以是任何类型。

然而,如果我们通过一些检查来保护实例化class_的行,以确定class_是否是一个type,并且如果该类型是Sensor的子类,那么 mypy 就足够好地理解这种情况 4 来检测传感器是否是Sensor[Any]的实例。

import importlib

from .sensors import Sensor

module = importlib.import_module("apd.sensors.sensors")
class_ = getattr(module, "PythonVersion")
if isinstance(class_, type) and issubclass(class_, Sensor):
    sensor = class_()
    reveal_type(sensor)

结果

mypyexample.py:6: note: Revealed type is 'sensors.sensors.Sensor[Any]'

可以通过使用typing.cast(Sensor[Any], sensor)手动强制一个实例被认为是Sensor[Any],但是这很少是必要的,并且可能会掩盖一些错误。

然后,调用函数可以捕获我们生成的任何RuntimeError,并通过将异常强制转换为字符串来显示适合用户的错误消息:

if sensor_path:
    try:
        sensors = [get_sensor_by_path(sensor_path)]
    except RuntimeError as error:
        click.secho(str(error), fg="red", bold=True, err=True)
        sys.exit(ReturnCodes.BAD_SENSOR_PATH)

这会用红色粗体文本将RuntimeError的值打印到标准错误流中,然后用一个已知的退出代码退出脚本。退出代码是类 Unix 环境中控制台脚本的一个便利特性。它允许脚本调用程序,可以处理错误情况,而不必解析结果错误。

我们应该使用枚举来存储有效代码。对于只包含从名称到整数的映射的类,这是一个特殊的基类,它包括一些有用的功能,如自定义字符串表示,这些功能在调试时很有用。

class ReturnCodes(enum.IntEnum):
    OK = 0
    BAD_SENSOR_PATH = 17

许多工具使用小数字和大约等于 255 的数字来定义它们自己的内部错误,所以选择 16 的偏移量使得我们的返回代码不太可能与我们的工具产生的任何其他代码冲突。特别是,除了一般的故障代码之外,我们不应该使用 1。我选择 17 作为退出代码,来表示传递给程序的参数意味着解析不能成功的错误。

卸载解析以点击参数类型

Click 支持自动解码作为参数传入的值。对于某些参数类型,这具有直观的意义;将参数声明为数字(或布尔值等)更容易。)总比传递字符串并让命令自己解析值要好。

Click 中有内置类型,可以用来提高命令行工具的可用性。简单类型click.STRINGclick.INTclick.FLOATclick.BOOL对它们的输入值进行相对简单的解析,将命令行调用的规范转换成 Python 值。例如,click.FLOAT在输入端调用float(...),然后click.BOOL根据一个简短的已知值列表检查输入,这些值表示TrueFalse,比如y/nt/f1/0等等。可以直接使用 Python 类型(即strintfloatbool)作为简写来指定这些类型,如果没有指定类型,请单击“尝试猜测类型”。

还有一些更复杂的类型,比如在click.INT之上应用验证的click.IntRange和允许指定接受多个选项的选项类型的click.Tuple(...)。例如,如果您正在开发一个接受位置的程序,那么您可能有一个--coordinate参数,其定义如下:

@click.option(
    "--coordinate",
    nargs=2,
    metavar="LAT LON",
    help="Specify a latitude and longitude according to the WGS84 \coordinate system",
    type=click.Tuple((click.FloatRange(-90, 90), click.FloatRange(-180, 180))),
)

使用这些类型可以确保传递给函数的数据是有效的,并且最终用户可以得到有用的错误消息。它还显著减少了您必须编写的解析和验证逻辑的数量。这对于所有点击提供的最复杂的类型尤其有用。此类型允许您指定应将打开的文件引用传递给函数,并在函数完成执行后正确关闭。它还允许指定-来表示应该使用标准输入和标准输出流,而不是驱动器上的文件,这是许多命令行工具提供的功能,通常必须作为特例添加。

也许最令人惊讶的有用类型是click.Choice,它采用一组字符串来检查值。例如,click.Choice(("red", "green", "blue"), case_sensitive=False)提供了一个类型验证器,它只接受字符串“红色”、“绿色”和“蓝色”。此外,如果您的用户已经为您的程序启用了自动完成功能,那么当用户在此参数中按 tab 键时,这些值会被自动建议。

自定义单击参数类型

新的类型可以添加到 Click 的解析系统中,这使得需要定期进行相同命令行解析的程序可以将其拆分为一个可重用的函数,并信任框架来调用它。

在我们的例子中,我们只有一个地方期望将对 Python 类的引用作为参数传递,因此没有实际理由将 Python 类实现为函数可以期望的类型。相对来说,这种正确的方法很少出现,但是很有可能你在将来的项目中需要这样做。

以下是 Python 类的解析器:

from click.types import ParamType

class PythonClassParameterType(ParamType):
    name = "pythonclass"

    def __init__(self, superclass=type):
        self.superclass = superclass

    def get_sensor_by_path(self, sensor_path: str, fail: Callable[[str], None]) -> Any:
        try:
            module_name, sensor_name = sensor_path.split(":")
        except ValueError:
            return fail(
                "Class path must be in the format dotted.path." "to.module:ClassName"
            )
        try:
            module = importlib.import_module(module_name)
        except ImportError:
            return fail(f"Could not import module {module_name}")
        try:
            sensor_class = getattr(module, sensor_name)
        except AttributeError:
            return fail(f"Could not find attribute {sensor_name} in " f"{module_name}")
        if (
            isinstance(sensor_class, type)
            and issubclass(sensor_class, self.superclass)
            and sensor_class != self.superclass
        ):
            return sensor_class
        else:
            return fail(
                f"Detected object {sensor_class!r} is not recognised as a " f"{self.superclass} type"
            )

    def convert(self, value, param, ctx):
        fail = functools.partial(self.fail, param=param, ctx=ctx)
        return self.get_sensor_by_path(value, fail)

    def __repr__(self):
        return "PythonClass"

# A PythonClassParameterType that only accepts sensors
SensorClassParameter = PythonClassParameterType(Sensor)

下面是使用内置解析器的更新选项调用:

@click.option(
    "--develop",
    required=False,
    metavar="path",
    help="Load a sensor by Python path",
    type=SensorClassParameter,
)

EXERCISE 4-1: ADDING AUTOCOMPLETE SUPPORT

我在本章前面提到过click.Choice,它为自动完成某些选项的值提供了支持。可以为任何选项参数提供回调,以允许自定义自动完成。

--develop标志编写一个完美的自动完成实现是不可行的,因为它涉及到自动完成 Python 模块名。扫描环境以确定所有的可能性是非常困难的。

然而,编写一个自动完成的实现要容易得多,一旦进入模块,它就完成类的一部分。本章附带的代码中有这样一个实现的例子;看之前试着自己写一个。

自动完成方法的方法签名是

def AutocompleteSensorPath(
    ctx: click.core.Context, args: list, incomplete: str
) -> t.List[t.Tuple[str, str]]:

通过添加autocompletion=AutocompleteSensorPath作为参数,为选项启用自动完成方法。

测试时,您可能需要进入虚拟环境中的 shell,并手动启用传感器可执行文件的自动完成功能。例如,要为 bash shell 启用自动完成功能,您可以使用

> pipenv shell
> eval "$(_SENSORS_COMPLETE=source_bash sensors)"

您需要手动启用自动完成,因为自动完成配置通常由软件包安装程序处理,并且在不同的操作系统之间有很大的差异。_SENSORS_COMPLETE=source_bash环境变量告诉 click 生成一个 bash 自动完成配置,而不是普通的处理。在前面的示例中,这是使用 eval 立即处理的,但是您也可以将结果保存在一个文件中,然后将其包含在您的 shell 的配置文件中。您应该检查针对您的特定操作系统和 shell 组合的推荐方法。

此外,:字符可能会导致一些 shells 中止自动完成。在这种情况下,用引号将--develop的参数括起来,然后重试。

固定期权

最后,期权的某些用法比其他用法更常见。人们希望在他们的程序中最常见的选项是--help显示关于如何调用命令的信息。点击自动将此选项添加到所有命令中,除非您在@click.command(...)调用中指定了add_help_option=False。您可以使用@click.help_option(...)装饰功能手动添加帮助选项,例如,如果您需要支持不同的语言:

@click.command(help="Displays the values of the sensors")
@click.help_option("--hilfe")
def show_sensors(develop: str) -> int:
    ...

另一个经常需要的功能是--version,它打印安装在用户计算机上的命令版本。像--help一样,这在内部作为一个选项用is_flag=Trueis_eager=True实现,并且有一个专门的callback方法。设置了is_flag的选项没有附加明确的值,它们或者存在或者不存在,这通过它们的值为True或者False来表示。

在解析命令行选项的过程中,is_eager参数将一个选项标记为重要选项。它允许--help--version命令在函数的其他参数被解析之前实现它们的逻辑,这有助于程序快速响应。

使用@click.version_option(...)装饰器应用版本参数。装饰器使用选项prog_name来指定当前应用的名称,使用选项version来指定当前版本号。这两个选项都是可选的:如果没有设置prog_name,那么使用调用程序时使用的名称。如果省略了version参数,那么从 Python 环境中查找当前安装的版本。因此,通常而不是需要覆盖这些值。因此,添加这个选项的标准方式是添加装饰器:@click.version_option()

对于某些操作,如删除,您可能希望在继续之前得到用户的明确确认。这可以用@click.confirmation_option(prompt="Are you sure you want to delete all records?")来实现。prompt=选项是可选的:如果省略,默认提示“是否继续?”被使用。用户也可以通过传递命令行标志--yes来跳过提示。

最后,还有一个@click.password_option装饰器,它在应用启动后立即提示用户输入密码。默认情况下,会要求用户输入密码,然后确认密码,就像设置了密码一样,但是可以使用confirmation_prompt=False禁用确认步骤。密码本身不会显示在终端中,从而防止当时计算机附近的人读取密码。如果您使用这个选项,您应该确保底层命令采用了一个password=选项,这样您就可以访问用户输入的密码。

允许第三方传感器插件

现在我们已经升级了命令行工具,以允许测试我们的外部传感器,并且我们已经完成了一个返回有用数据的实现,我们已经涵盖了两个用例中较为罕见的:帮助开发人员编写新的插件。更常见的情况是终端用户——安装了插件传感器并希望它“正常工作”的人让这些用户在每次命令行调用时都需要指定 Python 路径是不合适的。我们需要一种动态生成可用传感器列表的方法。

有两种方法可以解决这个问题:自动检测和配置。自动检测包括传感器向命令行工具注册自身,以便在运行时提供所有已安装传感器的列表。或者,配置依赖于用户维护一个文件,该文件指向他们想要安装的传感器,然后在运行时解析该文件。

就像我们到目前为止在两种方法之间做出的大多数决定一样,这两种方法都有优点和缺点,诀窍在于为您的特定用例选择正确的方法,如表 4-1 所示。

表 4-1

比较传感器类型的配置和自动检测

|

比较

|

配置

|

自动检测

|
| --- | --- | --- |
| 易于安装 | 安装软件包并编辑配置文件 | 安装软件包 |
| 重新排序插件 | 可能的 | 不可能 |
| 用新的实现覆盖内置插件 | 可能的 | 不可能 |
| 排除已安装的插件 | 可能的 | 不可能 |
| 插件可以有参数 | 可能的 | 不可能 |
| 方便用户 | 要求用户能够自如地编辑配置文件 | 不需要额外的步骤 |

使用基于配置的系统允许对插件系统的细节进行更多的控制。它非常适合开发人员或系统集成商可能使用的插件架构,因为它允许他们配置他们想要的确切环境,并将其存储在版本控制中。Django apps 系统就是一个例子。应用被安装到本地环境中,但不会影响网站,直到它们被添加到settings.py文件中,这时它们可以添加特定于插件的设置。

这种方法适用于 Django 和其他系统,在这些系统中,通过混合和匹配第三方代码和专门开发的软件来创建定制部署。想要使用已经安装的应用提供的功能子集是很常见的,例如,通过省略一些中间件选项或设置不同的 URL 方案。这种复杂性与 WordPress 这样的系统形成了鲜明的对比,在 WordPress 这样的系统中,插件的安装是在非技术用户的能力范围之内。在这种情况下,安装插件本身就足够了,更复杂的配置由应用而不是中央配置文件来处理。

对于非技术最终用户来说,自动检测方法要简单得多,因为他们不需要编辑配置文件。这也使得系统对印刷错误不太敏感。对于我们的用例,我们不太可能需要禁用插件,因为用户可以忽略任何他们不需要的数据。插件的排序同样不重要。

乍一看,用新的实现覆盖插件似乎很有用,但是这意味着根据使用的版本不同,收集的值可能有稍微不同的含义。例如,我们可能希望添加一个“温度”传感器,它返回系统温度而不是环境温度。对于某些用例,这些可能是可互换的,但是最好在数据中保持区别。如果需要,我们在分析数据时总是可以得出一个等价关系。

基于配置的系统具有的一个对该程序有用的特性是能够将配置值传递给传感器本身。到目前为止,我们有三个传感器将从配置中受益匪浅:温度和湿度传感器被硬编码,以期望传感器位于运行它们的系统的 IO pin D4 上,太阳能电池板传感器被硬编码到特定的蓝牙硬件地址。

对于我们不希望为其他人工作的私有插件(如太阳能电池板显示器),这两个都是可以接受的,但温度和湿度传感器是一个更通用的传感器,我们希望有一系列用户有兴趣安装。温度和湿度传感器需要为最终用户提供最少的配置选项。

使用固定名称的插件检测

有可能编写一个插件架构来检测定义在一个文件中的传感器,该文件由于位于当前工作目录中而可以导入。这种方法使用 Python 的源代码解析作为配置文件的解析系统。例如,我们可以创建一个custom_sensors.py文件,并在该文件中导入我们想要使用的任何传感器。

def get_sensors() -> t.Iterable[Sensor[t.Any]]:
    try:
        import custom_sensors
    except ImportError:
        discovered = []
    else:
        discovered = [
            attribute
            for attribute in vars(custom_sensors).values()
            if isinstance(attribute, type)
            and issubclass(attribute, Sensor)
        ]
    return discovered

这里的vars(custom_sensors)函数是代码中最不寻常的部分。它返回该模块中定义的所有内容的字典,其中键是变量名,值是变量的内容。

Note

vars(...)功能在调试时很有帮助。如果你有一个变量obj并调用vars(obj),你会得到一个关于这个对象的数据集的字典。相关函数dir(obj)返回该实例上所有可解析属性名称的列表。如果您想在调试会话期间了解某个对象,这两者都非常有用。

使用 Python 作为配置具有非常简单的优势,但是编写自定义 Python 文件是一种非常技术性的方法,大多数用户不喜欢使用。用户必须手动将传感器代码复制到该文件中(或从其他地方导入),并自己管理任何依赖关系。在任何情况下,我都不推荐将它作为一个插件架构系统,但是让一个 python 文件可以通过工作目录导入的想法作为一种配置方式有时是很有用的,正如我们在本书的结尾将会看到的。

使用入口点的插件检测

对于我们的用例,我认为易用性是最重要的考虑因素,所以我们应该采用一种不依赖于配置文件的方法来检测插件。Python 有一个实现这种类型的自动检测的特性,我们在前一章中简要提到过。它叫做入口点。entrypoint 特性是我们用来声明函数应该作为控制台脚本公开的特性(事实上,这是该特性最常见的用法),但是任何 Python 代码都可以将 entrypoint 系统用于自己的插件。

Python 包可以声明它提供入口点,但是由于它们是打包工具的一个特性,入口点只能在 Python 包的元数据中设置。创建 Python 发行版时,大部分元数据被拆分到元数据目录中的文件中。这与实际代码一起分发。当代码请求入口点的注册值时,将扫描这个元数据的解析版本。如果一个包提供了入口点,那么一旦这个包被安装,它们就可以被枚举,这是一个非常有效的方法让代码跨包发现插件。

入口点在两级名称空间中注册。外部名称是 entrypoint 组,它是一个简单的字符串标识符。对于命令行工具的自动生成,这个组名是console_scripts(对于图形工具,不太常见的是gui_scripts)。这些组名不必预先注册,因此您的包可以提供其他软件可能使用的入口点。如果您的最终用户没有安装该软件,他们将被忽略。组名可以是任何字符串,然后可以用它来查询入口点引用的所有内容。

您可以使用pkg_resources模块找到 Python 安装中正在使用的入口点组。这不是您需要在代码中做的事情,因为没有一个简单的 API 来实现它,但是在了解该特性以及其他 Python 工具如何使用它时,这是一件有趣的事情。下面是一个单行程序 6 (为了便于阅读,不包括导入和格式化),用于列出 Python 环境中使用的入口点类型:

>>> functools.reduce(
...     set.union,
...     [
...         set(package.get_entry_map(group=None).keys())
...         for package in pkg_resources.working_set
...     ],
... )
...
{'nbconvert.exporters', 'egg_info.writers', 'gui_scripts', 'pygments.lexers', 'console_scripts', 'babel.extractors', 'setuptools.installation', 'distutils.setup_keywords', 'distutils.commands'}

前面的示例显示了在我的计算机上使用了九组不同的入口点。其中大部分涉及 Python 包管理,但有三个是我电脑上安装的其他插件系统。nbconvert.exporters是 Jupyter 工具套件的一部分;在第一章中,我们使用nbconvert将笔记本转换成标准的 Python 脚本。这个转换器是通过检查这个入口点找到的,这意味着如果需要,我们可以编写自己的导出器。pygments.lexers是 pygments 代码格式库的一部分;这些入口点允许 pygments 支持新语言,而babel.extractors是帮助 i18n 工具 babel 在不同类型的源代码中找到可翻译字符串的入口点。

命名空间的第二层是单个入口点的名称。这些在一个组中必须是唯一的,并且本身没有意义。您可以使用iter_entry_points(group, name)来搜索特定的入口点名称,但是更常见的是使用iter_entry_points(group)来获得一个组中的所有入口点。

所有这些意味着我们需要决定一个标准字符串作为入口点组名,并让插件声明它们在这个组中提供入口点。我们还必须更新我们的核心代码,以确保所有的插件都这样声明。我们将使用字符串apd.sensors.sensor,因为这是有意义的,并且不太可能与其他开发人员可能做的事情冲突。apd.sensorssetup.cfg文件的 entrypoints 部分修改如下:

[options.entry_points]
console_scripts =
  sensors = apd.sensors.cli:show_sensors
apd.sensors.sensor =
  PythonVersion = apd.sensors.sensors:PythonVersion
  IPAddresses = apd.sensors.sensors:IPAddresses
  CPULoad = apd.sensors.sensors:CPULoad
  RAMAvailable = apd.sensors.sensors:RAMAvailable
  ACStatus = apd.sensors.sensors:ACStatus
  Temperature = apd.sensors.sensors:Temperature
  RelativeHumidity = apd.sensors.sensors:RelativeHumidity

apd.sunnyboy_solar包使用相同的入口点组名,通过在其setup.cfg中声明下面的入口点部分,将它的一个插件添加到已知插件集中:

[options.entry_points]
apd.sensors.sensor =
  SolarCumulativeOutput = apd.sunnyboy_solar.sensor:SolarCumulativeOutput

为了使用入口点而不是对传感器进行硬编码,我们需要对代码进行的唯一更改是重写get_sensors方法,如下所示:

def get_sensors() -> t.Iterable[Sensor[t.Any]]:
    sensors = []
    for sensor_class in pkg_resources.iter_entry_points("apd.sensors.sensor"):
        class_ = sensor_class.load()
        sensors.append(t.cast(Sensor[t.Any], class_()))
    return sensors

这里的造型不是绝对必要的。我们也可以使用isinstance(...)守卫 7 ,我们在--develop选项中看到过;然而在这种情况下,我们愿意相信插件作者只创建引用有效传感器的入口点。以前,我们依赖命令行调用,出错的几率相当高。这样做的效果是,我们告诉类型框架,我们从加载一个apd_sensors入口点并调用结果中得到的任何东西都是有效的传感器。

console_scripts入口点一样,我们需要重新安装这两个包,以确保入口点得到处理。对于脚本的真实版本,我们会增加次要版本号,因为我们引入了一个不会破坏向后兼容性的新特性,但是当我们使用开发安装时,我们会重新运行pipenv install -e .来强制安装。

配置文件

另一种方法是编写一个配置文件,我们之前已经放弃了。Python 的标准库支持解析 ini 文件,用户编辑起来相对容易。或者,像 YAML 或 TOML 这样的配置格式可能会使解析更容易,但是用户可能不太熟悉编辑。

一般来说,我建议使用 ini 格式进行配置,因为最终用户熟悉它。 8 我们还需要决定在哪里保存 ini 文件;它们可能位于工作目录中,如果合适的话,可能作为命令行参数显式包含在内,或者位于当前操作系统的一个众所周知的默认目录中。

无论我们决定在哪里存储文件,我们都将为命令行创建一个新的参数,该参数接受要使用的配置文件的位置;只有默认行为会有所不同。我们还需要创建一个函数来读取配置文件,并使用任何相关的配置数据实例化传感器。

标准库中的configparser模块有一个简单的接口,用于从一个或多个文件中加载 ini 格式的数据,所以这是我们用来加载配置值的接口。我们将把 ini 格式定义为包含一个plugins=值的[config]部分。plugins值中的项目指向新的部分,每个部分用其(可选)配置值定义一个传感器。以下是apd.sensors的基本config.cfg文件:

[config]
plugins =
    PythonVersion
    IPAddress

[PythonVersion]
plugin = apd.sensors.sensors:PythonVersion

[IPAddress]
plugin = apd.sensors.sensors:IPAddresses

这显示了配置系统的强大之处,因为这个配置文件只加载两个传感器,这大大加快了执行时间。不太明显的事实是,传感器配置块不需要与其派生的传感器类同名,例如,IPAddress vs. IPAddresses。以这种方式可以多次列出同一个传感器类别,这样就可以用不同的参数定义同一个传感器的多个实例,并从每个实例中收集数据。 9 传感器也可以从插件行中移除以暂时禁用它,而无需删除其配置。

这个配置文件的解析器将[config]部分的plugins行映射到键config.plugins。我们的代码必须检查这个值,提取名称,然后遍历它所引用的部分。将解析和传感器实例化保持为独立的函数是一个好主意,因为这极大地提高了各自的可测试性。如果读取配置和解析配置是不同的功能,可测试性会稍微好一点,但是由于 configparser 提供了这种功能,所以减少我们需要自己编写的文件处理代码的数量并将其留给 configparser 是有意义的。

像前面的--develop助手函数一样,我们将在这里捕捉任何相关的错误,并使用用户友好的消息重新提升为RuntimeError。这些问题将作为错误消息提交给最终用户,并带有一个新的返回代码来表示配置文件有问题:

def parse_config_file(
    path: t.Union[str, t.Iterable[str]]
) -> t.Dict[str, t.Dict[str, str]]:
    parser = configparser.ConfigParser()
    parser.read(path, encoding="utf-8")
    try:
        plugin_names = [
            name for name in parser.get("config", "plugins").split() if name
        ]
    except configparser.NoSectionError:
        raise RuntimeError(f"Could not find [config] section in file")
    except configparser.NoOptionError:
        raise RuntimeError(f"Could not find plugins line in [config] section")
    plugin_data = {}
    for plugin_name in plugin_names:
        try:
            plugin_data[plugin_name] = dict(parser.items(plugin_name))
        except configparser.NoSectionError:
            raise RuntimeError(f"Could not find [{plugin_name}] section " f"in file")
    return plugin_data

def get_sensors(path: t.Iterable[str]) -> t.Iterable[Sensor[t.Any]]:
    sensors = []
    for plugin_name, sensor_data in parse_config_file(path).items():
        try:
            class_path = sensor_data.pop("plugin")
        except TypeError:
            raise RuntimeError(
                f"Could not find plugin= line in [{plugin_name}] section"
            )
        sensors.append(get_sensor_by_path(class_path, **sensor_data))
    return sensors

get_sensors(...)函数将接受一个字符串 iterable,这些字符串是配置文件的可能路径。一个新的--config参数可以添加到默认为"config.cfg"show_sensors命令中,以收集将传递给get_sensors(...)的路径值。

@click.option(
    "--config",
    required=False,
    metavar="config_path",
    help="Load the specified configuration file",
)

每个需要配置变量的传感器现在必须接受它作为传感器类的__init__(...)函数的参数。这个函数定义了创建类实例的行为,并且是您处理类实例化的参数的地方。温度传感器会将它需要的变量存储在__init__(...)函数中,然后在value(...)函数中引用它们。以下是接受配置参数的Temperature传感器的部分列表:

class Temperature(Sensor[Optional[float]]):
    title = "Ambient Temperature"

    def __init__(self, board="DHT22", pin="D4"):
        self.board = board
        self.pin = pin

    def value(self) -> Optional[float]:
        try:
            import adafruit_dht
            import board

        except (ImportError, NotImplementedError):
            return None
         try:
            sensor_type = getattr(adafruit_dht, self.board)
            pin = getattr(board, self.pin)
            return sensor_type(pin).temperature
        except RuntimeError:
            return None

对于某些应用,您可能希望提供更标准化的配置文件加载,在这种情况下,我们可以利用 configparser 可以处理潜在路径列表以传入所有可能的配置文件位置这一事实。 10 一个简单的方法是在代码中包含/etc/apd.sensors/config.cfg~/.apd_sensors/config.cfg,但这在 Windows 上不起作用。Python 包安装程序 pip 遵循配置模式。它有一个非常复杂的代码路径,用于确定配置文件的位置,正确地实现一系列平台的预期位置。由于 pip 是麻省理工学院许可的,与apd.sensors的许可兼容,我们可以利用这些功能使 sensors command 感觉更像那些不同操作系统生态系统中表现良好的公民。这方面的一个例子包含在本章的代码中。

当然,改变插件的加载方式对apd.sensors的测试有连锁效应,意味着需要一些新的夹具和补丁来支持cli.py中的实质性变化。这也允许我们在测试中更加灵活,通过包含配置文件来设置虚拟传感器,这些虚拟传感器只用于测试程序的基础设施。

环境变量

满足配置少量传感器需求的最后一种方法是利用环境变量。这些是系统提供给程序的变量,通常包含像库路径这样的信息。我们可以编写一些需要配置的传感器,在环境变量中查找它们的配置。在这种情况下,我们不需要加载任何配置文件。我们可以使用传感器发现的自动检测风格,并将值提取放在__init__函数中。环境变量像字典一样暴露在属性os.environ上,所以与前面使用环境的Temperature的实现等价的是

def __init__(self):
    self.board = os.environ.get("APD_SENSORS_TEMPERATURE_BOARD", "DHT22")
    self.pin = os.environ.get("APD_SENSORS_TEMPERATURE_PIN", "D4")

这些可以在命令行上设置;然而,在使用 pipenv 时定义它们最简单的方法是使用“dotenv”标准,即在 pipenv 安装的根目录下创建一个名为.env的文件,其中包含相关的定义。pipenv run 命令加载该文件,并设置每次程序运行时定义的任何变量。在这种情况下,该文件类似于

。环境

APD_SENSORS_TEMPERATURE_BOARD=DHT22
APD_SENSORS_TEMPERATURE_PIN=D4

在某些平台上,管理环境变量可能很困难。这种.env文件范例允许我们将它们视为最小配置文件,这使得它们成为非常最小配置的好选择。这里有一个类似于我们所看到的命令行参数的权衡;我们选择了一个更简单的解决方案,不提供配置的自动解析,而不是更复杂的参数解析,因为与参数解析不同,这些决定对程序的可用性有很大的影响。

apd 传感器方法与类似程序

虽然有理由使用全面的配置文件系统,但对于我的特定用例,我希望最终用户只需付出最少的努力就可以开箱即用。正在考虑服务器状态聚合的人可能会发现自己站在了这个决策的另一边。这在很大程度上取决于你想要提供的用户界面,有可能编写越来越复杂的代码来支持你的确切需求。

例如,一些利用子命令风格的命令调用的工具实际上定义了一个配置命令来帮助用户管理他们的配置文件,而不是让他们直接编辑它们。版本控制软件 git 就是一个例子,其中任何面向用户的设置都可以使用git config命令来设置,指定应该读取各种配置文件中的哪一个。

对于apd.sensors,在这个阶段,阻力最小的路径是使用入口点枚举插件和环境变量来配置它们,不考虑任何忽略已安装插件或重新排序它们的可能性。

摘要

本章的大部分内容已经涵盖了一般的软件工程主题,例如配置文件管理和命令行工具用户体验。Python 中可用的工具在这些方面提供了很大的灵活性,因此我们可以专注于为用户做出最佳决策,而不是被软件的限制所驱使。

然而,插件系统需求是 Python 真正闪光的地方。我们正在构建的工具有点不寻常,因为它被设计成允许其他代码扩展它。尽管开发人员框架使用插件系统很常见,但是您编写的大多数软件都是独立的应用。这使得 Python 的 entrypoint 系统如此之好更加令人惊讶。这是一种定义简单插件接口的奇妙方式;它应该更广为人知。

在本章的过程中,我们对软件采取的总体方法是选择我们能提供给用户的最简单的用户界面。我们已经考虑了将来可能选择引入的替代产品,但我们认为它们提供的功能在现阶段并不重要。

我们的命令行工具实际上是完整的。我们有一个工作插件接口,允许配置单个传感器参数和安装特定应用的传感器。该程序是一个独立的 Python 应用,可以安装在我们想要监控的各种计算机上。最好的方法是使用一个新的Pipfile,因为我们到目前为止一直使用的是为了构建代码的开发环境。

新的Pipfile将使用apd.sensors的发布版本和我们创建的私有发布服务器来存放发布版本。我们可以在 Raspberry Pi 上创建它,然后将PipfilePipfile.lock分发给我们想要安装的所有其他 Raspberry Pi。

生产部署 Pipfile

[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true

[[source]]
name = "piwheels"
url = "https://piwheels.org/simple"
verify_ssl = true

[[source]]
name = "rpi"
url = "http://rpi4:8080/simple"
verify_ssl = false

[packages]
apd-sensors = "*"

[requires]
python_version = "3.8"

额外资源

由于本章关注的是决策而不是 Python 的特性,所以本章没有介绍太多新的软件。以下在线资源提供了一些与我们的使用案例无关的方法的附加细节,以及一些在不同操作系统上高级使用命令行脚本的帮助:

五、备用接口

我们有一个命令行工具来报告服务器上各种数据收集功能的结果,但是能够连接到服务器并运行命令行工具来检查它们的当前状态并不是监控大量数据收集系统的可持续方式。我们不想记下多次命令行工具调用的结果,并手动分析它们。最好能够自动收集信息,并能够分析原始值,而不是我们向用户显示的格式化结果。

我们可以创建一个简单的基于 HTTP 的 web 服务器,它返回传感器的值来响应 API 调用,而不是编写一个程序通过 SSH 依次连接到每个服务器并调用命令行工具。为此,我们必须为相同的传感器创建一个新的接口。

Web 微服务

在过去的几年中,有一种趋势是通过松散耦合许多服务来创建 web 应用,每个服务执行一个特定的任务。这种架构牺牲了统一代码库的便利性,换取了独立开发每个组件的灵活性。有些 web 框架比其他框架更适合这类问题——有些是专门为这一领域开发的。

有很多 Python web 框架,比如 Django、Pyramid、Flask 和 Bottle,它们都可以作为 API 服务器的基础。Django 和 Pyramid 都是复杂 web 应用的绝佳选择,它们提供了许多内置特性,如翻译、会话管理和数据库事务管理。其他的,像烧瓶和瓶子,要小得多。他们有少量的依赖项,并擅长作为微服务的基础。

我们需要一个非常简单的 API 服务器,没有任何为人类设计的界面。不需要 HTML 模板、导航系统或者 CSS 和 Javascript 管理。为微服务设计的 Web 框架非常适合非常小的 API 服务器。

WSGI(消歧义)

所有 Python web 框架都使用一种标准来创建通过 HTTP 提供服务的应用,这种标准称为 web 服务器网关接口,简称 WSGI。WSGI 是一个简单的 API,我们可以直接使用它来编写向 Web 公开的函数。

WSGI 应用是一个 Python 可调用程序,它接受两个参数。第一个是表示环境的字典(它包含各种 HTTP 头和服务器信息,比如客户机的远程地址),第二个是start_response(...)函数,它期望 HTTP 状态代码是一个字符串,响应头的 iterable 是两个字符串元组。

Python 标准库包括一个简单的 WSGI 服务器,用于测试 WSGI 应用。对于用于生产代码来说,它还不够好,但是对于开发来说却很方便。它是从wsgiref.simple_server模块导入的,其中make_server(...)上下文管理器接受主机和端口绑定参数以及要服务的函数。得到的上下文对象有一个serve_forever()方法来运行 HTTP 服务器,直到被<CTRL+c>中断,还有一个handle_request()方法来响应单个请求。使用 wsgiref 服务器运行一个演示,Hello World 网站如清单 5-1 所示。

import wsgiref.simple_server

def hello_world(environ, start_response):
    headers = [
        ("Content-type", "text/plain; charset=utf-8"),
        ("Content-Security-Policy", "default-src 'none';"),
    ]
    start_response("200 OK", headers)
    return [b"hello  world", ]

if __name__ == "__main__":
    with wsgiref.simple_server.make_server("", 8000, hello_world) as server:
        server.serve_forever()

Listing 5-1Hello world WSGI app

start_response(...)函数特定于负责处理传入连接的 WSGI 兼容服务器,但是它总是以相同的方式运行。如果在 Python 的内置测试 web 服务器上使用,hello_world(...)函数也会工作得很好,比如 Gunicorn 这样的专业生产质量 web 服务器,甚至是 Heroku 这样的 PaaS 提供商。在hello_world(...)中没有涉及特定于服务器的导入或函数调用;这完全是通用的。

这个函数的返回值是响应的主体,这可能与直觉相反,是一个可迭代的字节串,而不是单个字节串。如果我们在网页浏览器中打开http://localhost:8000,我们会看到“hello world”,如图 5-1 所示。

img/481001_1_En_5_Fig1_HTML.jpg

图 5-1

hello world 应用的浏览器视图

使用生成器函数允许服务器在所有数据生成之前就开始向客户机传递一些数据,即在计算其余数据之前先处理部分数据。如果我们从纯文本切换到 HTML, 1 我们可以通过引入一些有意的延迟来看到这种效果,如清单 5-2 所示。

import time
import wsgiref.simple_server

def hello_world(environ, start_response):
    headers = [
        ("Content-type", "text/html; charset=utf-8"),
        ("Content-Security-Policy", "default-src 'none';"),
    ]
    start_response("200 OK", headers)
    yield b"<html><body>"
    for i in range(20):
        yield b"<p>hello world</p>"
        time.sleep(1)
    yield b"</body></html>"

if __name__ == "__main__":
    with wsgiref.simple_server.make_server("", 8000, hello_world) as server:
        server.serve_forever()

Listing 5-2Generator-based hello world WSGI app

当我们在浏览器中打开http://localhost:8000时,我们现在看到 hello world 消息每秒钟以新行的形式出现一次。就吞吐量而言,这对于较大的响应非常有用,并且可以减少服务器上的内存使用。例如,如果我们编写了一个 WSGI 应用来传输一个 500MB 日志文件的每一行,那么遍历这些行并一行一行地生成它们就意味着内存中一次不会有超过一行,并且一旦文件开始被读取就发送数据。如果我们必须返回一个单独的字符串,那么整个文件必须被读入内存,然后作为一个整体传递给服务器进行传输。

我们可以使用同样的方法来创建一个 WSGI 端点,它遍历传感器并依次获取关于每个传感器的信息。然而,单个 JSON 对象更容易解析为 API 响应,因此最好创建一个传感器标题字典来赋值,并将其作为一个整体进行序列化。现在是向该函数添加类型信息的好时机,这样我们就可以利用 mypy 的类型提示来标记错误。结果服务器列出了 5-3 ,我们应该将其保存为src/apd/sensors/wsgi.py

import json
import typing as t
import wsgiref.simple_server

from apd.sensors.cli import get_sensors

if t.TYPE_CHECKING:
    # Use the exact definition of StartResponse, if possible
    from wsgiref.types import StartResponse
else:
    StartResponse = t.Callable

def sensor_values(
    environ: t.Dict[str, str], start_response: StartResponse
) -> t.List[bytes]:
    headers = [
        ("Content-type", "application/json; charset=utf-8"),
        ("Content-Security-Policy", "default-src 'none';"),
    ]
    start_response("200 OK", headers)
    data = {}
    for sensor in get_sensors():
        data[sensor.title] = sensor.value()
    encoded = json.dumps(data).encode("utf-8")
    return [encoded]

if __name__ == "__main__":
    with wsgiref.simple_server.make_server("", 8000, sensor_values) as server:
        server.handle_request()

Listing 5-3Basic WSGI server to show sensor data

我们可以通过在我们的开发机器上使用

> pipenv run python -m apd.sensors.wsgi

访问这个 web 服务器并通过jq JSON 格式化程序 2 传递它会产生以下输出:

{
  "AC Connected": false,
  "CPU Usage": 0.098,
  "IP Addresses": [
    [
      "AF_INET6",
      "fe80::xxxx:xxxx:xxxx:fa5"
    ],
    [
      "AF_INET6",
      "2001:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:1b9b"
    ],
    [
      "AF_INET6",
      "2001:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:fa5"
    ],
    [
      "AF_INET",
      "192.168.1.246"
    ]
  ],
  "Python Version": [

    3,
    8,
    0,
    "final",
    0
  ],
  "RAM Available": 716476416,
  "Relative Humidity": null,
  "Ambient Temperature": null,
  "Solar panel cumulative output": null
}

Note

我们已经检查了t.TYPE_CHECKING并且有条件地进口了一些东西。有些名字只能在 mypy 中导入,不能在普通 Python 中导入。当在.pyi文件中定义了帮助变量,而不是直接在.py文件中集成类型提示时,就会发生这种情况。StartResponse变量就是其中之一;它表示标准start_response(...)函数的类型,wsgiref 服务器的实际定义不需要它,只需要类型提示。这个块让我们在类型检查时导入正确的值,但是在其他情况下,我们退回到不太具体的t.Callable,因为类型提示在类型检查运行之外并不重要。

当然,我们应该编写一个测试来确保端点按预期工作。由于我们还没有编写任何代码来处理错误情况,所以我们可以编写的测试并不多,但是类似于test_sensors.py中 CLI 的高级功能测试是合适的。

由于 WSGI 接口是一个 Python API,所以可以通过调用带有environstart_response参数占位符值的sensor_values(...)函数来为它们编写功能测试。WebTest 包提供了一种封装 WSGI 函数并使用行为类似于高级 HTTP API 的 API 与之交互的方法,这使得编写测试更加容易。安装 WebTest 后,我们可以将清单 5-4 中的测试添加到tests/目录中并运行它。

import pytest

from webtest import TestApp

from apd.sensors.wsgi import sensor_values
from apd.sensors.sensors import PythonVersion

@pytest.fixture
def subject():
    return sensor_values

@pytest.fixture
def api_server(subject):
    return TestApp(subject)

@pytest.mark.functional
def test_sensor_values_returned_as_json(api_server):
    json_response = api_server.get("/sensors/").json
    python_version = PythonVersion().value()

    sensor_names = json_response.keys()
    assert "Python Version" in sensor_names
    assert json_response["Python Version"] == list(python_version)

Listing 5-4Functional test for wsgi service

> pipenv install --dev webtest

虽然我们的 WSGI 应用可以工作,但是它离产品质量还很远。这就是微框架有用的地方;它们让我们从没有错误检查的单端点 web 应用过渡到可靠的、生产质量的 web 应用。

API 设计

在我们继续之前,我们应该计划我们想要提供的 API。我们希望能够检索所有的传感器值,但是获得单个特定值可能是有用的,因为提取传感器值可能需要一些时间。我们还需要决定这个 API 的身份验证,因为它将不再受到保护,因为只有能够登录到相关服务器的人才能使用它。

大多数 API 不使用传统的用户名和密码登录系统;相反,他们使用单个 API 密钥作为凭证。无论我们的用户是由用户名和密码标识的人,还是由 API 键标识的其他程序,选择授权系统的考虑因素都是相同的。

有三种广泛的用户授权方法。 3 扁平的权限结构在简单的应用中很流行,用户只需要登录就可以访问网站的所有功能。这种方法通常用在简单的 Django 应用中。如果用户登录并且在用户对象上设置了is_staff属性,他们就可以访问网站的管理员功能。

Django 的完全授权制度表明了第二种选择。它在一个组和许可系统上工作。可以直接或通过用户的组成员身份为用户分配权限。这些权限是细粒度的,但相对来说是全局的。例如,如果用户拥有“编辑用户”权限,他们将能够编辑任何用户。

最后,最复杂的系统涉及用户和数据之间具有灵活关系的权限。在这种情况下,不会直接为用户分配权限,而是将权限分配给站点中某个数据上下文中的用户或组。在这种情况下,我们可能会看到,在给定用户的上下文中,“编辑用户”权限被分配给整个管理员组和相关的单个用户。

图 5-2 显示了我推荐用来确定三种方法中哪一种最适合您的用例的决策树。

img/481001_1_En_5_Fig2_HTML.jpg

图 5-2

用于选择不同 authz 方法的决策树

我们的 API 将是只读的;我们需要保护的唯一功能是读取传感器值的能力。要回答第一个问题,我们需要决定是否要根据请求用户的身份向不同组的传感器授予 API 访问权限。是否允许部分用户看到 Python 版本却看不到温度?这个 API 的唯一用例是从多个来源收集信息并集中存储,这意味着我们只希望加载所有传感器值,并且希望使用最少的 HTTP 请求来完成。对所有用户一视同仁的授权解决方案是最合适的。这种访问控制的目的不是区分具有不同权限级别的用户;我们只关心用户是否有效。

因此,我们在第一个问题上采取右边的分支。下一个决定是我们是否需要能够通过系统创建新用户,或者我们是否可以预先定义用户凭证。我们只需要一个用户就可以访问这些信息,所以我们不需要添加新的用户。

这样做的结果是,我们在最右边有认证系统;用户帐户预先定义为部署的属性。

证明

我们选择的认证框架也应该与我们期望的与 API 服务器的交互方式相匹配。用户最熟悉的身份验证形式是提供会话凭据的专用登录页面,通常采用 cookie 的形式。Cookies 有一个有限的生命周期,尽管这可能是一个很长的时间,这使得用户可以避免定期主动重新认证。

另一种在 API 中更常见的方法是,每个请求可以包含身份验证信息,或者作为专用的 HTTP 头,或者通过使用 HTTP Basic 和 Digest auth 特性。

因为我们打算让我们的 API 被一个自动化的过程访问,并且登录信息不会改变,所以 API key 风格的认证系统符合我们的需求。

这个 Flask 微框架的灵感来自于一个愚人节玩笑:一个叫做的微框架否认了是作为一个单一文件发布的,接口非常简单。作者阿明·罗纳彻写了一个 160 行的框架,强调营销胜于高级功能。也许毫不奇怪,当大多数 web 框架关注大型的、功能齐全的应用时,许多人实际上对 web 编程的简单接口感兴趣。一年后, Flask 诞生了,这是一个高质量的 web 框架,旨在满足那些对如此感兴趣而对不感兴趣的人。

Flask 提供使用 Jinja2 模板语言生成 HTML,管理请求和响应头,管理 URL 路由,并在需要时生成错误。这种灵活性使得简化我们之前编写的函数(删除一些实现细节)以及扩展接口以提供更多功能变得更加容易。

在开始编写基于 Flask 的 web 服务器之前,我们需要将 Flask 添加到项目的依赖项中。我们将采取不同于以往的方法,并将此作为“额外内容”添加进来。Extras 是 Python 包的可选依赖集,用户可以在安装时选择。只想要命令行工具的用户将运行pipenv install apd.sensors,而想要 API 访问的用户将运行pipenv install apd.sensors[webapp]

setup.cfg 段定义了 webapp 多余的

[options.extras_require]
webapp = flask

选择将 CLI 依赖项作为核心要求,而将 API 服务器作为可选项,这是完全随意的;开发人员完全有可能需要额外的两个特性,或者将它们作为默认依赖项包含进来。

Tip

您应该根据用户的需求决定默认安装哪些依赖项,哪些是额外的。如果您怀疑有些用户可能不想使用完全独立的特性,特别是如果它有很大的依赖集,那么它是额外的一个很好的候选。

请记住,试图从extras_require中指定的包导入一个模块会导致导入错误。如果您在这样的模块中有一个命令行脚本,您应该捕捉导入错误并在命令行上返回一个有用的错误。对于试图运行 CLI 工具而没有在安装时指定他们需要 CLI 依赖项的用户来说,ImportError traceback 并不是一个合适的错误。

一旦我们添加了webapp额外声明,我们就可以使用pipenv install -e .[webapp]声明我们的环境需要这个额外声明。这将导致 flask 被添加到依赖项集并安装到环境中。安装了一个 flask 可执行文件,可以通过pipenv run flask访问,但对我们来说重要的是我们能够导入 Flask 应用代码。

我们创建的基本传感器列表 WSGI 应用的等效 Flask 应用非常相似(列表 5-5 ),它展示了 Flask 在 web 服务器和程序员之间的介入是多么少。这里的关键是@app.route(...)装饰器调用。我们将在下一节中讨论 decorator,但是现在只需要知道 decorator 操作它直接位于其定义之前的函数或类就足够了。在这种情况下,@app.route("/sensors/")定义了下面的函数是负责http://localhost:8000/sensors/的实现。

import json
import typing as t
import wsgiref.simple_server

from flask import Flask

from apd.sensors.cli import get_sensors

app = Flask(__name__)

@app.route("/sensors/")
def sensor_values() -> t.Tuple[t.Dict[str, t.Any], int, t.Dict[str, str]]:
    headers = {"Content-Security-Policy": "default-src 'none'"}
    data = {}
    for sensor in get_sensors():
        data[sensor.title] = sensor.value()
    return data, 200, headers

if __name__ == "__main__":
    with wsgiref.simple_server.make_server("", 8000, app) as server:
        server.serve_forever()

Listing 5-5Minimal example of sensor API server in Flask

如果我们没有设置任何显式的头值,我们可以进一步简化sensor_values()函数,只返回数据字典。 4 Flask 自动处理将视图函数返回的字典转换为 JSON 表示,以及编码字符串和设置适当的内容类型头。

这个 WSGI 端点与我们手工创建的基本端点的最大区别是,这个端点根据 URL 返回不同的内容。我们最初的实现不检查特定的 URL,总是返回传感器的值。新的实现将为任何 URL 返回 404,除了/sensors/(和/sensors,它将被重定向到/sensors/)。

为了测试这个新的 Flask 版本,我们需要导入名称app而不是sensor_values(),因为sensor_values()已经成为实现细节,而app是实际的 WSGI 端点。此外,如果我们以前没有这样做,我们必须确保我们对正确的 URL 发出了 GET 请求。

DISPATCHING FUNCTIONS ON THE WEB

在第二章中,我们讨论了动态分派的概念,在运行时通过调用的类来查找函数。因此,我们使用的@app.route(...)装饰器有一个 app 的隐式第一个参数,允许装饰器将被装饰的函数注册为app对象上的已知路径。

无论请求是什么,WSGI 应用都使用相同的环境和请求类型调用相同的函数。由这个函数来决定哪个代码负责响应这个请求。

app对象有一组注册的视图函数可供选择。这些通常带有条件注释,比如匹配 URL 的特定正则表达式;请求是 GET、POST 还是 DELETE 等等;甚至像权限查找或接受头这样的复杂条件。

框架的职责是为给定的 web 请求确定应该调用哪个用户提供的函数。由于这允许将单个函数映射到 URL,这使得为 Web 编写代码的过程比 WSGI 默认的单个函数做所有工作更容易管理。

金字塔 web 框架通过其谓词系统将这一点发挥到了极致,允许将任意条件与视图函数相关联。它允许不同的函数根据任意条件负责给定的 URL,这是一个非常强大的特性。

Python 装饰者

在我们能够称这个 API 为生产就绪之前,我们需要实现我们讨论过的访问控制。我们可以通过使用装饰器来实现这一点,就像 Flask 使用与 route 装饰器相关联的 URL 模式来注释函数一样。

decorator 是一个 Python 函数,它将单个可调用的类或类作为参数,并返回传递给它的相同类型的参数。装饰模式允许用户编写定制的函数序言或后记——在函数主体之前或之后运行的代码。您无法访问函数的内部变量,只能访问输入和输出,但这足以对输入或输出的转换添加额外的错误检查。此外,一些 decorator 函数代码在函数定义时运行,可用于在应用启动时设置元数据(如 URL 路由)。

通过在函数体的开头或结尾调用一个实用函数,可以完成装饰者做的许多事情;装饰者是一个非常方便的特性。Python 开发人员通常更喜欢编写 decorators,因为他们被认为更符合该语言的习惯,但是他们也有一些真正的优势。

使用一个效用函数来完成装饰器的工作意味着被操作的函数需要添加一些条件逻辑来处理各种效用函数的结果。表 5-1 显示了一个效用函数和装饰器的例子,如果一个函数的任何参数为负,它将返回 0。has_negative_arguments(...)函数决定我们要禁止的情况是否适用,但是处理该情况的代码必须添加到power(...)函数本身。

表 5-1

一个助手函数和一个装饰器方法来验证参数

| *辅助功能方法*`def has_negative_arguments(*args):``for arg in args:``if arg < 0:``return True``return False``def power(x, y):``if has_negative_arguments(x, y):``return 0``return x ** y` | *装饰方法*`def disallow_negative(func):``def inner(*args):``for arg in args:``if arg < 0:``return 0``return func(*args)``return inner``@disallow_negative``def power(x, y):``return x ** y` |

装饰器方法将条件和测试一起放在装饰器中。这种方法意味着装饰器是完全独立的;想要使用它的函数不需要包含任何超出自己实现所需的逻辑。

这两种实现在行为上没有区别;然而,decorator 版本将所有的复杂性转移到了 decorator 定义本身,让用户函数自由。一般来说,decorators 由多个函数使用,所以这种模式允许使用干净、容易理解的代码。

关闭

装饰者依赖于一种叫做闭包的语言特性,这是变量作用域的一个有点复杂的结果。在 Python 中,当一个函数使用内部变量时,这些变量只能在该函数中通过名称获得:尽管它们的值可能会被返回,但是当执行从该函数传递过来时,该变量的内部名称的绑定会丢失。

def example(x, y):
    a = x + y
    b = x * y
    c = b * a
    print(f"a: {a}, b: {b}, c: {c}")
    return c

>>> result = example(1, 2)
a: 3, b: 2, c: 6
>>> print(result)
6

在执行example(...)函数的过程中,变量xy是传递给函数的参数。随着执行的继续,变量abc被逐步定义。一旦执行通过返回函数传递回包含范围,所有这些变量关联都将丢失。仅保留曾经与c关联的,然后将其存储在包含范围的结果变量中。

然而,如果我们在这个函数中定义了一个函数并返回它,那么这个内部函数必须仍然可以访问它需要执行的所有变量。只要还需要这些变量,解释器就不会解除它们的关联。在外部函数作用域中定义的、被内部函数使用的任何变量都将把它们的关联传递到这个新函数中, 6 ,并且这些变量仍然对内部函数可用,但对任何其他函数都不可用。这个内部函数被称为闭包

def example(x, y):
    a = x + y
    b = x * y
    c = b * a
    print(f"a: {a}, b: {b}, c: {c}")
    def get_value_of_c():
        print(f"Returning c: {c}")
        return c
    return get_value_of_c

>>> getter = example(1, 2)
a: 3, b: 2, c: 6
>>> print(getter)
<function example.<locals>.get_value_of_c at 0x034F96F0>
>>> print(getter())
Returning c: 6
6

在这个例子中,变量 c 与get_value_of_c()函数相关联,因此在调用该函数时可以返回它。当我们调用get_value_of_c()函数时,它可以访问示例中的变量c,但不能访问变量ab,因为它不使用它们。

修改父范围中的变量

有可能比这更进一步,编写复杂的函数集,对其包含范围内的变量进行操作,可能会改变它们的值。我想不出我什么时候需要过这个功能,但是理解变量作用域是如何工作的是很有帮助的。

为了实现这一点,我们需要使用非本地关键字。虽然 Python 可以推断出,如果使用了变量的值,就应该从包含变量的范围中提取变量,但是它不能推断出设置变量是试图修改外部变量还是创建新变量。假设您正在创建一个新变量,它隐藏了外部变量, 7 ,就像函数可以隐藏其模块全局范围内的名字一样。

一对函数通过闭包对它们共享的变量进行操作。

def private_variable():
    value = None
    def set(new_value):
        nonlocal value
        value = new_value

    def get():
        return value
    return set, get

>>> a_set, a_get = private_variable()
>>> b_set, b_get = private_variable()

>>> print(a_get, a_set)
<function private_variable.<locals>.get at 0x034F98E8> <function private_variable.<locals>.set at 0x034F9660>
>>> print(b_get, b_set)
<function private_variable.<locals>.get at 0x034F9858> <function private_variable.<locals>.set at 0x034F97C8>

>>> a_set(10)
>>> print(f"a={a_get()} b={b_get()}")
a=10 b=None

>>> b_set(4)
>>> print(f"a={a_get()} b={b_get()}")
a=10 b=4

这证明了可以编写一个包含函数的函数,并且内部函数可以使用外部函数中定义的数据。装饰者更进一步,将外部和内部函数共享的数据作为第三个函数,也就是被扩展的函数。

基础装修工

最简单的装饰函数是对它所装饰的函数没有影响的函数。如清单 5-6 所示。在这个例子中,函数outer()将一个用户函数作为参数func=,并返回一个名为inner(...)的函数作为结果。这使得@outer成为装饰函数,其行为由inner(...)定义。函数 inner 是一个闭包,所以它可以访问outer(...)函数的func=参数。这个变量是原始函数,所以inner(...)可以用它接收到的相同参数调用它,并将其结果返回给被修饰的函数。

def outer(func):
    print(f"Decorating {func}")
    def inner(*args, **kwargs):
        print(f"Calling {func}(*{args}, **{kwargs})")
        value = func(*args, **kwargs)
        print(f"Returning {value}")
        return value
    return inner

@outer
def add_five(num):
    return num+5

Listing 5-6A decorator that does nothing but prints the variables it uses internally

一旦该代码被解释,行Decorating <function add_five at 0x034F9930>被打印。如果它存储为一个模块,它会在模块导入后立即显示。这表明装饰器中的outer(...)函数的内容是在解析函数时运行的,而不是在执行函数时运行的。

如果我们在一个交互式会话中使用它,我们可以看到,add_five(...)函数已经被 inner 函数所取代,但是它仍然以同样的方式工作,尽管增加了额外的打印。

>>> print(add_five)
<function outer.<locals>.inner at 0x034F9A50>
>>> add_five(1)
Calling <function add_five at 0x034F9930>(*(1,), **{})
Returning 6
6

inner函数使用*args**kwargs作为它的参数来接受任意数量的参数,并将它们传递给func。我们在这里写的装饰器不会改变参数,所以innerfunc的函数签名需要兼容。如果inner(...)定义了不同于func的参数,那么这个装饰器就不能使用。

Tip

通常包装函数需要访问至少一个传递给内部函数的参数,但是不加改变地传递它们。在这种情况下,我建议尝试精确匹配函数参数,而不是尝试从*args**kwargs中提取值。这避免了在从argskwargs中寻找正确值时引入的任何错误。

有时我们希望创建一个装饰器来操纵参数,例如,填充一个或多个参数而不需要调用方传递它们,或者删除一个或多个底层函数不期望的参数。通过这种方式,decorators 可以用来改变函数签名。能够更改签名允许我们编写装饰器来为程序员简化 API,同时仍然为应用的其他部分匹配更复杂的签名。

例如,sorted(...)标准库函数曾经有一个可选的cmp=参数和一个key=参数。Python 3 中删除了cmp=参数,所以移植到 Python 3 的旧代码有时需要更新。

这两种方法完全不同;将编写为 cmp 函数的代码转换为等价的键函数并不容易。标准库中的functools模块包含一个cmp_to_key函数,它可以用作装饰器并执行这种转换。

有争论的装饰者

还有一种更常见的装饰器形式,它将另一个嵌套函数添加到组合中。这种形式是迄今为止最难以理解的,但它是我们目前所看到的代码的逻辑结果。最后一种形式是一个接受直接参数的装饰器。

使用 decorator 的语法是在函数或类的上一行添加@decorator,这相当于在函数被定义后添加行function = decorator(function)

当使用接受参数的装饰器时,提供这些参数的格式是@decorator(arg),可以重写为function = decorator(arg)(function)。也就是说,装饰函数不再是decorator(...)本身,而是decorator(arg)的返回值。如清单 5-7 所示。

def add_integer_to_all_arguments(offset):
    def decorator(func):
        def inner(*args):
            args = [arg + offset for arg in args]
            return func(*args)
        return inner
    return decorator

@add_integer_to_all_arguments(10)
def power(x, y):
    return x ** y

@add_integer_to_all_arguments(3)
def add(x, y):
    return x + y

Listing 5-7A simple decorator that takes an argument

这些修饰函数的所有参数都增加了一个偏移量,但是每种情况下的偏移量都是不同的,因为修饰函数的参数定义了偏移量。

>>> print(power)
<function add_integer_to_all_arguments.<locals>.decorator.<locals>.inner at 0x00B0CBB8>
>>> power(0, 0)
10000000000
>>> print(add)
<function add_integer_to_all_arguments.<locals>.decorator.<locals>.inner at 0x00B0CC48>
>>> add(0,0)
6

Tip

有一个装饰器可以帮助编写用户友好的装饰器。用@functools.wraps(func)修饰内部函数可以确保,如果用户试图查看文档、帮助,甚至是被修饰函数的名称,他们看到的信息与未修饰版本的相同。

如果我们早些时候在inner(...)函数上使用了这个装饰器,那么终端会话应该如下所示:

>>> print(power)
<function power at 0x00B0CCD8>
>>> power(0, 0)
10000000000
>>> print(add)
<function add at 0x00B0CB70>
>>> add(0,0)
6

嵌套三个函数可能很难保持头脑清晰,特别是因为有两个层次的闭包,一个提供offset变量,另一个提供func。这种语法是一种令人困惑的嵌套逻辑,通常应该避免。在极少数需要这种装饰器的情况下,开发人员通常会通过查阅文档来提醒自己正确的语法。

三重嵌套函数的一种替代方法是使用基于类的装饰器(清单 5-8 ,它看起来更像标准 Python,所以一看就更容易理解。这是因为一个类定义了一个__init__(...)函数来接受实例化的参数,并且可以提供一个__call__(...)方法来允许类被直接调用,就像一个函数一样。它遵循与本章前面的私有变量示例相同的模式;在函数使用一个变量之前,使用一个闭包来长时间存储这个变量是不好的做法。类实例更适合这种情况。

class add_integer_to_all_arguments:
    def __init__(self, offset):
        self.offset = offset

    def __call__(self, func):
        def inner(*args):
            args = [arg + self.offset for arg in args]
            return func(*args)
        return inner

Listing 5-8A class-based version of an offset decorator

基于类的装饰器和基于多重嵌套函数的装饰器在功能上是等价的;但我发现基于课堂的方法更自然,也更容易记忆。

基于装饰的安全性

现在我们已经看了装饰者是如何工作的;我们可以用它来检查函数中授权的 API 访问。Flask 视图函数不需要参数;HTTP 请求数据存储在一个全局变量中,所以我们编写的装饰器不需要处理任何参数。我们不必担心参数与函数的匹配,因为很少有 Flask 视图函数接受参数。

但是,我们确实需要确保函数的返回值是类型注释所允许的。Flask 支持从视图函数返回响应的多种不同方式。在 JSON 响应的情况下,响应的主体可以作为字符串或字典返回。这个函数可以返回主体或者一个(body, status)元组,或者(body, headers),或者(body, status, headers),等等。这种灵活性使打字变得更加复杂。 8

清单 5-9 显示了一个不做任何事情的 flask 视图的类型化装饰器。这是一个泛型函数,就像我们将 Sensor 定义为泛型类一样。装饰器@outer将一个不需要参数的函数作为参数,并返回某个东西。装饰器的返回值是一个不带参数的函数,返回的内容与参数函数相同。

import functools
import typing as t

ViewFuncReturn = t.TypeVar("ViewFuncReturn")

def outer(func: t.Callable[[], ViewFuncReturn]) -> t.Callable[[], ViewFuncReturn]:

    @functools.wraps(func)
    def wrapped() -> ViewFuncReturn:
        return func()

    return wrapped

Listing 5-9A decorator for a flask function

类型变量是被修饰函数的返回值的占位符。如果该函数被声明为返回一个字符串,那么字典将被视为等效于

def outer(func: t.Callable[[], str]) -> t.Callable[[], str]:

    @functools.wraps(func)
    def wrapped() -> str:
        return func()

    return wrapped

如果同一个函数正在装饰一个返回元组(dict, int)的视图,那么装饰器将会匹配它。

我们想要创建一个装饰器来检查被认证的用户。其代码如清单 5-10 所示。如果用户通过了身份验证,我们希望正常使用该函数。如果不是,那么装饰器应该返回一个错误值。适当的错误应该是一个 JSON 文档,其中包含错误详细信息和状态 403 禁止。因此,包装函数必须声明为返回底层函数将返回的任何内容或t.Tuple[t.Dict[str, str], int]

from hmac import compare_digest
import functools
import os
import typing as t

import flask

ViewFuncReturn = t.TypeVar("ViewFuncReturn")
ErrorReturn = t.Tuple[t.Dict[str, str], int]  # The type of response we # generate as an error

def require_api_key(
    func: t.Callable[[], ViewFuncReturn]
) -> t.Callable[[], t.Union[ViewFuncReturn, ErrorReturn]]:
    """ Check for the valid API key and return an error if missing. """

    api_key = os.environ.get["APD_SENSORS_API_KEY"]

    @functools.wraps(func)
    def wrapped(*args, **kwargs) -> t.Union[ViewFuncReturn, ErrorReturn]:
        """ Extract the API key from the inbound request and return an error if no match """

        headers = flask.request.headers
        supplied_key = headers.get("X-API-Key", "")

        if not compare_digest(api_key, supplied_key):
            return {"error": "Supply API key in X-API-Key header"}, 403

        # Return the value of the underlying view
        return func(*args, **kwargs)

    return wrapped

Listing 5-10Authentication decorator for flask API methods

其结果是,require_api_key装饰器改变它所装饰的函数,以返回与func返回的 9 相同的类型的数据,或者一个包含字符串到字符串字典和整数的元组。

*该函数实现权限检查的方式如下。首先,我们从环境中提取我们正在寻找的 API 密钥,命名为APD_SENSORS_API_KEY。这里没有回退到默认值,这部分装饰器代码在启动时执行,所以如果没有设置 API 键,程序会以KeyError失败。

接下来是包装了原始func()函数的函数定义,称为wrapped()。这个包装函数被定义为返回ViewFuncReturnErrorReturn

EXERCISE 5-1: TYPING

本节中的类型定义非常复杂;很难理解正在发生的事情。我建议您尝试编写一些简单的函数,并用 mypy 检查它们,以便直观地了解这里发生了什么。

你可以从清单 5-11 中的基础程序开始,尝试改变hello()函数的类型、ErrorReturn类型以及 hello 函数是否有@result_or_number装饰器。这可能是一个更容易的开始,因为返回类型比实际的 Flask 函数要简单得多。

import functools
import random
import typing as t

ViewFuncReturn = t.TypeVar("ViewFuncReturn")
ErrorReturn = int

def result_or_number(
    func: t.Callable[[], ViewFuncReturn]
) -> t.Callable[[], t.Union[ViewFuncReturn, ErrorReturn]]:

    @functools.wraps(func)
    def wrapped() -> t.Union[ViewFuncReturn, ErrorReturn]:

        pass_through = random.choice([True, False])
        if pass_through:
            return func()
        else:
            return random.randint(0, 100)

    return wrapped

@result_or_number
def hello() -> str:
    return "Hello!"

if t.TYPE_CHECKING:
    reveal_type(hello)
else:
    print(hello())

Listing 5-11Sample file for experimenting with decorator typing

这个wrapped函数的主体是实际工作发生的地方。所提供的 API 键是从 flask 请求头中读取的,这些请求头在 flask 框架中作为全局状态被访问,因此这些函数中不包含请求参数。请求提供的密钥从X-API-Key头中读取,如果没有提供头,则默认值为空字符串。

空字符串 default 在这里,因为在下一行中,对compare_digest的调用用于比较收到的和预期的 API 键。这是一个字符串比较函数,适用于比较已知长度的身份验证字符串,如 HMAC 摘要。 10 从理论上讲,使用标准比较可能会通过返回错误所需的时间来泄露关于正确 API 键的信息,所以最好使用恒定时间比较。这个compare_digest函数仍然会泄漏关于秘密字符串长度的信息。虽然在这种情况下这不是一个严重的问题,但是这个问题很容易解决,没有理由不使用安全的比较函数。

最后,根据compare_digest函数的结果,我们要么委托给原始函数,要么返回股票错误响应。

传感器端点代码

@app.route("/sensors/")
@require_api_key
def sensor_values() -> t.Tuple[t.Dict[str, t.Any], int, t.Dict[str, str]]:
    headers = {"Content-Security-Policy": "default-src 'none'"}
    data = {}
    for sensor in get_sensors():
        data[sensor.title] = sensor.value()
    return data, 200, headers

这里,我们之前创建的 sensor view 函数用我们新的@require_api_key decorator 进行了修饰,API 键检查将自动发生。需要注意的是,这里的装饰者是有序的;它们是自下而上应用的,底部装饰器的输出成为上面那个的输入。

def sensor_values():
    ...
sensor_values = app.route("/sensors/")(require_api_key(sensor_values))

装饰器将函数与 flask URL 路由系统联系起来。它所修饰的函数与 URL 相关联;运行时不查找函数。虽然这种差异听起来很学术,但这意味着只有装饰者app.route(...)下面的装饰者会被应用到 Web 上可用的功能中。

如果以相反的顺序应用这些装饰器,那么在这个视图上就不会有 API 键验证。这是我们回到功能测试的地方;从单元测试中直接调用函数并不能通过 flask 视图注册表找到它,这可能会使程序员认为视图受到了正确的保护。重要的是要端到端地测试安全特性,而不仅仅是孤立地测试。

测试视图功能

我们已经有了一个基本的测试来查看传感器数据是否通过使用 WebTest 框架的 API 请求返回,但是我们通过添加 API key validator 打破了这个测试。如果在您的环境中没有设置 API 密匙,并且您运行了pipenv run pytest,,那么测试失败,并显示KeyError。如果您已经在本地环境中设置了一个 API 键,那么它会失败并出现一个Forbidden错误。

谈到可测试性,我们在 decorator 函数中犯了一个小小的判断错误。如前所述,预期的 API 键是在导入时加载的,如果没有设置 API 键,这会导致启动时出错。然而,导入时数据加载会增加测试代码的难度。我们希望使用一个已知的 API 键设置来运行测试,但是要做到这一点,我们需要确保在包含视图函数的模块被导入的第之前,这个键被设置到环境中。

Flask 在应用上提供了一个 config 属性,可以用来存储配置数据,这是一个比在 decorator 闭包中存储预期的 API 键更明智的地方。这样,当 web 服务器启动时,配置数据仍然可以被加载,或者测试框架可以为任何特定于测试的配置提供它。

Flask 假设配置数据是从 Python 文件加载的,这可能会诱使我们将apd.sensors包的配置系统更改为相同的模式,但是因为我们只需要添加一个配置变量,所以我们将坚持使用现有的环境变量模式。

最好的方法是创建一个设置函数,用来自环境的信息填充烧瓶配置。对 API 关键配置变量的检查在这里显式地发生,因为我们不得不在装饰器中移除对os.environ的检查以支持测试。显式检查通常比导致KeyError的隐式需求更容易理解,这应该有助于让我们确信这是一种更好的方法。如果这里没有显式的检查,那么在第一次加载受保护的视图之前,不会检查 API 键。

设置功能

REQUIRED_CONFIG_KEYS = {"APD_SENSORS_API_KEY"}

def set_up_config(environ: t.Optional[t.Dict[str, str]] = None) -> flask.Flask:
    if environ is None:
        environ = dict(os.environ)
    missing_keys = REQUIRED_CONFIG_KEYS - environ.keys()
    if missing_keys:
        raise ValueError("Missing config variables: {}".format(", ".join(missing_keys)))
    app.config.from_mapping(environ)
    return app

Note

这里的REQUIRED_CONFIG_KEYS变量被设置为 set 文字,而不是 dict 文字。集合字面量看起来非常类似于字典字面量,集合理解和字典理解也是如此。不同的是缺少:value

然后可以修改测试设置,用适当的测试配置值调用这个设置函数。我们创建一个新的 fixture 来提供测试 API 键,它可以是硬编码的或者随机的, 11 然后将主题 fixture 更改为依赖于这个 API 键 fixture,并将其值作为显式设置传入。

import pytest
from webtest import TestApp

from apd.sensors.wsgi import app, set_up_config
from apd.sensors.sensors import PythonVersion

@pytest.fixture
def api_key():
    return "Test API Key"

@pytest.fixture
def subject(api_key):
    set_up_config({"APD_SENSORS_API_KEY": api_key})
    return app

@pytest.fixture
def api_server(subject):
    return TestApp(subject)

如果测试授权访问的行为,单个测试将需要依赖于api_key fixture,或者使用 WebTest 框架的expect_errors选项来检查错误响应,而不需要用try / except块包围 get 请求。

API 端点的示例测试

@pytest.mark.functional
def test_sensor_values_fails_on_missing_api_key(api_server):
    response = api_server.get("/sensors/", expect_errors=True)
    assert response.status_code == 403
    assert response.json["error"] == "Supply API key in X-API-Key header"

@pytest.mark.functional
def test_sensor_values_returned_as_json(api_server, api_key):
    value = api_server.get("/sensors/", headers={"X-API-Key": api_key}).json
    python_version = PythonVersion().value()

    sensor_names = value.keys()
    assert "Python Version" in sensor_names
    assert value["Python Version"] == list(python_version)

这些测试验证了 API 服务器正在按预期工作,所以在这个阶段,可以安全地剪切一个新发布的apd.sensors包,它记录了这个新的 API 服务器,这样我们就可以将它安装在我们的 Raspberry Pi 服务器上。

新版本在不破坏向后兼容性的情况下增加了一个新特性,这再次意味着我们增加了次要版本号,这是第一个支持 web API access 1.3.0 的版本。

部署

我们现在有了一个可用的 API 端点,我们可以使用python -m apd.sensors.wsgi在本地作为测试,或者我们可以通过生产质量的 WSGI 服务器,比如女服务员来提供服务。为此,我们需要安装 want,并给它一个我们想要运行的 WSGI 应用的引用。还有许多其他的 WSGI 服务器,比如 mod_wsgi,它与 Apache 紧密集成;Gunicorn,这是一个单机应用,对性能的控制很好;Circus 和 Chaussette,包括过程管理和对工人的细粒度控制;还有 uWSGI,以性能好著称。

我们使用的是 waste,因为它有一个简单的接口,并且是用纯 Python 实现的,没有编译扩展,所以它可以安装在各种操作系统上。

> pipenv install waitress
> pipenv run waitress-serve --call apd.sensors.wsgi:set_up_config

缺省情况下,API web 服务在端口 8080 上提供服务,但是可以使用任何端口或 UNIX 套接字对其进行配置。如果要在可通过互联网访问的机器上运行,而不是在本地网络上运行,您应该考虑为您的部署设置一个 TLS 终止反向代理,如 apache、nginx 或 HAProxy。默认情况下,现代网络是加密的,用户只希望通过安全连接访问服务。幸运的是,有多种方法可以为您的域名获得免费的 TLS 证书。LetsEncrypt 和 AWS 证书管理器可能是最常见的。

在前面的例子中,apd.sensors.wsgi:set_up_config使用了与我们在命令行参数中使用的相同的点路径和冒号语法来定义入口点。我已经将它指向了set_up_config(...)函数,它本身不是一个 WSGI 可调用函数。多亏了--call选项,这才成为可能,这意味着目标不是一个 WSGI 应用,而是一个 WSGI 应用工厂:一个返回已配置的 WSGI 应用的可调用程序。

我们的 flask 应用是在模块范围内实例化的;我们可以用pipenv run waitress-serve apd.sensors.wsgi:app直接引用它,但是这不会像预期的那样工作,因为配置变量不会被设置。通过从我们的set_up_config函数返回模块范围app对象,我们使它像一个工厂一样工作,并确保配置变量被加载。

set_up_config(...)函数像app一样修改全局作用域的值,而不是返回一个独立的应用,所以它不是一个真正的工厂。然而,由于它的签名是相同的,我们每次只需要一个app,我们可以滥用这个特性。

用户编写一个定制的wsgi.py文件来设置他们的 WSGI 应用也是很常见的,潜在地将它包装在任何提供额外功能的中间件中。如果我们为这个 API 服务器这样做,它看起来会像

wsgi.py

from apd.sensors.wsgi import set_up_config
app = set_up_config()

启动服务器

> pipenv run waitress-serve wsgi:app

作为第三方扩展软件

我们在本章中所做的一切都不涉及改变apd.sensors包的 API,所以我们在核心包中创建的 API 服务器同样可能是由软件的核心维护者之外的人创建的。任何人都可以编写一个 WSGI 服务器来公开传感器值,并创建一个新的包,比如说apd.apiserver,它加载传感器并提供一个 API 端点来查询它们的值。

Note

下一节,直到“修复我们代码中的序列化问题”标题,将考虑其他开发人员在尝试扩展我们的代码和他们可以使用的工具时的体验。在此之后,我们将回到我们自己可以改进的地方。

然而,有时我们确实需要改变接口来扩展一个软件。如果我们回头看看我们的Temperature传感器,我们很早就做出了一个让 JSON 序列化变得微不足道的决定。value 函数返回一个浮点数,表示以摄氏度为单位的温度。JSON 可以序列化整数、字符串、列表和字典,但不能序列化日期时间或自定义对象。有一个名为 pint 的包,它专门表示物理常数,我们可能会选择使用 12 ,在这种情况下,温度传感器的值将不会是可序列化的。

Pint 没有声明支持类型注释,因为它使用元类和从数据文件动态构造类型,很难提供一组有用的类型给最终用户。可以理解的是,pint 的开发者选择关注终端用户在控制单元集方面的灵活性,而不是针对类型检查进行优化。

使用品脱值作为其返回类型的传感器

import os
from typing import Optional, Any

import pint

ureg = pint.UnitRegistry()

class Temperature(Sensor[Optional[Any]]):
    title = "Ambient Temperature"

    def __init__(self, board=None, pin=None):
        self.board = os.environ.get("APD_SENSORS_TEMPERATURE_BOARD", "DHT22")
        self.pin = os.environ.get("APD_SENSORS_TEMPERATURE_PIN", "D4")

    def value(self) -> Optional[Any]:
        try:
            import adafruit_dht
            import board
            sensor_type = getattr(adafruit_dht, self.board)
            pin = getattr(board, self.pin)
        except (ImportError, NotImplementedError, AttributeError):
            # No DHT library results in an ImportError.
            # Running on an unknown platform results in a
            # NotImplementedError when getting the pin
            return None
        try:
            return ureg.Quantity(sensor_type(pin).temperature, ureg.celsius)
        except RuntimeError:
            return None

    @classmethod
    def format(cls, value: Optional[Any]) -> str:
        if value is None:
            return "Unknown"
        else:
            return "{:.3~P} ({:.3~P})".format(value, value.to(ureg.fahrenheit))

    def __str__(self) -> str:
        return self.format(self.value())

由于 pint 没有声明支持类型检查,这些函数被定义为返回Any,这意味着它们不容易进行类型检查。在搜索类型定义时,我们还需要将 pint 添加到setup.cfg中作为一个被忽略的模块,以消除找不到它的警告:

添加到 setup.cfg 的代码

[mypy-pint]
ignore_missing_imports = True

METACLASSES

我前面提到过,Pint 使用元类和动态类型构造。这是一些相关的技术:它们都是定制类本身行为的方式,而不仅仅是它们的实例。在 pint 中,这些方法用于添加一个额外的钩子after_init(...),它在__init__(...)函数之后被自动调用,并用于创建引用不同类变量的某些内置类型的无限数量的子类。

本书的一些读者将期待关于元类使用的广泛讨论,将它们视为高级 Python 特性的缩影。我决定省略它们,因为这本书旨在解释专业 Python 程序员可以从中受益的特性。

在我作为 Python 开发者的全部时间里,我从来没有理由创建一个元类,或者在我编写的类中显式地使用一个元类。我确实经常通过基类隐式地使用它们。虽然只有很少一部分 Python 开发人员需要创建元类,但是大多数开发人员在不知道的情况下与元类进行交互。

Python 标准库模块enum和 ORM SQLAlchemy 是我所知道的良好元类使用的最好例子。两者都大量使用了元类,但是他们各自开发人员的技能保持了非常直观的界面,而牺牲了他们自己实现的可读性。如果你把元类做对了,用户甚至不会知道它们的存在。

大多数关于元类的建议是,除非你知道你需要元类,否则你不需要使用它们。这有点绕圈,所以参考图 5-3 中的决策树,当我决定是否需要使用元类时,我会使用它。

img/481001_1_En_5_Fig3_HTML.jpg

图 5-3

用于决定是否使用元类的决策树

这并不详尽,只是我对何时考虑元类的看法。可能有其他情况下它们是合适的解决方案,但是一般来说,我会认为它们仅仅是用于声明性地向框架公开用户数据的结构。元类的大多数其他用途可以更直观地表示为标准 Python。我强烈建议优先编写一看就能理解的代码,而不是聪明的 ?? 代码。

float 和基于 pint 的实现之间的第一个实质性区别来自于value()函数,它采用温度的浮点表示,并将其标记为摄氏度的Quantity。与动态调度允许整数和字符串的加法表现不同的方式一样,它允许开发人员忘记正在使用的温度单位的确切类型,并对所有温度一视同仁。

想象一下,我们也有一个连接到智能家庭恒温器的温度传感器,它只能返回华氏温度。我们很可能想要显示每个温度传感器和这个中央传感器之间的差异。如果我们使用浮点单位,我们必须在收集数据时将所有温度传感器标准化为相同的单位系统,或者在报告时使用外部知识,即一些读数使用不同的温标。Pint 允许我们无缝地处理来自不同测量系统的数字,而不需要显式转换。

我们可以在format(...)方法中看到这一点,我们没有调用自定义类方法将摄氏温度转换为华氏温度,而是使用了 pint 本身的转换特性。cls.celsius_to_fahrenheit(value)变成了value.to(ureg.fahrenheit),从而将逻辑分为收集和格式化。在原始形式中,format 方法要求其值为摄氏度;在后一种形式中,它可以根据值本身来判断需要进行什么转换(如果有的话)。

{:.3~P} FORMAT SPECIFICATION

Python 中的"{}".format(value)样式格式允许值函数的类型定义自己的格式规范。Python 没有内置的.3~P规范:这是由 Pint 提供的。

__format__(self, spec)方法允许类定义自己的格式规则。Pint 分别为 LaTeX、HTML 和 PrettyPrint 提供了LH和 P 格式器,还有一个可选的~来使用缩写的单元名和标准的浮点格式选项来指定幅度部分。

您编写的任何类也可以提供这些,因此我们的传感器可以定义一个__format__(...)方法来提供不同的格式化选项(如果相关的话)。一般来说,这个特性只对像 Pint 这样提供复杂数据存储类供其他程序员使用的项目有用。

然而,所有这些优势都是有代价的。当我们试图访问 JSON API 时,我们看到一个 HTTP 错误 500 页面,并且在 web 服务器的日志中,我们得到一个以

TypeError: Object of type Quantity is not JSON serializable

为了让value()方法更加灵活,我们打破了在 flask 应用中所做的隐含假设:函数value()的结果可以被 JSON 序列化。在任何以前版本的文档中,我们都没有提到 value 方法只能返回 JSON 可序列化的类型。不能保证我们软件的其他用户没有使用插件架构做过类似的事情,所以在没有意识到的情况下,我们已经违反了语义版本化政策。

我们应该创建一对新的方法,在传感器返回值和 JSON 可序列化表示之间进行转换,使Sensor类看起来像

更新传感器类型,包括 JSON 序列化

class Sensor(Generic[T_value]):
    title: str

    def value(self) -> T_value:
        raise NotImplementedError

    @classmethod
    def format(cls, value: T_value) -> str:
        raise NotImplementedError

    def __str__(self) -> str:
        return self.format(self.value())

    @classmethod
    def to_json_compatible(cls, value: T_value) -> t.Any:
        return json.dumps(value)

    @classmethod
    def from_json_compatible(cls, json_version: t.Any) -> T_value:
        return json.loads(value)

一对to_json_compatible(...)from_json_compatible(...)方法负责将值转换成可以序列化的表示形式,然后再转换回来。它们是类方法,因为像format(...)一样,它们对值进行操作,而不需要那种类型的活动传感器可用。这些方法将用户推向返回 JSON 结构,这与我们的 API 输出非常吻合。

API 的这一更新可以作为标准Sensor类的一部分,或者可以创建一个子类(如SerializableSensor 13 ),允许用户选择只实现传感器 API 的旧版本。

然而,在本节的开始,我们决定考虑如果我们将这个 API 服务器创建为第三方软件,并且无权更改Sensor类型的形式,会发生什么。因此,我们不能简单地决定改变传感器接口:如果我们在现实生活中处于这种情况,它将在我们无法控制的包中,其他人将实现该接口。

与对等方就临时签名达成一致

作为一名开发人员,如果想用自己不维护的代码来扩展一个接口,首先要确定的是,你觉得这个接口缺少了软件开发者定义的功能。作为一个最终用户,你可以添加任何你喜欢的函数到你的子类中,但是你不能轻易地命令类的其他作者实现相同的函数。

当决定将哪些功能添加到接口时,您应该选择其他开发人员可能认为有用的功能。如果您选择既容易实现又普遍有用的函数,其他类作者更有可能选择实现它们。如果你选择了非常具体的方法,那么他们可能会认为不值得努力。

因此,to_json_compatible(...)from_json_compatible(...),尽管是我们选择的软件维护者,可能会被其他开发人员认为太具体了。我认为一对serialize(...)deserialize(...)方法更有可能被实现。

我们可以编写 flask 函数来迭代传感器,如果可能的话,使用serialize(...)方法,否则返回到值。我们可以假设传感器的serialize(...)方法在传递有效数据时不会引发任何异常,但是我们知道不是所有的传感器都有这个方法,并且json.dumps(...)会因为一些传感器数据而失败,所以我们还需要通过三种方法来序列化值。

首先,我们从传感器获取值,并将其传递给serialize(...)方法。如果用AttributeError失败了,那么很可能没有serialize(...)方法,所以用json.dumps(...)再试一次。如果这个操作因TypeError而失败,那么我们就不能序列化这个传感器,应该返回一个占位符。

一个如何逐步支持一个 序列化的例子(...)方法 :

for sensor in get_sensors():
    raw_value = sensor.value()
    try:
        value = {"serialized": sensor.serialize(raw_value)}
    except AttributeError:
        try:
            value = {"serialized": json.dumps(raw_value)}
        except TypeError:
            value = {"error": f"Cannot serialize value {raw_value}"}
    data[sensor.title] = value

这将允许所有现有的传感器继续运行,而无需任何代码更改,只要它们的值是 JSON serializable,否则将返回一个错误。任何实现serialize(...)方法的传感器都将返回该方法的结果。

这种两个嵌套的 try/except 语句的情况有点难看,但很实用。在其他编程语言中,您可能通过检查serialize(...)方法的存在来实现相同的逻辑,而不是试图调用它。在 Python 中,尝试和调用方法并捕捉错误比检查它们是否存在更好;然而,有些时候检查是最好的选择。

前面的例子仍然有一个潜在的故障模式。很有可能有人实现了一个serialize()方法,但是没有实现deserialize(...)方法,这是由于传感器 API 的一些其他流行消费者的要求。在这种情况下,我们最好还是使用value()方法,因为我们无法保证能够得到真实的值来进行分析。在这种情况下,我们必须检查这两种方法是否存在,而不是只尝试使用我们现在需要的一种方法。

for sensor in get_sensors():
    raw_value = sensor.value()
    if hasattr(sensor, "serialize") and hasattr(sensor, "deserialize"):
        value = {"serialized": sensor.serialize(raw_value)}
    else:
        try:
            value = {"serialized": json.dumps(raw_value)}
        except TypeError:
            value = {"error": f"Cannot serialize value {raw_value}"}
    data[sensor.title] = value

当然,您的代码可能希望查找更复杂的方法和变量集合,以确定特定的功能集合是否存在。比如说does_sensor_support_serialization(sensor: Sensor[Any]) -> bool,你可能会试图将这种内省整合成一个函数,并将其作为条件。这两种情况下代码路径出现分歧的次数越多,就越有诱惑力。

Python 有一个称为抽象基类(ABCs)的特性,可以用来使这种类自省在 Python 上下文中感觉更自然。经常使用的一种类型的类内省是检查一个对象是否是一个特定类或其子类的实例;ABCs 允许你用isinstance(...)调用代替更复杂的类内省。

抽象基类

抽象基类是一种特殊类型的类;它们不能直接实例化,但可以用作代码的父类。他们还可以“声明”其他类,以便将它们视为子类,方法是将它们显式注册为虚拟子类,或者编写一个函数来检查一个类,以确定它是否应该被视为子类。

ABC 是另一个 Python 特性,人们通常认为它特别先进,因为他们以前没有理由使用它们。大多数开发人员没有使用过 ABC 是有道理的,因为它们对于常规的面向对象软件工程实践不实用的情况特别有用。一个有凝聚力的、统一的代码库通常没有理由使用 ABC,但是一个基于多个软件的庞大的应用可能会发现 ABC 是恰到好处的魔法,允许应用代码最小化技术债务的影响。

ABC 采取的方法是覆盖isinstance(...)issubclass(...)的逻辑。Python 中issubclass(...)的正常定义是,如果类定义将 B 列为父类,或者如果它列出的任何类本身都是 B 的子类,则类(A)是另一个(B)的子类。ABC 有两个额外的检查:issubclass(A, B)将返回 True,如果 A 是 B 的子类,如果在issubclass检查之前的任何时候调用了B.register(A),或者如果B.__subclasshook__(A)返回True

此外,更熟悉的isinstance(...)函数的工作方式类似,但是使用的是一个类的实例,而不是类本身。大多数 Python 开发人员认为isinstance(...)在某些情况下是自然的代码行,但是会回避检查特定的方法集,而是更喜欢使用 duck typing,即使以可读性为代价。

这是 ABC 最有用的地方;它们允许复杂的类自省以这样一种方式执行,这种方式对于不经意阅读代码的开发人员来说是自然的,对于熟悉代码的人来说是高度可维护的。

Note

任何从abc.ABC14 继承的类都遵循 ABCs 的特殊类规则,包括定制isinstance(...)行为的能力,但严格来说,如果一个类至少有一个用@abc.abstractmethod装饰器定义的抽象方法,那么它只是一个抽象基类。

对于我们来说,为序列化行为创建一个抽象基类是很有用的,这样可以避免在 flask 路径中显式地检查两个相关的方法。将序列化和反序列化所需的方法定义为抽象方法。 15

class SerializableSensor(ABC):

    @classmethod
    @abstractmethod
    def deserialize(cls, value):
        pass

    @classmethod
    @abstractmethod
    def serialize(cls, value):
        pass

然后,我们可以使用它,要么通过子类化这个 ABC,要么通过注册一个实现。这两种方法如表 5-2 所示。

表 5-2

创建被视为 SerializableSensor 的子类的 ExampleSensor 的两种方法的示例

| *子类化方法*`class ExampleSensor(``Sensor[bool],``SerializableSensor``):``def value(self) -> bool:``return True``@classmethod``def format(cls, value: bool) -> str:``return "{}".format(value)``@classmethod``def serialize(cls, value: bool) -> str:``return "1" if value else "0"``@classmethod``def deserialize(cls, serialized: str) -> bool:``return bool(int(serialized))` | *报名方式* 16`class ExampleSensor(Sensor[bool]):``def value(self) -> bool:``return True``@classmethod``def format(cls, value: bool) -> str:``return "{}".format(value)``@classmethod``def serialize(cls, value: bool) -> str:``return "1" if value else "0"``@classmethod``def deserialize(cls, serialized: str) -> bool:``return bool(int(serialized))``SerializableSensor.register(ExampleSensor)` |

每种方法都有优点和缺点。对于子类方法,父类提供助手函数或者默认实现serialize(...)deserialize(...)。对于注册方法,碰巧实现正确方法的类可以标记为子类,而不必修改它们。当这些类不在您控制的代码中时,例如依赖关系中的类,这尤其有用。您不需要在使用子类化或注册方法之间进行选择;您可以从 ABC 中创建子类,同时将其他类别注册为虚拟子类。

最后,最后一个可能的方法是子类 hook,其中不需要显式注册。为了实现这一点,我们向SerializableSensor类添加了一个新方法,它包含了确定一个类是否是SerializableSensor的逻辑。__subclasshook__类方法只接受一个参数,即要自省的类。

它可以返回TrueFalse来指定传递的类是否确实是 ABC 或NotImplemented的实例,以遵从正常的 Python 行为。NotImplemented选项是必不可少的,因为__subclasshook__不仅为SerializableSensor调用,也为任何将其声明为超类的类调用。返回NotImplemented可以避免为这些情况重新实现默认的 Python 逻辑。 17

    @classmethod
    def __subclasshook__(cls, C):
        if cls is SerializableSensor:
            has_abstract_methods = [hasattr(C, name) for name in {"value", "serialize", "deserialize"}]
            return all(has_abstract_methods)
        return NotImplemented

ABC 也支持类型注释,所以 ABC 的最终版本应该包含适当的注释,以允许直接从基类继承的任何类的静态类型。我们将把value(...)函数添加到基类的抽象方法列表中。我们还可以将SerializableSensor基类设置为 generic,它必须采用与其配对的传感器的子类型兼容的子类型。这允许我们在静态类型级别确保serialize方法支持与value(...)函数相同的类型:

from abc import ABC, abstractmethod

import typing as t

T_value = t.TypeVar("T_value")

class SerializableSensor(ABC, t.Generic[T_value]):

    title: str

    @abstractmethod
    def value(self) -> T_value:
        pass

    @classmethod
    @abstractmethod
    def serialize(cls, value: T_value) -> str:
        pass

    @classmethod
    @abstractmethod
    def deserialize(cls, serialized: str) -> T_value:
        pass

    @classmethod

    def __subclasshook__(cls, C: t.Type[t.Any]) -> t.Union[bool, "NotImplemented"]:
        if cls is SerializableSensor:
            has_abstract_methods = [hasattr(C, name) for name in {"value", "serialize", "deserialize"}]
            return all(has_abstract_methods)
        return NotImplemented

后备策略

使用 ABCs 清理了在传感器能够处理序列化其自身值和需要使用回退实现之间切换的 if 语句,但是它不能帮助我们实现现有的回退逻辑。

我们可以选择的各种序列化方法(包括 JSON)都提供了一对序列化和反序列化函数,通常称为dumps(...)loads(...)。如果用户愿意,我们可以提供 mixin 18 类供用户使用。

一个 JSON 回退 mixin 类的例子

class JSONSerializedSensor(SerializableSensor[t.Any]):

    @classmethod
    def serialize(cls, value: t.Any) -> str:
        try:
            return json.dumps(value)
        except TypeError:
            return json.dumps(None)

    @classmethod
    def deserialize(cls, serialized: str) -> t.Any:
        return json.loads(serialized)

这个类继承自SerializableSensor ,,所以它遵循 ABCs 的特殊类处理规则。SerializableSensor类声明valueserializedeserialize方法是必需的,但是我们只定义了其中的两个方法。这意味着JSONSerializedSensor仍然被认为是一个抽象基类,所以它不能被实例化。如果您试图实例化这个类,将会引发下面的TypeError:

TypeError: Can't instantiate abstract class JSONSerializedSensor with abstract methods value

适配器模式

JSONSerializedSensor超类提供了一种将 JSON 序列化方法添加到我们自己的类中的方法,但是如果我们安装了其他传感器,它就没有用了,因为我们不能仅仅编辑它们来使用超类。

解决这个问题的经典方法被称为适配器模式,是著名的四个软件工程模式之一。适配器是包装另一个对象以提供不同接口的对象。在这种情况下,我们可以为给定的传感器创建一个适配器,方法是将该传感器的一个实例存储为包装器实例的一个属性:

使用 JSONSerializedSensor 从 ExampleSensor 到 SerializableSensor 的适配器示例

class SerializableExample(JSONSerializedSensor):

    def __init__(self):
        self.wrapped = ExampleSensor()
        self.title = self.wrapped.title

    def value(self) -> bool:
        return self.wrapped.value()

serialize(...)deserialize(...)方法来自我们已经开发的JSONSerializedSensor,所以这个适配器模式允许我们使用 mixin 的实现作为我们的后备策略。对于SerializedSensor协议的任何其他部分实现也是如此,可能使用不同的序列化器。

我们可以动态创建传感器类型,而不是为每个类创建一个后备传感器类型。这些动态包装的传感器必须假设底层的值类型是Any,因为我们没有具体的保证我们将传递给它什么类型的传感器。

def get_wrapped_sensor(sensor_class: Sensor[t.Any]) -> SerializableSensor:
    class Fallback(JSONSerializedSensor):

        def __init__(self):
            self.wrapped = sensor_class()
            self.title = self.wrapped.title

        def value(self) -> t.Any:
            return self.wrapped.value()

    return Fallback

如果传感器不可序列化,我们用来迭代传感器并获取其值的代码现在可以更改为实例化此包装器:

for sensor in get_sensors():
    raw_value = sensor.value()
    sensor_class = type(sensor)
    if not issubclass(sensor, SerializableSensor):
        sensor_class = get_wrapped_sensor(sensor_class)

    value = {"serialized": sensor_class.serialize(raw_value)}
    data[sensor.title] = value

动态类生成

这种方法不能精确地映射到经典的设计模式,部分原因是它在编译语言中是不可能的。这个方法动态地定义了一个新类,它继承了原始的Sensor类和序列化 mixin,创建了一个具有两者行为的新类。只有当两个类实现之间的方法定义没有重叠时,这种方法才可靠。尽管如此,它的优点是派生类可以被视为一个直接实现序列化的传感器,因为format(...)__str__()方法仍然存在,而不是被包装器隐藏。

许多 Python 开发人员发现这是一个很难做出的选择,因为适配器模式更简单、更明确,而动态类生成方法依赖于语言的行为,以一种对最终用户不透明的方式来解析方法,但是对于不经意的观察者来说,动态类生成方法似乎更简单。

将 JSON 串行器实现合并到任意传感器的函数

def get_merged_sensor(sensor_class: Sensor[t.Any]) -> SerializableSensor:
    class Fallback(sensor_class, JSONSerializedSensor):
        pass

    return Fallback

然后,这个传感器类可以用在任何需要传感器的地方,以及任何需要可序列化传感器的地方。例如,我们可以提供一个get_serializable_sensors()方法,它复制了get_sensors()的实现,但是去掉了任何不可序列化的传感器。

def get_sensors() -> t.Iterable[Sensor[t.Any]]:
    sensors = []
    for sensor_class in pkg_resources.iter_entry_points("apd.sensors.sensors"):
        class_ = sensor_class.load()
        if not issubclass(class_, SerializableSensor):
            class _ = get_merged_sensor(class_)
        sensors.append(t.cast(Sensor[t.Any], class_()))
    return sensors

其他序列化格式

我们前面所有的例子都使用 JSON 协议,所以任何不提供显式序列化的类都不能与 JSON serializable 兼容。为此,我们需要使用更通用的序列化程序,比如 pickle。

Warning

您经常会看到这样的警告:pickle 不应该用于不可信的数据,因为它不安全。这一点至关重要,因为精心编制的 pickle 变量会导致任意代码执行。如果一个传感器不知何故受到损害或恶意,并返回序列化值c__builtin__\neval\n(V__import__("webbrowser").open(" https://advancedpython.dev/pickles ")\ntR.,那么当 API 消费者试图反序列化它时,这本书的网站将在 API 消费者的计算机上打开。

我认为在这种情况下使用 pickles 是不合适的,因为传感器类型很少,而且它们返回的数据相对简单。包含以下讨论是因为序列化是一个常见问题,并且经常建议使用 pickles。

一般来说,最好投入额外的工程努力来避免使用 pickles,但是如果您发现自己处于需要它们的情况下,您应该确保至少使用 HMAC 来认证它们,如表 5-3 所示。

表 5-3

签名和验证 pickle 的示例函数

| *签泡菜*`import hashlib``import hmac``import pickle``secret = bytearray([``0xb2,0x56,0xc4,0x88,0x09,0xa0,0x8a,0x1e,``0x28,0xe3,0xa3,0x25,0xe9,0x2b,0x98,0x6f,``0x13,0x60,0xfb,0x26,0x06,0x9b,0x9d,0x6f,``0x3a,0x01,0x2c,0x3f,0x9d,0x9f,0x72,0xcd``])``untrusted_pickle = pickle.dumps(2)``digest = hmac.digest(``secret,``untrusted_pickle,``hashlib.sha256``)``signed_pickle = digest + b":" + untrusted_pickle` | *验证签名*`import hashlib``import hmac``import pickle``secret = bytearray([``0xb2,0x56,0xc4,0x88,0x09,0xa0,0x8a,0x1e,``0x28,0xe3,0xa3,0x25,0xe9,0x2b,0x98,0x6f,``0x13,0x60,0xfb,0x26,0x06,0x9b,0x9d,0x6f,``0x3a,0x01,0x2c,0x3f,0x9d,0x9f,0x72,0xcd``])``digest, untrusted = received_pickle.split(``b":", 1``)``expected_digest = hmac.digest(``secret,``untrusted,``hashlib.sha256``)``if not hmac.compare_digest(digest, expected_digest):``raise ValueError("Bad Signature")``else:``value = pickle.loads(untrusted)` |

这个方案是对称的;任何可以验证 pickle 的人也可以为任意 pickle 创建一个有效的签名,但这通常对于封闭系统来说已经足够了。因为它是对称的,所以保持秘密不被公众所知是非常重要的。这个秘密通常存储在一个配置文件或环境变量中,因此对于代码的每个用户来说是不同的。使用非对称密钥的更复杂的签名是可能的,但是很少值得花费工程上的努力来创建一个定义好的 JSON(或其他)模式来安全地反序列化数据。

将这一切结合在一起

在我们的平行世界中,我们试图将 WSGI 服务器改造到现有的传感器生态系统中,我们现在已经拥有了所需的所有代码(清单 5-12 )。web 服务器代码的大部分与我们真实的集成 Flask 应用的代码相同;web 服务器代码中唯一显著的变化是在sensor_values()视图中添加了一个 if 语句和匹配的 else 子句,总共添加了三行视图代码。我们已经成功地将类自省和回退逻辑封装到支持代码中,这些代码可以拆分成一个实用的 Python 文件,让它发挥自己的魔力。

from abc import ABC, abstractmethod
import typing as t
import json

import flask

from apd.sensors.sensors import Sensor
from apd.sensors.cli import get_sensors
from apd.sensors.wsgi import require_api_key, set_up_config

app = flask.Flask(__name__)

T_value = t.TypeVar("T_value")

class SerializableSensor(ABC, t.Generic[T_value]):

    title: str

    @abstractmethod
    def value(self) -> T_value:
        pass

    @classmethod
    @abstractmethod
    def serialize(cls, value: T_value) -> str:
        pass

    @classmethod
    @abstractmethod

    def deserialize(cls, serialized: str) -> T_value:
        pass

    @classmethod
    def __subclasshook__(cls, C: t.Type[t.Any]) -> t.Union[bool, "NotImplemented"]:
        if cls is SerializableSensor:
            has_abstract_methods = [
                hasattr(C, name) for name in {"value", "serialize", "deserialize"}
            ]
            return all(has_abstract_methods)
        return NotImplemented

class JSONSerializedSensor(SerializableSensor[t.Any]):
    @classmethod
    def serialize(cls, value: t.Any) -> str:
        try:
            return json.dumps(value)
        except TypeError:
            return json.dumps(None)

    @classmethod
    def deserialize(cls, serialized: str) -> t.Any:
        return json.loads(serialized)

class JSONWrappedSensor(JSONSerializedSensor):
    def __init__(self, sensor: Sensor[t.Any]):
        self.wrapped = sensor
        self.title = sensor.title

    def value(self) -> t.Any:
        return self.wrapped.value()

def get_serializable_sensors() -> t.Iterable[SerializableSensor[t.Any]]:
    sensors = get_sensors()
    found = []
    for sensor in sensors:
        if isinstance(sensor, SerializableSensor):
            found.append(sensor)
        else:
            found.append(JSONWrappedSensor(sensor))
    return found

@app.route("/sensors/")
@require_api_key
def sensor_values() -> t.Tuple[t.Dict[str, t.Any], int, t.Dict[str, str]]:
    headers = {"Content-Security-Policy": "default-src 'none'"}
    data = {}
    for sensor in get_serializable_sensors():
        data[sensor.title] = sensor.serialize(sensor.value())
    return data, 200, headers

if __name__ == "__main__":
    import wsgiref.simple_server

    set_up_config(None, app)

    with wsgiref.simple_server.make_server("", 8000, app) as server:
        server.serve_forever()

Listing 5-12A possible implementation of WSGI server and fallback encoding in a third-party piece of code

修复代码中的序列化问题

撇开我们如何在第三方代码中解决这个问题不谈,我们也应该在apd.sensors的主线代码库中解决这个问题。当我们希望将此作为第三方工具时,我们有强烈的动机选择通常有用的函数签名,因此选择了特定的serializedeserialize方法,其他用户可能会使用它们(例如)来记录文件。现在我们回到了我们作为软件维护者的角色,我们在决定界面应该是什么的时候有了更多的灵活性。我们仍然希望代码易于实现,但是我们有更大的权力来规定我们认为最好的功能。

我非常相信,在这里限制我们自己只使用 JSON API 是有益的,因为它使原始数据更容易理解。如果我们的接口有一个serialize(...),那么我们就不能保证输出是人类可读的。因此,我不会创建serialize(...)deserialize(...)函数,而是创建将值缩减为 JSON 可序列化的值的函数,并从这些值重新构建它。

我们可以用我们想要的任何默认实现在Sensor基类上定义这些。目前还不能保证任何给定的传感器都与 JSON 序列化兼容,所以默认的实现必须是引发异常。

添加到 传感器基类的其他方法

    @classmethod
    def to_json_compatible(cls, value: T_value) -> Any:
        raise NotImplementedError

    @classmethod
    def from_json_compatible(cls, json_version: Any) -> T_value:
        raise NotImplementedError

我们现在需要为我们现有的每个传感器提供这两种方法的实现。有三种不同的代码路径需要更新。第一种是针对大多数已经与 JSON 兼容的传感器。为此,我们可以创建一个新的 mixin 类:

class JSONSensor(Sensor[T_value]):
    @classmethod
    def to_json_compatible(cls, value: T_value) -> t.Any:
        return value

    @classmethod
    def from_json_compatible(cls, json_version: t.Any) -> T_value:
        return cast(JSONT_value, json_version)

TYPING OF JSON VALUES

在 Python 类型提示中没有简单的方法来表示 JSON 兼容,因为 JSON 兼容的定义本身就是递归的。只有当列表的所有元素都是 JSON 兼容的,例如。我们可以通过将 JSON 的类型限制到最大递归级别,来尝试越来越接近 JSON 兼容类型的定义,比如

from typing import *

JSON_0 = Union[str, int, float, bool, None]
JSON_1 = Union[Dict[str, JSON_0], List[JSON_0], JSON_0]
JSON_2 = Union[Dict[str, JSON_1], List[JSON_1], JSON_1]
JSON_3 = Union[Dict[str, JSON_2], List[JSON_2], JSON_2]
JSON_4 = Union[Dict[str, JSON_3], List[JSON_3], JSON_3]
JSON_5 = Union[Dict[str, JSON_4], List[JSON_4], JSON_4]
JSON_like = JSON_5

我们用于 Sensor 的 T_ value泛型引用可以是任何类型,但是我们希望我们的 JSONSensor 超类只与 JSON 兼容的类型一起工作,因此需要一个不同的带有 bind 参数的 TypeVar:

JSONT_value = TypeVar(“JSONT_value”, bound=JSON_like)

在我看来,这种绕过类型检查器的做法是适得其反的。打字是用来帮助开发人员的,而不是让他们跳来跳去。如果有些东西很难用静态类型提示来表达,那么你应该用文档和注释来解释清楚。你应该相信开发者会做正确的事情。因此,我将使用Any作为类型提示来表示 JSON 兼容的 Python 对象。

我们编写的大多数传感器都可以透明地使用JSONSensor;不过PythonVersion传感器的类型很奇怪。它使用无法直接实例化的自定义类。Python 的这个实现细节并不重要,但是我们需要稍微改变一下传感器,以便能够从 JSON 转换回行为类似于实际值的东西。

from typing import NamedTuple

version_info_type = NamedTuple(
    "version_info_type",
    [
        ("major", int),
        ("minor", int),
        ("micro", int),
        ("releaselevel", str),
        ("serial", int),
    ],
)

class PythonVersion(JSONSensor[version_info_type]):
    title = "Python Version"

    def value(self) -> version_info_type:
        return version_info_type(*sys.version_info)

    @classmethod
    def format(cls, value: version_info_type) -> str:
        if value.micro == 0 and value.releaselevel == "alpha":
            return "{0.major}.{0.minor}.{0.micro}a{0.serial}".format(value)
        return "{0.major}.{0.minor}".format(value)

这使用了一个类型化的命名元组来模拟真实的sys.version_info,否则我们将无法实现from_json_compatible(...)来返回与value()完全相同的值。

最后,温度和太阳能传感器都使用物理量作为它们的值类型,因此它们将使用 pint 的单位系统作为它们的值,并且需要一对定制的 JSON 方法。

温度的 JSON 方法对

class Temperature(Sensor[Optional[Any]]):

    ...

    @classmethod
    def to_json_compatible(cls, value: Optional[Any]) -> Any:
        if value is not None:
            return {"magnitude": value.magnitude, "unit": str(value.units)}
        else:
            return None

    @classmethod
    def from_json_compatible(cls, json_version: Any) -> Optional[Any]:
        if json_version:
            return ureg.Quantity(json_version["magnitude"], ureg[json_version["unit"]])
        else:
            return None

在创建这个版本的软件时,我们已经获得了相当数量的传感器支持代码;是时候把它从传感器的实现中移走了,让代码库更容易导航。

整理

sensors.py文件目前有两个基类和一些实际的传感器。只在这个文件中列出传感器更清楚,所以我将把支持代码移到 base.py。

让 JSON API 使用与传感器入口点相同的键也是一个好主意。这将使反序列化数据变得容易得多,因为我们可以轻松地查找定义它的传感器类。为此,添加了一个新的名称属性。列表 5-13 显示了Sensor基类的完整定义。

import typing as t

T_value = t.TypeVar("T_value")

class Sensor(t.Generic[T_value]):
    name: str
    title: str

    def value(self) -> T_value:
        raise NotImplementedError

    @classmethod
    def format(cls, value: T_value) -> str:
        raise NotImplementedError

    def __str__(self) -> str:
        return self.format(self.value())

    @classmethod
    def to_json_compatible(cls, value: T_value) -> t.Any:
        raise NotImplementedError()

    @classmethod
    def from_json_compatible(cls, json_version: t.Any) -> T_value:
        raise NotImplementedError()

class JSONSensor(Sensor[T_value]):
    @classmethod
    def to_json_compatible(cls, value: T_value) -> t.Any:
        return value

    @classmethod
    def from_json_compatible(cls, json_version: t.Any) -> T_value:
        return t.cast(T_value, json_version)

Listing 5-13Definition of the sensor base class from base.py

版本控制 API

作为这些变化的一部分,我们已经改变了 API 的行为,尽管是以最小的方式。唯一面向用户的区别是 API 值现在由传感器 ID,而不是人类可读的名称来键入。我们需要创建一个新的面向用户的 API 版本,因为它的行为与以前的版本不同。

新的 API 版本通常是通过在一个稍微不同的 URL 上提供不同的 API,包含版本号。我们可以适当地改变 API,但是任何依赖 API 的人都会突然看到不同的行为。对于个人项目来说,这可能不是问题,但是对公众或公司内部可用的 API 可能会有用户,你不能事先与他们讨论更改。

Tip

有能力支持旧的 API 版本并不意味着你必须支持它们。很有可能你已经有了/v/1.0/v/1.1,但是后来决定发布/v/2.0,它与其他两个非常不同。在这种情况下,您可能会决定完全删除旧的 API 版本。在 URL 中包含版本号并不强制您维护对旧版本的支持,但是如果您不按版本来确定 API 端点的范围,那么如果您以后选择维护旧版本,就很难维护旧版本。

在对 API 进行版本控制时,您需要决定如何处理 bug。总的来说,有两种策略。要么你可以把 bug 留在原地,坚持让人们升级到最新版本的 API,要么你可以用不引人注目的方式修复 bug。将 bug 留在原处是一个更常见的解决方案,因为修复它们要做的工作要多得多。不过,旧 API 版本中的安全漏洞应该总是被修复。

我们在本章中所做的更改是处理温度和湿度传感器的序列化,现在我们已经将它们更改为使用 pint。最初的 API 以摄氏度为单位返回这些值;新函数返回一个包含温度系统的字典。

在本章附带的代码版本中,我应用了一个修复程序,通过捕捉TypeError并在需要时跳过传感器,来防止任何无法 JSON 序列化的传感器出现在 v1.0 API 输出中。这意味着温度和湿度传感器将不再出现在那里,只出现在 v2 API 中。是否花费额外的时间和精力在 v1.0 API 中为 pint 对象添加一个特例在很大程度上取决于用户的需求。

为了方便托管多个版本的 API,我们将视图移动到一个以 API 版本命名的新文件中,并针对该 API 版本的flask.Blueprint实例注册它们,而不是直接针对flask.Flask对象。Flask blueprints 是一组可以添加到应用中的相关 URL。使用蓝图允许我们编写在主网站的子路径中工作的视图代码,而不必修改所有单独的 URL 来包含 API 版本号:

v10.py

version = flask.Blueprint(__name__, __name__)

@version.route("/sensors/")
@require_api_key
def sensor_values() -> t.Tuple[t.Dict[str, t.Any], int, t.Dict[str, str]]:
    ...

init。py

app = flask.Flask(__name__)
app.register_blueprint(v10.version, url_prefix="/v/1.0")

wsgi 目录的文件结构对于每个 API 版本都有不同的文件,目前是v10.pyv20.py,以及一些支持代码,比如认证函数。

src/apd/sensors/wsgi/
├── __init__.py
├── base.py
├── serve.py
├── v10.py
└── v20.py

这里我给了 API 一个简单的版本号,但是许多公共 API 使用日历版本号作为它们的版本号。这可能对用户更友好,但这确实是个人偏好的问题。

易测性

当支持 API 的多个版本时,我们还需要测试它们。即使您认为旧版本的正常工作并不重要,您仍然需要确保旧版本的 API 中没有引入安全问题。

我通过为 API 的每个版本创建一个类来实现这一点。这允许设置 fixtures 来避免必须让每个测试指定它所针对的 API 版本。例如,我们已经有了一个测试来检查在访问传感器时丢失 API 键是否会导致 HTTP 禁止错误。它被写成

@pytest.mark.functional
def test_sensor_values_fails_on_missing_api_key(self, api_server):
    response = api_server.get("/sensors/", expect_errors=True)
    assert response.status_code == 403
    assert response.json["error"] == "Supply API key in X-API-Key header"

这个测试假设 api_server 是一个 WebTest 应用,它在根目录下安装了 api。当我们没有命名空间 API 版本时,这很好,但是似乎我们必须为/v/1.0/sensors/v/2.0/sensors编写这个测试。每个 API 版本都有一个支持类,这意味着我们可以将该版本的蓝图安装在 Flask 应用的根目录下,而不是针对将蓝图安装在不同前缀上的复合应用进行测试。

/v/1.0 作为根的测试类

from apd.sensors.wsgi import v10

class Testv10API:
    @pytest.fixture
    def subject(self, api_key):
        app = flask.Flask("testapp")
        app.register_blueprint(v10.version)
        set_up_config({"APD_SENSORS_API_KEY": api_key}, to_configure=app)
        return app

    @pytest.fixture
    def api_server(self, subject):
        return TestApp(subject)

TestV20API类做了同样的事情,但是使用了v20.version而不是v10.version,使得每个类中的测试在它们的 HTTP 名称空间的根中看到合适的 API 版本。然后,前面缺失的 API 关键测试可以被分解到一个 mixin 类中,以及在不同版本的 API 中工作相同的任何其他测试。对我们来说,这将是处理 API 认证的两个测试。

class CommonTests:
    @pytest.mark.functional
    def test_sensor_values_fails_on_missing_api_key(self, api_server):
        response = api_server.get("/sensors/", expect_errors=True)
        assert response.status_code == 403
        assert response.json["error"] == "Supply API key in X-API-Key header"

    @pytest.mark.functional
    def test_sensor_values_require_correct_api_key(self, api_server):
        response = api_server.get(
            "/sensors/", headers={"X-API-Key": "wrong_key"}, expect_errors=True
        )
        assert response.status_code == 403
        assert response.json["error"] == "Supply API key in X-API-Key header"

由于测试类名不以Test开头,pytest runner 没有将这些测试视为独立的测试,这很好,因为它们依赖于一个名为api_server的 fixture,这个 fixture 没有定义。然而,当我们添加CommonTests作为TestV10APITestV20API的基类时,这些测试函数被两个类继承。pytest 只检查以单词Test开头的测试类,所以CommonTests类不会被孤立执行。它包含的方法由特定于版本的类继承,这些类有适当的装置来支持它们。

摘要

我们在这一章已经讲了很多,介绍了 Flask 的 web APIs,并讲述了我们如何扩展传感器接口来克服 JSON 序列化的限制。Python web 开发的生态系统是巨大的,许多书籍只深入这个世界的一个小方面。

尽管我们确实需要一个 HTTP API 来完成我们的传感器聚合程序,但它从根本上说并不是一个 web 应用。我鼓励任何有兴趣在网上学习 Python 的人尝试一些流行的框架(比如 Django、Pyramid 和 Flask ),了解它们的优缺点。Django 被誉为全方位 web 开发的良好框架,这是正确的,但 Flask 的最小风格和 Pyramid 的表达能力使它们成为选择平台时需要注意的有价值的工具。

我们还讨论了作为系统的原始作者和使用抽象基类的第三方来扩展类定义的实用性。最后,我们还介绍了许多 Python 代码的常用方法,比如用于消息认证的 HMAC 和用于扩展函数行为的 decorators。

传感器 API 已经以打破向后兼容性的方式进行了更改,因此软件包的版本号已经更新为 2.0.0,并且文档现在解释了如何访问 API。在下一章中,我们将使用这个新的 HTTP API 开始整理一个中心源中的信息。

额外资源

如果您对 web 编程特别感兴趣,下面的资源提供了一些关于我们已经讨论过的主题的附加信息,值得一读:

*

六、聚集过程

既然我们已经有了从计算机收集数据并通过 HTTP 接口报告数据的健壮代码库,是时候开始记录和分析这些数据了。我们需要创建一个连接到每个传感器并提取数据的中央聚合流程。这样的过程将允许我们同时观察不同传感器之间的相关性以及随时间的趋势。

首先,我们需要创建一个新的 Python 包。对于我们来说,将聚合过程的所有代码与数据收集代码一起分发是没有意义的;我们期望部署更多的传感器,而不是聚合过程。

程序员很少从零开始一个新项目,并自己编写所有的样板文件。更常见的是使用模板,要么显式地使用,要么通过复制另一个项目并删除其功能来使用。从一段存在但什么都不做的代码开始比从一个空目录开始容易得多。

饼干成型切割刀

虽然您可以通过复制目录从模板创建新项目,但是有一些工具可以使这个过程变得更容易。尽管复制和修改一个模板目录看起来很简单,但它通常需要从“框架”或“示例”中重命名文件和目录,以匹配您正在创建的项目的名称。像 cookiecutter 这样的工具通过允许您创建使用首次创建项目时提供的变量的模板来自动化这个过程。

我推荐使用 cookiecutter 来创建新项目。对我们来说,这将是一个全球性的开发工具,而不是一个特定于项目的工具。我们应该将它安装到系统 Python 环境中, 1 ,就像我们对 Pipenv 所做的那样。

> pip install --user cookiecutter

有许多预先存在的 cookiecutter 模板;有些为一般的 Python 包提供模板,有些为更复杂的东西提供模板。对于各种各样的东西,如混合 Python/rust 包、基于 Python 的智能手机应用和 Python web 应用,都有专门的模板。

您不需要安装 cookiecutter 模板;事实上,你不能。一个模板只能作为本地模板副本的路径或者作为 git 的远程规范被引用(比如,你通常会传递给git clone 2 )。当您指定远程模板时,cookiecutter 会自动下载并使用该模板。如果您以前已经使用过该模板,系统会提示您用新下载的版本替换它。

Tip

如果你有一个经常使用的模板,我建议你在本地保存一个。不要忘记定期更新它,以防 git 存储库中已经应用了修复,但是除了速度上的小改进,这允许您在不连接到互联网的情况下生成代码。

如果您发现自己没有网络连接,但是没有维护本地签出,那么 cookiecutter 可能在~/.cookiecutter/有一个来自过去调用的缓存

创建新模板

我们可以使用这些模板作为聚合过程的基础,但是它们都不完全符合我们在前面章节中做出的决策。相反,我将创建一个新的模板,该模板收集了本书对最小 Python 包的建议。您可以根据自己的喜好进行调整,或者创建新的模板来自动创建特定于您的工作的样板代码。

Note

如果你想使用我在这里描述的模板,你没有必要做你自己的版本。我的模板可以和cookiecutter gh:MatthewWilkes/cookiecutter-simplepackage一起用。本节解释了创建您自己的自定义模板的过程。

我们将创建一个新的 git 存储库来保存模板。我们需要添加的第一件事是一个cookiecutter.json文件,如清单 6-1 所示。这个文件定义了我们将向用户询问的变量及其默认值。其中大多数都是简单的字符串,在这种情况下,会提示用户输入一个值或按 enter 键接受显示在括号中的默认值。通过将 Python 表达式括在大括号中,它们还可以包含来自早期条目的变量替换(这些条目可以是 Python 表达式),在这种情况下,这些替换的结果将用作默认值。最后,它们可以是一个列表,在这种情况下,用户会看到一个选项列表,并被要求选择一个,第一个项目是默认的。

{
    "full_name": "Advanced Python Development reader",
    "email": "example@advancedpython.dev",
    "project_name": "Example project",
    "project_slug": "{{ cookiecutter.project_name.lower().replace(' ', '_').replace('-', '_') }}",
    "project_short_description": "An example project.",
    "version": "1.0.0",
    "open_source_license": ["BSD", "GPL", "Not open source"]
}

Listing 6-1cookiecutter.json

我们还需要创建一个目录,其中包含我们将要创建的模板。我们还可以使用大括号在文件名中包含用户提供的值,所以这个应该被称为{{ cookiecutter.project_slug }}来创建一个与project_slug值同名的目录。我们可以使用来自cookiecutter.json的任何值;但是,项目 slug 是最好的选择。这个目录将成为新项目的 git 存储库的根,因此它的名称应该与预期的存储库名称相匹配。

从这里,我们可以创建我们想要包含在这种类型的每个项目中的各种文件,比如构建文件(setup.pysetup.cfg)、文档(README.mdCHANGES.mdLICENCE)以及test/src/目录。

然而,有一个复杂的问题。该模板在src/中包含了一个{{ cookiecutter.project_slug }}/目录,这对于任何在其 slug 中不包含.的包都很好,但是如果我们正在创建apd.sensors,我们将会看到 cookiecutter 生成的内容与我们想要的内容之间的差异(图 6-1 )。

img/481001_1_En_6_Fig1_HTML.png

图 6-1

我们拥有的文件夹结构与我们需要的文件夹结构的比较

我们需要在目录结构中增加这一层,因为apd是一个名称空间包。当我们第一次创建apd.sensors时,我们决定apd将是一个名称空间,这允许我们在名称空间内创建多个包,条件是没有代码直接放在名称空间包中,只有它们包含的标准包。

我们在这里需要一些自定义行为,这超出了单独使用模板所能实现的范围。 3 我们需要识别 slug 中哪里有.,在这种情况下,拆分 slug 并为每个部分创建嵌套目录。Cookiecutter 通过使用后一代钩子来支持这个需求。在模板的根目录中,我们可以添加一个带有post_gen_project.py文件的 hooks 目录。预生成钩子,存储为钩子/ pre_gen_project.py,用于在生成开始前操作和验证用户输入;后生成钩子,存储为钩子/ post_gen_project.py,用于操作生成的输出。

钩子是 Python 文件,在适当的生成阶段直接执行。它们不需要提供任何重要的功能;代码可以是模块级的。Cookiecutter 首先将这个文件解释为一个模板,在它执行钩子代码之前,任何变量都会被替换。这种行为允许使用变量将数据直接插入到钩子的代码中(如清单 6-2 所示),而不是使用更常见的 API 来检索数据。

import os

package_name = "{{ cookiecutter.project_slug }}"
*namespaces, base_name = package_name.split(".")

if namespaces:
    # We need to create the namespace directories and rename the inner directory
    directory = "src"
    # Find the directory the template created: src/example.with.namespaces
    existing_inner_directory = os.path.join("src", package_name)

    # Create directories for namespaces: src/example/with/
    innermost_namespace_directory = os.path.join("src", *namespaces)
    os.mkdir(innermost_namespace_directory)

    # Rename the inner directory to the last component
    # and move it into the namespace directory
    os.rename(
        existing_inner_directory,
        os.path.join(innermost_namespace_directory, base_name)
    )

Listing 6-2hooks/post_gen_project.py

Note

*namespaces, base_name = package_name.split(".")行是扩展解包的一个例子。它与函数定义中的*args有相似的含义;base_name变量包含从package_name中分离出的最后一项,任何之前的项都存储为一个名为namespaces的列表。如果package_name中没有.字符,那么base_name将等于package_name,并且名称空间将是一个空列表。

使用我在这里创建的 cookiecutter 模板可以通过 GitHub helper 来完成,因为我已经将代码存储在 GitHub 中了。本章附带的代码中也提供了这一功能。cookiecutter 调用如下,其中gh:是 GitHub 助手前缀:

> cookiecutter gh:MatthewWilkes/cookiecutter-simplepackage

或者,您可以使用您的本地工作副本来测试调用

> cookiecutter ./cookiecutter-simplepackage

创建聚合包

我们现在可以使用 cookiecutter 模板为聚合过程创建一个包,名为apd.aggregation。切换到apd.code目录的父目录,但是不需要为聚合过程创建一个目录,因为我们的 cookiecutter 模板会这样做。我们调用 cookiecutter 生成器并填充我们想要的细节,然后可以用第一次提交中添加的生成文件在该目录中初始化一个新的 git 存储库。

生成 apd.aggregation 的控制台会话

> cookiecutter gh:MatthewWilkes/cookiecutter-simplepackage
full_name [Advanced Python Development reader]: Matthew Wilkes
email [example@advancedpython.dev]: matt@advancedpython.dev
project_name [Example project]: APD Sensor aggregator
project_slug [apd_sensor_aggregator]: apd.aggregation
project_short_description [An example project.]: A programme that queries apd.sensor endpoints and aggregates their results

.
version [1.0.0]:
Select license:
1 - BSD
2 - MIT
3 - Not open source
Choose from 1, 2, 3 (1, 2, 3) [1]:
> cd apd.aggregation
> git init
Initialized empty Git repository in /apd.aggregation/.git/
> git add .
> git commit -m "Generated from skeleton"

下一步是开始创建实用函数和附带的测试来收集数据。作为其中的一部分,我们必须对聚合过程的确切职责以及它所提供的特性做出一些决定。

我们希望从聚合过程中获得的特性的完整列表如下。在本书的过程中,我们不一定要构建所有这些特性,但是我们需要确保我们的设计不排除其中任何一个。

  • 按需从所有端点收集传感器的值

  • 以特定的时间间隔自动记录传感器的值

  • 调用在特定时间点为一个或多个端点记录的传感器数据

  • 调用一个或多个端点在某个时间范围内的传感器数据

  • 查找传感器值与某个条件(如某个范围内的最大值、最小值)相匹配的时间,可以是在所有时间内,也可以是在某个时间范围内

  • 支持所有传感器类型,无需修改服务器来存储数据

    • 要求传感器安装在服务器上进行分析是可以的,但不能检索数据。
  • 必须能够导出和导入兼容的数据,以实现数据可移植性和备份目的

  • 必须能够按时间或端点 4 删除数据

数据库类型

我们需要做的第一件事是决定数据应该如何存储在这个应用中。有许多数据库可用,涵盖了各种各样的特性集。开发人员经常根据当前的流行趋势选择特定的数据库,而不是对利弊进行冷静的分析。图 6-2 是一个决策树,它概括了我在决定使用什么类型的数据库时问自己的广泛问题。这只能帮助你找到一个广泛的数据库类别,而不是一个特定的软件,因为功能集变化很大。尽管如此,我相信在决定一种类型的数据库时问这些问题是有帮助的。

img/481001_1_En_6_Fig2_HTML.jpg

图 6-2

挑选一类数据库的决策树

我问自己的第一个问题是排除一些数据库技术的特例。这些都是有价值的技术,在它们特定的领域,它们是优秀的,但是它们相对来说是不经常需要的。这些是只附加的数据库——一旦写入,就不能(轻易)删除或编辑。这种数据库非常适合日志,比如事务日志或审计日志。区块链数据库和仅追加数据库之间的主要区别是信任;虽然在典型情况下两者都阻止编辑或删除数据,但是可以通过操作底层存储文件来编辑标准的仅追加数据库。区块链略有不同;它允许一组人共同充当维护者。只有在至少 50%的用户同意的情况下,才能编辑或删除数据。任何不同意的用户可以保留旧数据并离开该组。在撰写本文时,区块链是当今流行的数据库,但是它们不适用于几乎所有的应用。

图左侧的数据库类型更有用。它们是 SQL 和 NoSQL 数据库。NoSQL 数据库在 21 世纪初很流行。关系数据库后来采用了它们的一些特性作为扩展和额外的数据类型。是否使用 SQL 不是区分这些数据库类型的关键方法,而是它们是否是无模式的。这种区别类似于有和没有类型提示的 Python 无模式数据库允许用户添加任意形状的数据,而具有已定义模式的数据库验证数据以确保它符合数据库作者的期望。无模式数据库可能看起来更有吸引力,但它会使查询或迁移数据变得更加困难。如果不能保证存在哪些列以及它们的类型,就有可能存储看起来正确的数据,但在以后的开发中会出现问题。

例如,假设我们有一个温度日志表,其中存储了记录温度值的时间、记录该温度的传感器以及该值。该值很可能被声明为十进制数,但是如果传感器提供类似于"21.2c"而不是21.2的字符串,会发生什么呢?在实施模式的数据库中,这将引发错误,数据将无法插入。在无模式数据库中,如果检索到的数据集中存在这些格式不正确的条目之一,则插入会成功,但聚合数据的尝试(如计算平均值)会失败。与 Python 的类型提示一样,这并不能防止所有的错误,只是一种类型的错误。值70.2将被接受,因为它是一个有效的数字,尽管人类可以分辨出它是用华氏度而不是摄氏度来度量的。

我们需要考虑的最后一件事是如何查询数据。查询支持是这三个问题中最难概括的,因为数据库类别之间有很大的差异。人们通常认为关系数据库更适合查询,而 NoSQL 数据库更依赖自然键,比如对象存储中的路径或键/值存储中的键。然而,这过于简单化了。例如,SQLite 是一个关系数据库,但是与 PostgreSQL 等替代数据库相比,它的索引选项相对较少;Elasticsearch 是一个 NoSQL 数据库,设计用于索引和搜索的灵活性。

我们的例子

在我们的例子中,我们发现很难决定传感器值的单一类型,除了所有值都是 JSON 可序列化的这一事实。我们希望能够访问这种类型的内部,例如,温度值的大小或 IP 地址列表的长度。如果我们要用标准的关系数据库结构来构建它,我们将很难用一种面向未来的方式来表示这些选项。我们必须预先知道可能返回的不同类型的值来编写数据库结构。

更适合我们的是使用无模式数据库,让从 API 返回的传感器的 JSON 表示成为存储的数据。我们有一个保证,我们可以准确地恢复这些数据(假设我们有相同版本的传感器代码),并且找到一种表示它的方法没有任何困难。

这个问题把我们带到了决策树的最低决策点;我们现在需要考虑数据库中项目之间的关系。单个传感器值由于由相同的传感器类型生成、从相同的端点检索以及同时检索而与其他值相关。也就是说,传感器值通过传感器名称、端点 URL 和创建时间相关联。这些多维关系应该引导我们走向一个具有丰富索引和查询支持的数据库,因为它将帮助我们找到相关数据。我们还希望数据库具有良好的查询支持,因为我们希望能够从它们的值中找到记录,而不仅仅是传感器和时间。

这些需求引导我们使用关系数据库 和无模式支持选项。也就是说,我们应该强烈地考虑这样一个数据库,它的核心是关系型的,但支持实现无模式行为的类型。PostgreSQL 及其 JSONB 类型就是一个很好的例子。JSONB 用于以 JSON 格式 6 存储数据,并允许创建在其内部结构上工作的索引。

CREATE TABLE sensor_values(
    id SERIAL PRIMARY KEY,
    sensor_name TEXT NOT NULL,
    collected_at TIMESTAMP
    data JSONB
 )

这种格式平衡了固定模式数据库的一些优点,因为它是部分固定的。namecollected_at字段是固定列,但是剩余的数据字段是无模式字段。理论上,我们可以将 JSON 或任何其他序列化格式作为文本列存储在这个表中,但是使用 JSONB 字段允许我们编写查询和索引来检查这个值。

对象关系映射器

直接把 SQL 代码写成 Python 是完全可能的,但人们这样做的情况相对较少。数据库是复杂的野兽,SQL 因易受注入攻击而臭名昭著。完全抽象出单个数据库的特性是不可能的,但是确实有工具可以处理表创建、列映射和 SQL 生成。

Python 世界中最流行的是由 Michael Bayer 等人编写的 SQLAlchemy。SQLAlchemy 是一个非常灵活的对象关系映射器;它处理 SQL 语句和原生 Python 对象之间的转换,并且是以可扩展的方式完成的。另一个常用的 ORM 是 Django ORM,它不太灵活,但是提供了一个不太需要数据库工作原理的接口。一般来说,只有在 Django 项目中使用 Django ORM,否则 SQLAlchemy 是最合适的 ORM。

Note

SQLAlchemy 不附带类型提示;但是,有一个名为 sqlmypy 的 mypy 插件,它为 SQLAlchemy 提供提示,并教会 mypy 理解列定义所隐含的类型。我建议在使用类型检查的基于 SQLAlchemy 的项目中使用这种方法。本章附带的代码使用了这个插件。

首先,我们需要安装 SQLAlchemy 和一个数据库驱动程序。我们需要将SQLAlchemypsycopg2添加到setup.cfg中的install_requires部分,并使用命令行上的pipenv install -e .触发这些依赖关系进行重新评估。

用 SQLAlchemy 描述数据库结构有两种方式,经典和声明式。在经典风格中,实例化Table对象并将它们与现有的类相关联。在声明式风格中,您使用一个特定的基类(它引入了一个元类),然后您直接在面向用户的类上定义列。在大多数情况下,Python 风格的声明性方法使其成为自然的选择。

与前面相同的表,采用 SQLAlchemy 声明风格

import sqlalchemy
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.dialects.postgresql import JSONB, TIMESTAMP

Base = declarative_base()

class DataPoint(Base):
    __tablename__ = 'sensor_values'
    id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True)
    sensor_name = sqlalchemy.Column(sqlalchemy.String)
    collected_at = sqlalchemy.Column(TIMESTAMP)
    data = sqlalchemy.Column(JSONB)

然后,您可以使用 Python 代码编写查询,这会自动创建适当的 SQL。create_engine(...)函数用于从连接字符串创建数据库连接。设置echo=True可以通过,让你看到生成的 SQL。下一步是使用sessionmaker(...)创建一个函数,该函数允许您启动一个新的会话和事务,然后最终为数据库连接创建一个会话,如下所示:

>>> engine = sqlalchemy.create_engine("postgresql+psycopg2://apd@localhost/apd", echo=True)
>>> sm = sessionmaker(engine)
>>> Session = sm()
>>> Session.query(DataPoint).filter(DataPoint.sensor_name == "temperature").all()
INFO sqlalchemy.engine.base.Engine SELECT sensor_values.id AS sensor_values_id, sensor_values.sensor_name AS sensor_values_sensor_name, sensor_values.collected_at AS sensor_values_collected_at, sensor_values.data AS sensor_values_data
FROM sensor_values
WHERE sensor_values.sensor_name = %(sensor_name_1)s
INFO sqlalchemy.engine.base.Engine {'sensor_name_1': 'temperature'}
[]

Column Objects And Descriptors

我们在类中使用的列对象以一种不寻常的方式运行。当我们从类中访问一列时,比如DataPoint.sensor_name,我们得到一个特殊的对象来表示列本身。这些对象截取许多 Python 操作,并返回表示操作的占位符。如果没有这个拦截,DataPoint.sensor_name == "temperature"将被求值,filter(...)函数将等同于Session.query(DataPoint).filter(False).all()

DataPoint.sensor_name=="temperature"返回一个 BinaryExpression 对象。这个对象是不透明的,但是 SQL 模板(不包括常量值)可以用str(...)预览:

>>> str((DataPoint.sensor_name=="temperature"))                                 'sensor_values.sensor_name = :sensor_name_1'

表达式的隐含数据库类型存储在表达式结果的type属性中。在比较的情况下,总是Boolean

当对DataPoint类型的实例执行相同的表达式时,它不会保留任何特定于 SQL 的行为;该表达式正常计算对象的实际数据。SQLAlchemy 声明类的任何实例都像普通 Python 对象一样工作。

因此,开发人员可以使用相同的表达式来表示 Python 条件和 SQL 条件。

这是可能的,因为由DataPoint.sensor_name引用的对象是一个描述符。描述符是一个具有某种方法组合__get__(self, instance, owner)__set__(self, instance, value)__delete__(self, instance)的对象。

描述符允许实例属性的自定义行为,允许在类或实例上访问值时返回任意值,以及自定义设置或删除值时发生的事情。

下面是一个描述符示例,它在实例上的行为类似于普通的 Python 值,但在类上公开自己:

class ExampleDescriptor:

    def __set_name__(self, instance, name):
        self.name = name

    def __get__(self, instance, owner):
        print(f"{self}.__get__({instance}, {owner})")
        if not instance:
            # We were called on the class available as `owner`
            return self
        else:
            # We were called on the instance called `instance`
            if self.name in instance.__dict__:
                return instance.__dict__[self.name]
            else:
                raise AttributeError(self.name)

    def __set__(self, instance, value):
        print(f"{self}.__set__({instance}, {value})")
        instance.__dict__[self.name] = value

    def __delete__(self, instance):
        print(f"{self}.__delete__({instance}")
        del instance.__dict__[self.name]

class A:

    foo = ExampleDescriptor()

下面的控制台会话演示了前面的 get 方法的两个代码路径,以及设置和删除功能。

>>> A.foo
<ExampleDescriptor object at 0x03A93110>.__get__(None, <class 'A'>)
<ExampleDescriptor object at 0x03A93110>
>>> instance = A()
>>> instance.foo
<ExampleDescriptor object at 0x03A93110>.__get__(<A object at 0x01664090>, <class 'A'>)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File ".\exampledescriptor.py", line 16, in
    __get__raise AttributeError(self.name)
AttributeError: foo
>>> instance.foo = 1
<ExampleDescriptor object at 0x03A93110>.__set__(<A object at 0x01664090>, 1)
>>> instance.foo
<ExampleDescriptor object at 0x03A93110>.__get__(<A object at 0x01664090>, <class 'A'>)
1
>>> del instance.foo
<ExampleDescriptor object at 0x03A93110>.__delete__(<A object at 0x01664090>)

大多数时候,你需要一个描述符,这是为了产生一个计算结果的属性。这可以用@property装饰器更好地表达,它在幕后构造一个描述符。在只需要定制 get 功能的常见情况下,属性特别有用,但是它们也支持设置和删除的定制实现。

class A:

    @property
    def foo(self):
        return self._foo

    @foo.setter
    def foo(self, value):
        self._foo = value

    @foo.deleter
    def foo(self):
        del self._foo

一些核心 Python 特性是作为描述符实现的:它们是一种非常强大的方式,可以深入到核心对象逻辑中。在不了解它们的情况下,像@property@classmethoddecorator 这样的特性看起来就像是解释器专门寻找的魔法,而不是你可以自己编程的东西。

也就是说,虽然我经常使用@property装饰器,但我从来没有理由编写描述符。如果您发现自己在复制/粘贴属性定义,那么您可能需要考虑将它们的代码合并到一个描述符中。

版本化数据库

SQLAlchemy 中有一个函数可以创建数据库中定义的所有各种表、索引和约束。这将检查已经定义的表和列,并为它们生成匹配的数据库结构。

使用 SQLAlchemy 创建所有已定义的数据库表

engine = sqlalchemy.create_engine("postgresql+psycopg2://apd@localhost/apd", echo=True)
Base.metadata.create_all(engine)

这个功能一开始看起来很棒,但是非常有限。在完成一些性能测试后,您可能会在将来添加更多的表或列,或者至少添加更多的索引。create_all(...)函数创建了所有还不存在的东西,这意味着如果您重新运行create_all(...),任何被更改但之前存在的表都不会被更新。因此,依靠create_all(...)可能会导致数据库包含您期望的所有表,但不包含所有列。

为了解决这个问题,人们使用 SQL 迁移框架。Alembic 是 SQLAlchemy 最受欢迎的一个。它的工作方式是连接到数据库的一个实例,并生成使连接的数据库与代码中定义的数据库同步所需的操作。如果您使用的是 Django ORM,有一个内置的迁移框架,它通过分析所有过去的迁移并将分析的状态与代码的当前状态进行比较来工作。

这些框架允许我们对数据库进行更改,并相信这些更改会传播到实际部署中,而不管他们过去使用的是什么版本的软件。如果用户跳过一个或三个版本,这些版本之间的任何迁移也将运行。

为此,我们将 Alembic 添加到setup.cfg依赖项列表中,然后重新运行pipenv install -e .来刷新这些依赖项并安装 Alembic。然后我们使用alembic命令行工具来生成在我们的包中使用 Alembic 所需的文件。

> pipenv run alembic init src\apd\aggregation\alembic
Creating directory src\apd\aggregation\alembic ...  done
Creating directory src\apd\aggregation\alembic\versions ...  done
Generating alembic.ini ...  done
Generating src\apd\aggregation\alembic\env.py ...  done
Generating src\apd\aggregation\alembic\README ...  done
Generating src\apd\aggregation\alembic\script.py.mako ...  done
Please edit configuration/connection/logging settings in 'alembic.ini' before proceeding.

大多数文件都是在包内的alembic/目录中创建的。我们需要将文件放在这里,以便安装软件包的人可以访问它们;该层次结构之外的文件不会分发给最终用户。例外是alembic.ini,它提供日志和数据库连接配置。这些因最终用户而异,因此不能包含在软件包中。

我们需要修改生成的alembic.ini文件,主要是改变数据库 URI 以匹配我们正在使用的连接字符串。如果愿意,我们可以保留script_location=src/apd/aggregation/alembic的值,因为在这个开发环境中,我们使用的是apd.aggregation的可编辑安装,但是这个路径对于最终用户来说是无效的,所以我们应该将它改为引用一个已安装的包,并且我们应该在 readme 文件中包含一个最小的alembic.ini示例。

Caution

Alembic 脚本一般只适用于用户模型(依赖项有自己的配置和 ini 文件来迁移它们的模型)。用户从来没有一个有效的理由为他们的依赖关系中包含的模型生成新的迁移。另一方面,Django 的 ORM 同时处理用户模型和依赖关系,所以如果一个维护者发布了一个包的不完整版本,最终用户在生成他们自己的迁移时可能会无意中为它创建新的迁移。因此,检查迁移文件是否被正确提交和发布是非常重要的。当作为最终用户生成新的迁移时,您应该全面检查为您的代码创建的文件,而不是依赖项。

面向最终用户的最小 alembic.ini】

[alembic]
script_location = apd.aggregation:alembic
sqlalchemy.url = postgresql+psycopg2://apd@localhost/apd

我们还需要在包内定制生成的代码,从env.py文件开始。这个文件需要一个对我们之前在使用create_all(...)函数时看到的元数据对象的引用,因此它可以确定代码中模型的状态。它还包含连接数据库和生成代表迁移的 SQL 文件的函数。这些可以编辑,以允许定制数据库连接选项,以满足我们的项目需求。

我们需要更改target_metadata行,以使用模型使用的声明性Base类的元数据,如下所示:

from apd.aggregation.database import Base
target_metadata = Base.metadata

现在我们可以生成一个迁移来表示数据库的初始状态, 7 这个迁移创建了我们为支持 DataPoint 类而创建的datapoints表。

> pipenv run alembic revision --autogenerate -m "Create datapoints table"

修订命令在alembic/versions/目录中创建一个文件。名称的第一部分是随机生成的不透明标识符,但第二部分基于上面给出的消息。标志的存在意味着生成的文件不会是空的;它包含匹配代码当前状态所需的迁移操作。该文件基于一个模板,alembic/目录中的script.py.mako。这个模板是由 Alembic 自动添加的。虽然我们可以修改它,如果我们想,默认的一般是好的。改变这一点的主要原因是修改注释,也许是生成迁移时要检查的事情的清单。

在对该文件运行 black 并删除包含说明的注释后,它看起来像这样:

alem BIC/versions/6 D2 ea CD 5 da 3f _ create _ sensor _ values _ table . py

"""Create datapoints table

Revision ID: 6d2eacd5da3f
Revises: N/A
Create Date: 2019-09-29 13:43:21.242706

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = "6d2eacd5da3f"
down_revision = None
branch_labels = None
depends_on = None

def upgrade():
    op.create_table(
        "datapoints",
        sa.Column("id", sa.Integer(), nullable=False),
        sa.Column("sensor_name", sa.String(), nullable=True),
        sa.Column("collected_at", postgresql.TIMESTAMP(), nullable=True),
        sa.Column("data", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
        sa.PrimaryKeyConstraint("id"),
    )

def downgrade():
    op.drop_table("datapoints")

Alembic 使用四个模块范围变量来确定迁移的运行顺序。这些不应该被改变。upgrade()downgrade()函数的主体是我们需要检查的,以确保它们做了我们期望的所有更改,并且只做了我们期望的更改。需要进行的最常见的更改是如果检测到不正确的更改,例如迁移更改了一个列,但是目标状态等于起始状态。例如,如果数据库备份恢复不正确,就会发生这种情况。

一个不太常见(但仍然常见)的问题是,有时 alembic 迁移包括从依赖项或用户代码中的其他地方引入代码的 import 语句,通常是在开发人员使用自定义列类型时。在这种情况下,迁移必须改变,因为迁移代码完全独立是很重要的。出于同样的原因,任何常量也应该复制到迁移文件中。

如果迁移导入外部代码,那么它的效果可能会随着外部代码的改变而改变。任何影响不完全确定的迁移都可能导致现实世界的数据库具有不一致的状态,这取决于迁移时哪个版本的依赖关系代码是可用的。

Example of a Migration Repeatability Issue

例如,考虑以下用于将用户表添加到软件中的部分迁移代码:

from example.database import UserStates

def upgrade():
    op.create_table(
        "user",
        sa.Column("id", sa.Integer(), nullable=False),
        sa.Column("username", sa.String(), nullable=False),
        sa.Column("status", sa.Enum(UserStates), nullable=False),
        ...
        sa.PrimaryKeyConstraint("id"),
    )

有一个状态字段,作为枚举字段,只能包含预选值。如果 1.0.0 版本的代码定义了UserStates = ["valid", "deleted"],那么 Enum 将被创建为有效选项。然而,1.1.0 版本可能会添加另一个状态,使UserStates = ["new", "valid", "deleted"]表示用户在登录之前必须验证他们的帐户。1.1.0 版还需要添加一个迁移,以将“new”作为有效类型添加到该枚举中。

如果用户安装了 1.0.0 版并运行了迁移,然后安装了 1.1.0 版并重新运行了迁移,则数据库将是正确的。但是,如果用户只是在 1.1.0 发布后才了解该软件,并且在安装了 1.1.0 的情况下运行了两次迁移,那么初始迁移将添加所有三个用户状态,而第二个迁移将无法添加已经存在的值。

作为开发人员,我们习惯了不应该重复代码的想法,因为这会导致可维护性问题,但是数据库迁移是个例外。您应该复制您需要的任何代码,以确保迁移的行为不会随着时间的推移而改变。

最后,有些变化模棱两可。如果我们要更改我们在这里创建的datapoints表的名称,Alembic 不清楚这是名称更改还是删除一个表并创建另一个恰好具有相同结构的表。Alembic 总是在删除和重新创建方面出错,所以如果想要重命名,但迁移没有改变,就会发生数据丢失。

Alembic 文档中提供了可用操作的详细信息,它提供了您可能需要的所有日常操作。操作插件可以提供新的操作类型,尤其是特定于数据库的操作。

Tip

当您对升级操作进行更改时,也应该对降级操作进行等效的更改。如果您不想支持从特定版本降级,您应该引发一个异常,而不是保留不正确的自动生成的迁移代码。对于非破坏性迁移,允许降级非常有用,因为它允许开发人员在特性分支之间切换时恢复数据库。

随着这个迁移的生成并提交到源代码控制中,我们可以运行迁移了,它为我们生成了这个数据点表。运行迁移是通过 alembic 命令行完成的,如下所示:

> alembic upgrade head

其他有用的 alembic 命令

有一些 Alembic 用户日常需要的子命令。这些因素如下:

  • alembic current

    • 显示连接的数据库的版本号。
  • alembic heads

    • 显示迁移集中的最新版本号。如果列出了多个版本,则需要合并迁移。
  • alembic merge heads

    • 创建一个新的迁移,它依赖于 alembic heads 列出的所有修订,确保它们都被执行。
  • alembic history

    • 显示了 Alembic 已知的所有迁移的列表。
  • alembic stamp <revisionid>

    • 用字母数字版本标识符替换<revisionid>,将现有数据库标记为该版本,而不运行任何迁移。
  • alembic upgrade <revisionid>

    • <revisionid>替换为要升级到的字母数字版本标识符。这可以对头部 8 进行最近一次修改。Alembic 跟踪修订历史,运行尚未执行的任何迁移的升级方法。
  • alembic downgrade <revisionid>

    • upgrade,但是目标修改更早,使用降级方式。根据我的经验,这在合并迁移中不如在直接迁移中有效,您应该知道降级并不等同于撤销。它不能还原已删除的列中的数据。

加载数据

现在我们已经定义了数据模型,可以开始从传感器加载数据了。我们将使用优秀的请求库通过 HTTP 来完成这项工作。支持将 HTTP 请求内置到 Python 中,但是 requests 库有更好的用户界面。我建议在所有情况下都使用基于标准库 HTTP 支持的请求。只有在使用依赖关系不切实际的情况下,才应该使用标准库的 HTTP 请求支持。

我们从传感器中提取数据所需的最底层构建块是一个函数,该函数给定端点的 API 细节,向 API 发出 HTTP 请求,解析结果,并为每个传感器创建DataPoint类实例。

从服务器添加数据点的功能

def get_data_points(server: str, api_key: t.Optional[str]) -> t.Iterable[DataPoint]:
    if not server.endswith("/"):
        server += "/"
    url = server + "v/2.0/sensors/"
    headers = {}
    if api_key:
        headers["X-API-KEY"] = api_key
    try:
        result = requests.get(url, headers=headers)
    except requests.ConnectionError as e:
        raise ValueError(f"Error connecting to {server}")
    now = datetime.datetime.now()
    if result.ok:
        for value in result.json()["sensors"]:
            yield DataPoint(
                sensor_name=value["id"], collected_at=now, data=value["value"]
            )
    else:
        raise ValueError(
            f"Error loading data from {server}: "
            + result.json().get("error", "Unknown")
        )

该函数连接到远程服务器,并返回每个传感器值的 DataPoint 对象。它还可以引发一个表示在试图读取数据时遇到错误的ValueError,并对所提供的 URL 执行一些基本的检查。

Yield and Return

我只是将get_data_points()函数描述为返回数据点对象的,但这并不完全正确。它使用 yield 关键字,而不是 return。我们在第五章中简要地看到了这一点,当时编写了一个 WSGI 应用,它返回部分响应,中间有一个延迟。

yield语句使它成为一个生成器函数。生成器是一个延迟求值的可迭代值。它可以产生零个或多个值,甚至无穷多个值。生成器只生成调用者请求的项,不像普通函数在第一个项对调用者可用之前计算完整的返回值。

构建简单生成器的最简单方法是使用生成器表达式,如果您熟悉列表、集合和字典理解,它看起来就像您想象的元组理解。

>>> [item for item in range(10)]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> (item for item in range(10))
<generator object <genexpr> at 0x01B58EB0>

这些生成器表达式不能像列表一样进行索引,您只能从中请求下一项:

>>> a=(item for item in range(10))
>>> a[0]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'generator' object is not subscriptable
>>> next(a)
0
>>> next(a)
1
...
>>> next(a)
8
>>> next(a)
9
>>> next(a)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration

也可以使用list(a)语法将它们转换成列表或元组(只要它们不包含无限多的条目);然而,这考虑到了他们的状态。如果您已经从生成器中提取了一些或所有的项目,那么list(a)的结果将只包含那些剩余的项目。

发电机功能

前面的例子是生成器表达式,但是get_data_points()是生成器函数。它们使用yield关键字来指定下一个值应该是什么,然后暂停执行,直到用户请求下一个值。Python 记住了函数的状态;当请求下一项时,它从 yield 语句的点继续。

这非常有用,因为有些函数需要很长时间来生成每个后续值。另一种方法是创建一个函数,您需要指定想要生成的项目数量,但是生成器模型允许您在决定是否需要更多项目之前检查返回的项目。

考虑以下发生器函数:

def generator() -> t.Iterable[int]:
    print("Stating")
    yield 1
    print("Part way")
    yield 2
    print("Done")

这里,print(...)代表更复杂的代码,可能连接到外部服务或复杂的算法。如果我们将这个生成器强制转换为一个元组,那么在我们得到结果之前,所有的打印都会发生:

>>> tuple(generator())
Stating
Part way
Done
(1, 2)

但是,如果我们逐个使用这些项目,我们可以看到在返回的值之间执行了yield语句之间的代码:

>>> for num in generator():
...   print(num)
...
Stating
1
Part way
2
Done

何时使用它们

有时可能不清楚最好使用生成器还是普通函数。任何只是单独生成数据的函数都可以是生成器函数或标准函数,但是对数据执行操作(比如向数据库添加数据点)的函数必须确保消耗迭代器。

通常所说的经验法则是,这样的函数应该返回值,而不是产生值,但是任何导致整个迭代器被求值的模式都可以。另一种方法是循环所有项目:

def add_to_session(session)
    for item in generator:
        session.add(item)

或者通过将生成器转换成具体的列表或元组类型:

def add_to_session(session)
    session.add_all(tuple(generator))

然而,如果在前面的函数中有一个yield语句,那么它们就不会像预期的那样工作。前面的两个函数都可以用add_to_session(generator)调用,生成器生成的所有项目都将被添加到会话中。如果以同样的方式调用以下内容,将不会向会话中添加任何项目:

def add_to_session(session)
    for item in generator:
        session.add(item)
        yield item

如果有疑问,请使用标准函数,而不是生成器函数。无论哪种方式,请确保您测试了您的函数的行为是否符合预期。

Exercise 6-1: Practice With Generators

编写一个生成器函数,从单个传感器提供无限量的数据点。您应该在您构建的DataPoint实例上使用yield,并在使用time.sleep(...)函数的采样之间等待一秒钟。

一旦编写了这个函数,就应该循环遍历它的值,以查看传感器被查询时数据的突发情况。您还应该尝试使用标准库的filter(function, iterable)函数来查找特定传感器的值。

本章附带的代码中提供了一个实现示例。

这个函数是一个很好的开始:它提供了我们可以迭代的包含DataPoint对象的东西,但是我们需要创建一个数据库连接,将它们添加到一个会话中,然后提交那个会话。为此,我定义了两个助手函数(如清单 6-3 所示),一个函数给定一个数据库会话和服务器信息,从每个服务器获取所有数据点,并调用session.add(point)将它们添加到当前数据库事务中。第二个是作为一个独立的数据收集功能。它建立会话,调用add_data_from_sensors(...),然后将会话提交给数据库。我还创建了另一个基于单击的命令行工具来执行这些操作,允许在命令行上传递参数。

def add_data_from_sensors(
    session: Session, servers: t.Tuple[str], api_key: t.Optional[str]
) -> t.Iterable[DataPoint]:
    points: t.List[DataPoint] = []
    for server in servers:
        for point in get_data_points(server, api_key):
            session.add(point)
            points.append(point)
    return points

def standalone(
    db_uri: str, servers: t.Tuple[str], api_key: t.Optional[str], echo: bool = False
) -> None:
    engine = sqlalchemy.create_engine(db_uri, echo=echo)
    sm = sessionmaker(engine)
    Session = sm()
    add_data_from_sensors(Session, servers, api_key)
    Session.commit()

Listing 6-3Helper functions in collect.py

点击 cli.py 中的 input point

@click.command()
@click.argument("server", nargs=-1)
@click.option(
    "--db",
    metavar="<CONNECTION_STRING>",
    default="postgresql+psycopg2://localhost/apd",
    help="The connection string to a PostgreSQL database",
    envvar="APD_DB_URI",
)
@click.option("--api-key", metavar="<KEY>", envvar="APD_API_KEY")
@click.option(
    "--tolerate-failures",
    "-f",
    help="If provided, failure to retrieve some sensors' data will not " "abort the collection process",
    is_flag=True,
)
@click.option("-v", "--verbose", is_flag=True, help="Enables verbose mode")
def collect_sensor_data(
    db: str, server: t.Tuple[str], api_key: str, tolerate_failures: bool, verbose: bool
):
    """This loads data from one or more sensors into the specified database.

    Only PostgreSQL databases are supported, as the column definitions use
    multiple pg specific features. The database must already exist and be
    populated with the required tables.

    The --api-key option is used to specify the access token for the sensors
    being queried.

    You may specify any number of servers, the variable should be the full URL
    to the sensor's HTTP interface, not including the /v/2.0 portion. Multiple
    URLs should be separated with a space.
    """
    if tolerate_failures:
        attempts = [(s,) for s in server]
    else:
        attempts = [server]
    success = True
    for attempt in attempts:
        try:
            standalone(db, attempt, api_key, echo=verbose)
        except ValueError as e:
            click.secho(str(e), err=True, fg="red")
            success = False
    return success

这个示例使用了 click 的更多特性,包括 click 命令上的文档字符串作为命令的帮助向最终用户公开。帮助文本大大增加了函数的长度,但是在带有语法突出显示的代码编辑器中,帮助文本就不那么冗长了。这在用户使用--help标志时公开,如下所示:

> pipenv run collect_sensor_data --help
Usage: collect_sensor_data [OPTIONS] [SERVER]...

  This loads data from one or more sensors into the specified database

.

  Only PostgreSQL databases are supported, as the column definitions use
  multiple pg specific features. The database must already exist and be
  populated with the required tables.

  The --api-key option is used to specify the access token for the sensors
  being queried.

  You may specify any number of servers, the variable should be the full URL
  to the sensor's HTTP interface, not including the /v/2.0 portion. Multiple
  URLs should be separated with a space.

Options:
  --db <CONNECTION_STRING>  The connection string to a PostgreSQL database
  --api-key <KEY>
  -f, --tolerate-failures   If provided, failure to retrieve some sensors'
                            data will not abort the collection process

  -v, --verbose             Enables verbose mode
  --help                    Show this message and exit.

然后,我们第一次使用@click.argument。我们用它来收集函数的参数,而不是带有相关值的选项。这个参数的nargs=-1选项声明我们接受任意数量的参数,而不是特定的数量(通常是1)。因此,该命令可以作为collect_sensor_data http://localhost:8000/(仅从本地主机收集数据)、作为collect_sensor_data http://one:8000/ http://two:8000/(从两个服务器收集数据),甚至作为collect_sensor_data(不收集数据,但会隐式测试数据库连接)来调用。

--api-key--verbose选项可能不需要任何解释,但是--tolerate-failures选项是我们可能没有考虑到的。如果没有这个选项及其支持代码,我们将对所有传感器位置运行standalone(...)函数,但是如果其中一个失败,整个脚本都会失败。此选项允许用户指定在指定了多个服务器的情况下,任何成功的服务器都会保存其数据,而失败的传感器会被忽略。代码通过使用该选项来决定是否应该从[(" http://one:8000/ ", " http://two:8000/ ")][(" http://one:8000/ ", ), (" http://two:8000/ ", )]下载数据来实现这一点。这个命令的代码在正常情况下将所有服务器传递给standalone(...),但是如果添加了--tolerate-failures,那么对于每个服务器 URL,将有一个对standalone(...)的调用。这是一个非常方便的特性,但是如果我自己使用这个命令的话,我会喜欢这个特性。

最后,支持功能相对简单。add_data_from_sensors(...)函数包装现有的get_data_points(...)函数,并在其返回的每个数据点上调用session.add(...)。然后它将这些作为返回值传递给调用者,但作为一个列表而不是一个生成器。当我们遍历生成器时,它确保了迭代器被完全消耗。对add_data_from_sensors(...)的调用可以访问DataPoint对象,但是它们没有义务遍历这些对象来使用生成器。

Caution

喜欢函数式编码风格的开发人员有时会陷入一个陷阱。他们可能想用类似于map(Session.add, items)的东西来代替这个功能。map 函数创建了一个生成器,因此需要消耗它才能产生效果。这样做可能会引入一些微妙的错误,比如只有在启用了 verbose 标志的情况下代码才起作用,这会导致 iterable 被日志记录语句消耗掉。

Do not use

map(...) 如果你对项目调用的函数有任何副作用,比如用数据库会话注册对象。总是使用循环来代替;它更加清晰,并且没有对后续代码施加任何义务来确保生成器被消耗掉。

新技术

我们已经略微谈到了一些非常常用的技术。我建议花点时间去理解我们在这一章中对它们的使用所做的所有决定。为此,我在下文中简要概述了我的建议。

数据库

选择一个与你需要处理的数据相匹配的数据库,而不是当前流行的数据库。有些数据库,比如 PostgreSQL,是很好的默认选择,因为它们提供了很大的灵活性,但是灵活性是以复杂性为代价的。

如果您使用基于 SQL 的数据库,请使用 ORM 和迁移框架。除了极端的情况,它们比编写自己的定制 SQL 更好地为您服务。但是,不要误以为 ORM 会保护你不了解数据库。它简化了界面,但是如果您试图在不了解数据库需求的情况下与数据库进行交互,您将会遇到困难。

自定义属性行为

如果您需要一个行为类似于计算属性的东西,也就是说,一个行为类似于对象上的属性但实际上从其他来源构建其值的东西,@property是最好的方法。对于一次性的值包装器来说也是如此,其中数据被修改或重新格式化。在这种情况下,应该使用带有 setter 的属性。

如果您正在编写一个要在代码库中多次使用的行为(尤其是如果您正在构建一个供他人使用的框架),描述符通常是一个更好的选择。您可以用属性做的任何事情都可以用自定义描述符来完成,但是您应该更喜欢属性,因为它们看起来更容易理解。如果你创建了一个行为,你应该小心确保它不会偏离其他开发者从 Python 代码中期望的行为太远。

发电机

生成器适用于希望提供无限(或非常长)的值流进行循环的情况。如果生成器的用户不需要保存所有先前值的记录,则可以使用它们来减少内存消耗。这种优势也可能是它们最大的缺点:除非消耗掉整个生成器,否则不能保证生成器函数中的代码能够执行。

不要使用生成器,除非是在需要生成一个只读取一次的项目列表的函数中,在生成过程预计会很慢的情况下,以及在您不确定调用者是否需要处理所有项目的情况下。

摘要

在这一章中我们已经做了很多:我们已经创建了一个新的包,引入了 ORM 和迁移框架,并且在幕后窥视了一些 Python 解释器用来确定当你访问一个对象的属性时会发生什么的深层魔法。我们还有一个有效的聚合流程,可以从每个传感器中提取数据并存储起来以备后用。

在下一章中,当我们看到如何在 Python 中实现异步编程以及何时异步编程是解决问题的合适方法时,我们将更深入地研究yield功能的复杂用法。

额外资源

我建议查看以下资源,以了解更多关于我们在本章中使用的技术。像往常一样,请随意阅读那些你感兴趣的或者与你的工作相关的内容。

七、并行和异步

开发人员发现自己面临的一个常见问题是,他们有一个花费大量时间等待某事发生的操作,以及其他不依赖于第一个操作结果的操作。当程序正在做其他事情时,等待缓慢的操作完成可能会令人沮丧。这是异步编程试图解决的基本问题。

这个问题在 IO 操作(如网络请求)期间变得最为明显。在我们的聚合过程中,我们有一个循环,它向各个端点发出 HTTP 请求,然后处理结果。这些 HTTP 请求可能需要一些时间来完成,因为它们通常涉及检查外部传感器并在几秒钟内查看值。如果每个请求需要 3 秒钟才能完成,那么检查 100 个传感器将意味着在所有处理时间之外还要等待 5 分钟。

另一种方法是我们将程序的某些方面并行化。并行化最自然的功能是涉及等待某个外部系统的步骤。如图 7-2 所示,如果图 7-1 中的三个等待步骤能够并行化,将会节省大量时间。

img/481001_1_En_7_Fig2_HTML.png

图 7-2

并行等待的逐步过程,解析不一定按顺序进行

img/481001_1_En_7_Fig1_HTML.png

图 7-1

连接到三台传感器服务器并下载其数据的分步过程

当然,计算机对一次可以处理多少网络请求有实际的限制。任何将文件复制到外部硬盘的人都知道,一些存储介质比并行存储介质更适合处理多个顺序访问。最适合并行编程的情况是在需要执行的 IO 绑定和 CPU 绑定操作之间达到平衡的时候。如果强调 CPU 限制,唯一可能的速度提升就是提交更多的资源。另一方面,如果有太多的 IO 发生,我们可能不得不限制并发任务的数量,以避免处理任务的积压。

非阻塞 IO

用 Python 编写异步函数的最简单的方法,也是长久以来一直可行的方法,是编写使用非阻塞 IO 操作的函数。非阻塞 IO 操作是标准 IO 操作的变体,它在操作开始时立即返回,而不是在操作完成时返回的正常行为。

一些库可能将这些用于底层操作,比如从套接字读取,但是它们很少用于更复杂的设置或者被大多数 Python 开发人员使用。没有广泛使用的库允许开发人员利用 HTTP 请求的非阻塞 IO,所以我不能推荐它作为管理 web 服务器同时连接问题的实用解决方案。不过,这是一种在 Python 2 时代更常用的技术,看起来很有趣,因为它有助于我们理解更现代的解决方案的优点和缺点。

我们将在这里查看一个示例实现,以便我们可以看到代码必须如何构造才能利用这一点的差异。实现依赖于标准库的select.select(...)函数,它是select(2)系统调用的包装器。当给定一个类似文件的对象列表(包括套接字和子进程调用)时,select返回那些准备好读取数据的对象, 1 或阻塞,直到至少有一个准备好。

select代表异步代码的关键思想,即我们可以并行等待多件事情,但有一个处理阻塞的函数,直到一些数据准备好。阻塞行为从依次等待每个任务变为等待多个并发请求中的第一个。非阻塞 IO 进程的关键是一个阻塞的函数,这似乎有悖常理,但其目的并不是完全消除阻塞,而是将阻塞转移到我们没有其他事情可做的时候。

堵不是坏事;这使得我们的代码有一个易于理解的执行流程。如果没有连接就绪时select(...)没有阻塞,我们就必须引入一个循环来重复调用select(...),直到连接就绪。立即阻塞的代码更容易理解,因为它不必处理变量是尚未准备好的未来结果的占位符的情况。select 方法通过将阻塞推迟到稍后的时间点来牺牲程序流中的一些天真的清晰性,但是它允许我们利用并行等待。

Caution

以下示例函数非常乐观;它们不是符合标准的 HTTP 函数,并且它们对服务器的行为做了许多假设。这是故意的;它们在这里是为了说明一种方法,而不是推荐在现实世界中使用的代码。对于教学和比较目的来说,它足够好,仅此而已。

清单 7-1 显示了一个程序的例子,它发出一些非阻塞 IO HTTP 请求。我们代码的 HTTP 处理和这个示例最显著的区别是增加了两个额外的函数——执行 HTTP 请求和响应动作的函数。像这样分割逻辑使得这种方法没有吸引力,但是重要的是要记住在请求包中有这些函数的等价物;我们在这里看到它们只是因为我们在寻找一个没有库可以依靠的方法。

import datetime
import io
import json
import select
import socket
import typing as t
import urllib.parse

import h11

def get_http(uri: str, headers: t.Dict[str, str]) -> socket.socket:
    """Given a URI and a set of headers, make a HTTP request and return the
    underlying socket. If there were a production-quality implementation of
    nonblocking HTTP this function would be replaced with the relevant one
    from that library."""
    parsed = urllib.parse.urlparse(uri)
    if parsed.port:
        port = parsed.port
    else:
        port = 80
    headers["Host"] = parsed.netloc
    sock = socket.socket()
    sock.connect((parsed.hostname, port))
    sock.setblocking(False)

    connection = h11.Connection(h11.CLIENT)
    request = h11.Request(method="GET", target=parsed.path, headers=headers.items())

    sock.send(connection.send(request))
    sock.send(connection.send(h11.EndOfMessage()))
    return sock

def read_from_socket(sock: socket.socket) -> str:
    """ If there were a production-quality implementation of nonblocking HTTP
    this function would be replaced with the relevant one to get the body of
    the response if it was a success or error otherwise. """
    data = sock.recv(1000000)
    connection = h11.Connection(h11.CLIENT)
    connection.receive_data(data)

    response = connection.next_event()
    headers = dict(response.headers)
    body = connection.next_event()
    eom = connection.next_event()

    try:
        if response.status_code == 200:
            return body.data.decode("utf-8")
        else

:
            raise ValueError("Bad response")
    finally:
        sock.close()

def show_responses(uris: t.Tuple[str]) -> None:
    sockets = []
    for uri in uris:
        print(f"Making request to {uri}")
        sockets.append(get_http(uri, {}))
    while sockets:
        readable, writable, exceptional = select.select(sockets, [], [])
        print(f"{ len(readable) } socket(s) ready")
        for request in readable:
            print(f"Reading from socket")
            response = read_from_socket(request)
            print(f"Got { len(response) } bytes")
            sockets.remove(request)

if __name__ == "__main__":
    show_responses([
        "http://jsonplaceholder.typicode.com/posts?userId=1",
        "http://jsonplaceholder.typicode.com/posts?userId=5",
        "http://jsonplaceholder.typicode.com/posts?userId=8",
    ])

Listing 7-1Optimistic nonblocking HTTP functions – nbioexample.py

使用 Python 解释器运行该文件的结果将是获取这三个 URL,然后在它们的数据可用时读取它们,如下所示:

> pipenv run python .\nbioexample.py
Making request to http://jsonplaceholder.typicode.com/posts?userId=1
Making request to http://jsonplaceholder.typicode.com/posts?userId=5
Making request to http://jsonplaceholder.typicode.com/posts?userId=8
1 socket(s) ready
Reading from socket
Got 27520 bytes
1 socket(s) ready
Reading from socket
Got 3707 bytes
1 socket(s) ready
Reading from socket
Got 2255 bytes

get_http(...)函数是创建套接字的函数。它解析提供给它的 URL,并设置一个 TCP/IP 套接字来连接到该服务器。这确实涉及到一些阻塞 IO,特别是任何 DNS 查找和套接字设置操作,但是与等待主体的时间相比,这些相对较短,所以我没有试图使它们成为非阻塞的。

然后,该函数将这个套接字设置为非阻塞,并使用h11库生成一个 HTTP 请求。仅仅通过字符串操作生成 HTTP 请求 2 是完全可能的,但是这个库极大地简化了我们的代码。

一旦套接字上有可用的数据,我们就调用read_from_socket(...)函数。它假设数据少于 1000000 字节,并且表示一个完整的响应, 3 然后使用h11库将其解析为表示响应的头和主体的对象。我们用它来确定请求是否成功,并返回响应的主体或引发一个ValueError。数据被解码为 UTF-8,因为那是 Flask 在另一端为我们生成的。用正确的字符集解码是很重要的;这可以通过提供一个定义了字符集的头来实现,也可以通过其他一些关于字符集的保证来实现。由于我们还编写了服务器代码,我们知道我们正在使用 Flask 的内置 JSON 支持,它使用 Flask 的默认编码,即 UTF-8。

Tip

在某些情况下,您可能不确定使用的是哪种字符编码。chardet 库分析文本以建议最可能的编码,但这并不是万无一失的。该库或类似具有多种编码的 try/except 块的后备库仅适用于从不一致且不报告其编码的源加载数据的情况。在大多数情况下,您应该能够指定准确的编码,并且您必须这样做以避免细微的错误。

使我们的代码不阻塞

为了将前面的函数集成到我们的代码库中,我们代码中的其他函数需要一些更改,如清单 7-2 所示。现有的get_data_points(...)功能将需要分成connect_to_server(...)prepare_datapoints_from_response(...)功能。因此,我们将 socket 对象暴露给add_data_from_sensors(...)函数,允许它使用select,而不仅仅是在每个服务器上循环。

def connect_to_server(server: str, api_key: t.Optional[str]) -> socket.socket:
    if not server.endswith("/"):
        server += "/"
    url = server + "v/2.0/sensors/"
    headers = {}
    if api_key:
        headers["X-API-KEY"] = api_key

    return get_http(url, headers=headers)

def prepare_datapoints_from_response(response: str) -> t.Iterator[DataPoint]:
    now = datetime.datetime.now()
    json_result = json.loads(response)
    if "sensors" in json_result:
        for value in json_result["sensors"]:
            yield DataPoint(
                sensor_name=value["id"], collected_at=now, data=value["value"]
            )
    else:
        raise ValueError(
            f"Error loading data from stream: " + json_result.get("error", "Unknown")
        )

def add_data_from_sensors(
    session: Session, servers: t.Tuple[str], api_key: t.Optional[str]
) -> t.Iterable[DataPoint]:
    points: t.List[DataPoint] = []
    sockets = [connect_to_server(server, api_key) for server in servers]
    while sockets:
        readable, writable, exceptional = select.select(sockets, [], [])
        for request in readable:
            # In a production quality implementation there would be
            # handling here for responses that have only partially been
            # received.
            value = read_from_socket(request)
            for point in prepare_datapoints_from_response(value):
                session.add(point)
                points.append(point)
            sockets.remove(request)
    return points

Listing 7-2Additional glue functions

这听起来可能微不足道,但这是决定不在生产代码中使用这种 HTTP 请求方法的充分理由。在我看来,如果没有一个库来简化 API,那么使用非阻塞套接字所增加的认知负荷是过多的。理想的方法是不对程序流引入任何更改,但是最小化更改有助于保持代码的可维护性。这种实现将原始套接字泄漏到应用函数中的事实是不可接受的。

总的来说,虽然这种方法确实减少了等待时间,但是它要求我们对代码进行重大的重构,并且它只在等待步骤中提供了节省,而不是在解析阶段。非阻塞 IO 是一种有趣的技术,但是它只适用于例外情况,并且需要对程序流进行重大修改,以及放弃所有公共库来实现最基本的结果。我不推荐这种做法。

多线程和多重处理

更常见的方法是将工作负载分成多个线程或进程。线程允许同时处理逻辑子问题。它们可能是 CPU 受限的,也可能是 IO 受限的。在这个模型中,一组结果的解析可能发生在等待另一组结果之前,因为整个检索过程被分成一个新的线程。每个任务都是并行运行的,但是在一个线程中,所有的事情都是顺序运行的(如图 7-3 所示),函数照常阻塞。

img/481001_1_En_7_Fig3_HTML.png

图 7-3

使用线程或多个进程时的并行任务

一个线程中的代码总是按顺序执行,但是当多个线程同时运行时,不能保证它们的执行以任何有意义的方式同步。更糟糕的是,不能保证不同线程中的代码执行与语句边界对齐。当两个线程访问同一个变量时,不能保证先执行动作:它们可能会重叠。Python 用来执行用户函数的内部低级“字节码”是 Python 中并行性的构建块,而不是语句。

低级线程

Python 中线程的最底层接口是threading.Thread对象,它有效地将函数调用包装到一个新线程中。线程的动作可以通过传递一个函数作为target=参数或者通过子类化threading.Thread并定义一个run()方法来定制,如表 7-1 所示。

表 7-1

为线程执行提供代码的两种方法

| `import threading``def helloworld():``print("Hello world!")``thread = threading.Thread(``target=helloworld,``name="helloworld"``)``thread.start()``thread.join()` | `import threading``class HelloWorldThread(threading.Thread):``def run(self):``print("Hello world!")``thread = HelloWorldThread(name="helloworld")``thread.start()``thread.join()` |

start()方法开始执行线程;join()方法阻塞执行,直到该线程完成。name参数主要用于调试性能问题,但是如果您曾经手动创建过线程,那么总是设置一个名称是一个好习惯。

线程没有返回值,所以如果它们需要返回一个计算出的值,这可能会很棘手。传回值的一种方式是使用一个可变对象,它可以在适当的位置改变,或者,如果使用子类方法,在线程对象上设置一个属性。

当只有一个简单的返回类型时,线程对象上的属性是一个很好的方法,比如一个布尔成功值,或者一个计算的结果。当线程在做一件不连续的工作时,这是一个很好的选择。

当您有多个线程时,可变对象是最合适的,每个线程处理一个常见问题的一部分,例如,从一组 URL 收集传感器数据,每个线程负责一个 URL。这个对象非常适合这个目的。

Exercise 7-1: Write a Wrapper To Return via a Queue

与其直接调整函数,不如编写一些代码来包装任意函数,并将其结果存储在一个队列中,而不是直接返回,以允许函数像线程一样干净地运行。如果你卡住了,回头看看第五章和如何写一个接受参数的装饰器。

函数return_via_queue(...)应该如下所示:

from __future__ import annotations
...

def add_data_from_sensors(
    session: Session, servers: t.Tuple[str], api_key: t.Optional[str]
) -> t.Iterable[DataPoint]:
    points: t.List[DataPoint] = []
    q: queue.Queue[t.List[DataPoint]] = queue.Queue()
    wrap = return_via_queue(q)
    threads = [
        threading.Thread(target=wrap(get_data_points), args=(server, api_key))
        for server in servers
    ]
    for thread in threads:
        # Start all threads
        thread.start()
    for thread in threads:
        # Wait for all threads to finish
        thread.join()
    while not q.empty():
        # So long as there's a return value in the queue, process one # thread's results
        found = q.get_nowait()
        for point in found:
            session.add(point)
            points.append(point)
    return points

您还必须调整get_data_points(...)函数来返回一列DataPoint对象,而不是它们的迭代器,或者在包装器函数中进行等效的转换。这是为了确保在线程将数据返回给主线程之前,所有数据都在线程中得到处理。因为生成器直到值被请求时才产生它们的值,所以我们需要确保请求发生在线程内。

本章的代码示例中提供了包装器方法的示例实现和该程序的简单线程版本。

关于 __ 未来 _ _ 进口的说明

from __future__ import example这样的语句是启用特性的方式,这些特性将成为 Python 未来版本的一部分。它们必须在 Python 文件的最顶端,前面没有其他语句。

在这种情况下,行q: queue.Queue[t.List[DataPoint]] = queue.Queue()就是问题。标准库中的queue.Queue对象不是 Python 3.8 中的泛型类型,所以它不能接受它所包含的对象类型的类型定义。这一遗漏在 Python 中被跟踪为 bug 33315,这里有理由不愿意添加新的typing.Queue类型或调整内置类型。

尽管如此,mypy 将queue.Queue视为泛型类型;只是 Python 解释器没有。有两种方法可以解决这个问题,一种是使用基于字符串的类型提示,这样 Python 解释器就不会试图计算queue.Queue[...]而失败

    q: "queue.Queue[t.List[DataPoint]]" = queue.Queue()

或者通过使用来自__future__annotations选项,启用为 Python 4 计划的类型注释解析逻辑。这个逻辑阻止 Python 在运行时解析注释,并且是前面的示例中采用的方法。

这种低级别的线程对用户来说一点也不友好。正如我们在前面的练习中看到的,可以编写一个包装器代码,使函数在线程环境中不变地工作。也可以为threading.Thread对象编写一个包装器,自动包装被调用的函数,并自动从内部队列中检索结果,然后无缝地返回给程序员。

幸运的是,我们不必在生产代码中编写这样的特性;Python 标准库中内置了一个助手:concurrent.futures.ThreadPoolExecutorThreadPoolExecutor管理使用中的线程数量,允许程序员限制一次执行的线程数量。

使用ThreadPoolExecutor对一个 hello world 线程的等效调用是

from concurrent.futures import ThreadPoolExecutor

def helloworld():
    print("Hello world!")

with ThreadPoolExecutor() as pool:
    pool.submit(helloworld)

这里,我们看到一个上下文管理器,它定义了线程池活动的时间段。由于没有将max_threads参数传递给执行器,Python 根据运行程序的计算机上可用的 CPU 数量来选择线程数量。

一旦进入上下文管理器,程序就向线程池提交函数调用。可以多次调用pool.submit(...)函数来调度额外的任务,其结果是一个表示该任务的Future对象。使用过现代 JavaScript 的开发人员对未来会非常熟悉;它们是代表将来某个时刻会出现的值(或错误)的对象。result()方法返回提交的函数返回的任何值。如果该函数引发了一个异常,那么当调用result()方法时也会引发相同的异常。

from concurrent.futures import ThreadPoolExecutor

def calculate():
    return 2**16

with ThreadPoolExecutor() as pool:
    task = pool.submit(calculate)

>>> print(task.result())
65536

Caution

如果不访问未来的 result()方法,那么它引发的任何异常都不会传播到主线程。这可能会使调试变得困难,所以最好确保您总是能够访问结果,即使您从未将它赋给变量。

如果在with块中调用了result(),执行将会阻塞,直到相关任务完成。当with块结束时,执行会一直阻塞,直到所有的预定任务都完成,所以在with块结束后对 result 方法的调用总是立即返回。

字节码

为了理解 Python 中线程化的一些限制,我们需要看看解释器如何加载和运行代码的幕后。在这一节中,Python 代码可能用解释器使用的底层字节码进行了注释。这个字节码是一个实现细节,存储在.pyc文件中。它在最底层编码程序的行为。解释像 Python 这样复杂的语言并不是一项简单的任务,因此 Python 解释器将其对代码的解释缓存为一系列简单的操作。

当人们谈论 Python 时,他们通常谈论的是 C 编程语言中 Python 的实现 CPython。CPython 是引用的实现,因为它旨在成为人们在了解 Python 如何工作时的参考。还有其他实现,其中最流行的是 PyPy,这是一种用专门设计的类似 Python 的语言而不是 c 语言编写的 Python 实现。4CPython 和 pypypy 都将它们对 Python 代码的解释缓存为 Python 字节码。

Python 的另外两个实现值得一提:Jython 和 IronPython。这两者都将它们的解释缓存为字节码,但关键是它们使用了不同的字节码。Jython 使用与 Java 相同的字节码格式,IronPython 使用与. NET 相同的字节码格式。对于本章,当我们谈论字节码时,我们谈论的是 Python 字节码,因为我们是在 CPython 中如何实现线程的背景下看待它的。

一般来说,您不必担心字节码,但是了解它的作用对于编写多线程代码是很有用的。以下给出的样本是使用标准库中的dis模块 5 生成的。函数dis.dis(func)显示了给定函数的字节码,假设它是用 Python 而不是 C 扩展编写的。例如,sorted(...)函数是用 C 实现的,因此没有字节码可以显示。

为了演示这一点,让我们看一个函数及其反汇编(清单 7-3 )。该函数已经用来自dis.dis(increment)的反汇编结果进行了注释,该结果显示了文件中的行号、函数中指令的字节码偏移量、指令名和任何指令参数作为它们的原始值,括号中是 Python 表示。

num = 0

def increment():
    global num
    num += 1          # 5  0    LOAD_GLOBAL              0 (num)
                      #    2    LOAD_CONST               1 (1)
                      #    4    INPLACE_ADD
                      #    6    STORE_GLOBAL             0 (num)

    return None       # 10 8    LOAD_CONST               0 (None)
                      #    10   RETURN_VALUE

Listing 7-3A simple function to increment a global variable

num += 1看起来像一个原子操作, 6 但是字节码显示底层解释器运行四个操作来完成它。我们不关心这四个指令是什么,只是我们不能相信我们的直觉,哪些操作是原子的,哪些不是。

如果我们连续运行这个增量函数 100 次,存储到num的结果将是100,这在逻辑上是有意义的。如果这个函数在一对线程中执行,就不能保证最后的结果是100。在这种情况下,只有当另一个线程正在运行LOAD_CONSTIN_PLACE_ADDSTORE_GLOBAL步骤时,没有线程执行LOAD_GLOBAL字节码步骤,才能找到正确的结果。Python 不能保证这一点,所以前面的代码不是线程安全的。

启动一个线程是有开销的,而且计算机会同时运行多个进程。尽管有两个线程可用,但这两个线程可能碰巧按顺序运行,或者它们可能同时启动,或者启动时间之间可能存在偏移。执行重叠的方式如图 7-4 所示。

img/481001_1_En_7_Fig4_HTML.png

图 7-4

两个线程同时执行 num += 1 的可能安排。只有最左边和最右边的例子产生正确的结果

那个女孩

然而,这有些简化了。CPython 有一个称为 GIL(Global Interpreter Lock)的特性,用于简化线程安全。 7 这个锁意味着一次只能有一个线程在执行 Python 代码。然而,这不足以解决我们的问题,因为 GIL 的粒度是字节码级别的,所以尽管没有两条字节码指令同时执行,解释器仍然可以在每条路径之间切换,从而导致重叠。因此,图 7-5 显示了螺纹如何重叠的更准确的表示。

img/481001_1_En_7_Fig5_HTML.png

图 7-5

GIL 激活时 num += 1 执行的可能安排。只有最左边和最右边产生正确的结果

看起来 GIL 在不保证正确结果的情况下消除了线程的优势,但它并不像看起来那么糟糕。我们将很快讨论它的好处,但是首先,我们应该解决这个否定线程优势的问题。严格来说,没有两条字节码指令可以同时运行。

字节码指令比 Python 的行要简单得多,允许解释器在任何给定的点上推理它正在采取什么动作。因此,它可以在安全的情况下允许多线程执行,比如在网络连接期间或等待从文件中读取数据时。

具体来说,并不是 Python 解释器做的所有事情都需要持有 GIL。它必须保存在字节码指令的开头和结尾,但是可以在内部释放。等待套接字有数据可供读取是不需要持有 GIL 就可以完成的事情之一。在发生 IO 操作的字节码指令中,GIL 可以被释放,解释器可以同时执行任何不需要持有 GIL 的代码,只要它在不同的线程中。一旦 IO 操作完成,它必须等待从获取它的线程那里重新获得 GIL,然后才能继续执行。

在这种情况下,代码永远不必等待 IO 函数完成,Python 会以设定的时间间隔中断线程,以公平地调度其他线程。默认情况下,这大约是每 0.005 秒一次,这是一个足够长的时间,我们的示例可以在我的计算机上正常运行。如果我们使用sys.setswitchinterval(...)函数手动告诉解释器更频繁地切换线程,我们会开始看到失败。

不同切换间隔的线程安全测试代码

if __name__ == "__main__":
    import concurrent.futures
    import sys
    for si in [0.005, 0.0000005, 0.0000000005]:
        sys.setswitchinterval(si)
        results = []
        for attempt in range(100):
            with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
                for i in range(100):
                    pool.submit(increment)
            results.append(num)
            num = 0
        correct = [a for a in results if a == 100]
        pct = len(correct) / len(results)
        print(f"{pct:.1%} correct at sys.setswitchinterval({si:.10f})")

在我的电脑上,运行这个的结果是

100.0% correct at sys.setswitchinterval(0.0050000000)
71.0% correct at sys.setswitchinterval(0.0000005000)
84.0% correct at sys.setswitchinterval(0.0000000005)

在我的测试中,默认行为 100%正确并不意味着它解决了问题。0.005 是一个精心选择的区间,对于大多数人来说,这个区间产生错误的几率较低。当你测试一个函数时,它碰巧能工作,这并不意味着它能保证在每台机器上都能工作。引入线程的代价是获得了相对简单的并发性,但没有关于共享状态的强有力的保证。

锁和死锁

通过强制执行字节码指令不重叠的规则,可以保证它们是原子的。对于相同的值,没有两个STORE字节码指令同时发生的风险,因为没有两个字节码指令可以真正同时运行。指令的执行可能会自动释放 GIL,并等待在其执行的部分重新获得它,但这与并行发生的任意两个指令不同。Python 使用这种原子性来构建线程安全的类型和同步工具。

如果您需要在线程之间共享状态,您必须用锁手动保护这个状态。锁是允许您防止您的代码与其他可能干扰的代码同时运行的对象。如果两个并发线程都试图获取锁,只有一个会成功。任何试图获取锁的其他线程都将被阻塞,直到第一个线程释放它。这是可能的,因为锁是用 C 代码实现的,这意味着它们的执行是作为一个字节码步骤发生的。等待锁变得可用并获得锁的所有工作都是响应单个字节码指令而执行的,这使它成为原子性的。

受锁保护的代码仍然可以被中断,但是在这些中断期间不会运行冲突的代码。当线程持有锁时,它们仍然可以被中断。如果线程被中断而试图获取同一个锁,那么它将无法这样做,并将暂停执行。在有两个线程的环境中,这意味着执行将直接返回到第一个函数。如果有一个以上的线程是活动的,它可能会首先传递给其他线程,但同样无法获取第一个线程的锁。

带锁定的增量功能

import threading

numlock = threading.Lock()
num = 0

def increment():
    global num
    with numlock:
        num += 1
    return None

在这个版本的函数中,名为numlock的锁用于保护读/写 num 值的操作。这个上下文管理器使得锁在执行传递到主体之前被获取,并且在主体之后的第一行之前被释放。尽管我们在这里增加了一些开销,但这是最小的,并且它保证了代码的结果是正确的,不管任何用户设置或不同的 Python 解释器版本。

带锁测试结果 num += 1

100.0% correct at sys.setswitchinterval(0.0050000000)
100.0% correct at sys.setswitchinterval(0.0000005000)
100.0% correct at sys.setswitchinterval(0.0000000005)

无论切换间隔是多长,这段代码都能找到正确的结果,因为组成num += 1的四个字节码指令保证作为一个块执行。在每四个块的前后都有一个额外的锁定字节码指令,如图 7-6 所示。

img/481001_1_En_7_Fig6_HTML.png

图 7-6

num += 1 在两个线程上的可能安排,在开始和结束时显示显式锁定

从线程池中正在使用的两个线程来看,with numlock :行可能会阻塞执行,也可能不会。两个线程都不需要做任何特殊的事情来处理这两种情况(立即获得锁或等待轮到),因此这是对控制流相对最小的更改。

困难在于确保所需的锁到位并且不存在矛盾。如果一个程序员定义了两个锁并同时使用它们,就有可能造成程序陷入死锁的情况。

僵局

考虑这样一种情况,我们在一个线程中递增两个数字,在另一个线程中递减它们,从而产生以下函数:

num = 0
other = 0

def increment():
    global num
    global other
    num += 1
    other += 1
    return None

def decrement():
    global num
    global other
    other -= 1
    num -= 1
    return None

这个程序遭遇了我们以前遇到的同样的问题;如果我们在ThreadPoolExecutor中安排这些功能,那么结果可能是不正确的。我们可能会考虑应用之前解决这个问题的相同的锁定模式,添加一个otherlock锁来补充我们已经创建的numlock锁,但是死锁的可能性潜伏在这段代码中。我们有三种方法来安排这些函数中的锁(如表 7-2 所示),其中一种方法会导致死锁。

表 7-2

同时更新两个变量的三种锁定方法

| *最小化锁定代码(防止死锁)*`num = 0``other = 0``numlock = \threading.Lock()``otherlock = \threading.Lock()``def increment():``global num``global other``with numlock:``num += 1``with otherlock:``other += 1``return None``def decrement():``global num``global other``with otherlock:``other -= 1``with numlock:``num -= 1``return None` | *以一致的顺序使用锁(防止死锁)*`num = 0``other = 0``numlock = threading.Lock()``otherlock = \threading.Lock()``def increment():``global num``global other``with numlock, otherlock:``num += 1``cother += 1``return None``def decrement():``global num``global other``with numlock, otherlock:``other -= 1``num -= 1``return None` | *以不一致的顺序使用锁(* ***导致死锁*** *)*`num = 0``other = 0``numlock = \threading.Lock()``otherlock = \threading.Lock()``def increment():``global num``global other``with numlock, otherlock:``num += 1``other += 1``return None``def decrement():``global num``global other``with otherlock, numlock:``other -= 1``num -= 1``return None` |

最好的选择是确保我们永远不会同时持有两把锁。这使得它们真正独立,因此没有死锁的风险。在这种模式下,线程永远不会等待获取锁,直到它们已经释放了之前持有的锁。

中间的实现同时使用两个锁。这不太好,因为它持有锁的时间超过了需要的时间,但有时代码不可避免地需要锁定两个变量。虽然前面的两个函数都可以编写为一次只使用一个锁,但是请考虑交换值的函数的情况:

def switch():
    global num
    global other
    with numlock, otherlock:
        num, other = other, num
    return None

这个函数要求numother在执行时都没有被另一个线程使用,所以它需要保持两个数字都被锁定。在increment()decrement()(和switch())函数中获取锁的顺序相同,所以每个函数都试图在otherlock之前获取numlock。如果两个线程在执行过程中是同步的,它们会同时尝试获取numlock,其中一个会阻塞。不会出现死锁。

最后一个例子展示了一个实现,其中decrement()函数中的锁的顺序被颠倒了。这确实很难注意到,但是会导致死锁。运行第三版increment()的线程有可能在运行减量的线程获得otherlock锁的同时获得numlock锁。现在,两个线程都在等待获取它们没有的锁,并且在它们获得丢失的锁之前都不能释放它们的锁。这会导致程序无限期挂起。

有几种方法可以避免这个问题。由于这是关于代码结构的逻辑断言,自然的工具是静态检查器,以确保您的代码不会颠倒获取锁的顺序。不幸的是,我不知道任何现有的 Python 代码检查的实现。

最直接的替代方法是使用一个锁来覆盖这两个变量,而不是单独锁定它们。尽管表面上看这很吸引人,但随着需要保护的对象数量的增加,它并不能很好地扩展。当另一个线程处理other变量时,一个锁对象会阻止对num变量的任何操作。跨独立函数共享锁会大大增加代码中的阻塞量,这会抵消线程带来的优势。

您可能会放弃获取锁的with numlock:方法,而直接调用锁的acquire()方法。虽然这允许您指定一个超时时间和一个错误处理程序,以防在超时时间内没有获得锁,但我不推荐这样做。随着错误处理程序的引入,这种变化使得代码的逻辑更难理解,以这种方式检测死锁的唯一适当的反应是引发异常。这会因为超时而减慢程序的速度,并且不能解决问题。这种方法在本地调试时可能很有用,允许您检查死锁期间的状态,但不应该考虑用于生产代码。

我的建议是,您应该使用所有这些方法来防止死锁。首先,你应该使用最小数量的锁来保证你的程序线程安全。如果您确实需要多个锁,您应该最小化它们被持有的时间,一旦共享状态被操纵,就释放它们。最后,您应该定义锁的顺序,并在获取锁时始终使用这个顺序。最简单的方法是总是按字母顺序获取锁。确保锁的固定顺序仍然需要手动检查您的代码,但是锁的每次使用都可以根据您的规则独立检查,而不是根据所有其他使用检查。

避免全局状态

避免全局状态并不总是可能的,但是在许多情况下,这是可能的。一般来说,如果两个函数都不依赖于共享变量的值,那么可以安排两个函数并行运行。 8 假设不是 100 个对increment()的调用和 100 个对decrement()的调用,而是 100 个对increment()的调用和 1 个对函数save_number_to_database()的调用。无法保证在调用save_number_to_database()之前increment()会完成多少次。保存的数字可能在 0 到 100 之间,这显然是没有用的。这些函数并行运行没有意义,因为它们都依赖于共享变量的值。

共享数据有两种主要的关联方式。共享数据可以用于跨多个线程整理数据,也可以用于在多个线程之间传递数据。

整理数据

我们的两个increment()decrement()函数只是简单的演示。它们通过加 1 或减 1 来操纵它们的共享状态,但是通常并行运行的函数会进行更复杂的操纵。例如,在apd.aggregation中,共享状态是我们拥有的传感器结果的集合,每个线程向该集合添加更多的结果。

有了这两个例子,我们可以将决定操作应该是什么和应用操作的工作分开。由于这只是我们应用需要访问共享状态的操作的阶段,这允许我们并行地进行任何计算或 IO 操作。然后每个线程返回结果,最后将结果合并在一起,如清单 7-4 所示。

import concurrent.futures
import threading

def increment():
    return 1

def decrement():
    return -1

def onehundred():
    tasks = []
    with concurrent.futures.ThreadPoolExecutor() as pool:
        for i in range(100):
            tasks.append(pool.submit(increment))
            tasks.append(pool.submit(decrement))
    number = 0
    for task in tasks:
        number += task.result()
    return number

if __name__ == "__main__":

    print(onehundred())

Listing 7-4Example of using task result to store intended changes

传递数据

到目前为止,我们讨论的例子都涉及到将工作委托给子线程的主线程,但是在处理来自早期任务的数据的过程中发现新任务是很常见的。例如,大多数 API 对数据进行分页,因此如果我们有一个获取 URL 的线程和一个解析响应的线程,我们需要能够将初始 URL 从主线程传递到获取线程,还需要将新发现的 URL 从解析线程传递到获取线程。

当在两个(或更多)线程之间传递数据时,我们需要使用队列,或者是queue.Queue或者是变体queue.LifoQueue。这些分别实现 FIFO和 LIFO9 和 队列。虽然我们以前只将Queue用作一个方便的、线程安全的数据容器,但现在我们将按预期使用它。

队列有四种主要方法。10get()put()方法是不言自明的,除了说如果队列是空的,那么get()方法阻塞,如果队列设置了最大长度并且是满的,那么put()方法阻塞。此外,还有一个task_done()方法,用于告诉队列一个项目已经被成功处理,还有一个join()方法,阻塞直到所有项目都被成功处理。join()方法通常由向队列添加项目的线程调用,以允许它等待直到所有工作完成。

因为如果队列当前是空的,那么get()方法就会阻塞,所以不可能在非线程代码中使用这个方法。但是,这确实使它们非常适合线程代码,在线程代码中,需要等到产生数据的线程使数据可用。

Tip

事先并不总是清楚一个队列中将存储多少项。如果在检索到最后一项后调用get(),那么它将无限期阻塞。这可以通过为 get 提供一个超时参数来避免,在这种情况下,它将在引发queue.Empty异常之前阻塞给定的秒数。更好的方法是发送一个 sentinel 值,比如 None。然后,代码可以检测这个值,并知道它不再需要检索新值。

如果我们构建一个线程程序来从 GitHub 公共 API 获取信息,我们需要能够检索 URL 并解析它们的结果。如果能够在获取 URL 的同时进行解析就好了,所以我们将在获取和解析函数之间拆分代码。

清单 7-5 展示了这样一个程序的例子,其中多个 GitHub repos 可以并行检索它们的提交。它使用三个队列,一个用于 fetch 线程的输入,一个用于 fetch 的输出和 parse 的输入,一个用于 parse 的输出。

from concurrent.futures import ThreadPoolExecutor
import queue
import requests
import textwrap

def print_column(text, column):
    wrapped = textwrap.fill(text, 45)
    indent_level = 50 * column
    indented = textwrap.indent(wrapped, " " * indent_level)
    print(indented)

def fetch(urls, responses, parsed):
    while True:
        url = urls.get()
        if url is None:
            print_column("Got instruction to finish", 0)
            return
        print_column(f"Getting {url}", 0)
        response = requests.get(url)
        print_column(f"Storing {response} from {url}", 0)
        responses.put(response)
        urls.task_done()

def parse(urls, responses, parsed):
    # Wait for the initial URLs to be processed
    print_column("Waiting for url fetch thread", 1)
    urls.join()

    while not responses.empty():
        response = responses.get()
        print_column(f"Starting processing of {response}", 1)

        if response.ok:
            data = response.json()
            for commit in data:
                parsed.put(commit)

            links = response.headers["link"].split(",")
            for link in links:
                if "next" in link:
                    url = link.split(";")[0].strip("<>")
                    print_column(f"Discovered new url: {url}", 1)
                    urls.put(url)

        responses.task_done()
        if responses.empty():
            # We have no responses left, so the loop will
            # end. Wait for all queued urls to be fetched
            # before continuing
            print_column("Waiting for url fetch thread", 1)
            urls.join()

    # We reach this point if there are no responses to process
    # after waiting for the fetch thread to catch up. Tell the
    # fetch thread that it can stop now, then exit this thread.
    print_column("Sending instruction to finish", 1)
    urls.put(None)

def get_commit_info(repos):
    urls = queue.Queue()
    responses = queue.Queue()
    parsed = queue.Queue()

    for (username, repo) in repos:
        urls.put(f"https://api.github.com/repos/{username}/{repo}/commits")

    with ThreadPoolExecutor() as pool:
        fetcher = pool.submit(fetch, urls, responses, parsed)
        parser = pool.submit(parse, urls, responses, parsed)
    print(f"{parsed.qsize()} commits found")

if __name__ == "__main__":
    get_commit_info(
        [("MatthewWilkes", "apd.sensors"), ("MatthewWilkes", "apd.aggregation")]
    )

Listing 7-5Threaded API client

运行这段代码会产生两列输出,由来自每个线程的消息组成。完整的输出太长,无法在此包含,但下面给出了一小部分作为演示:

Getting https://api.github.com/repos/MatthewW
ilkes/apd.aggregation/commits
Storing <Response [200]> from https://api.git
hub.com/repos/MatthewWilkes/apd.aggregation/c
ommits
                                                  Starting processing of <Response [200]>
                                                  Discovered new url: https://api.github.com/
                                                  repositories/188280485/commits?page=2
                                                  Starting processing of <Response [200]>
Getting https://api.github.com/repositories/1
88280485/commits?page=2
                                                  Discovered new url: https://api.github.com/
                                                  repositories/222268232/commits?page=2

通过检查来自每个线程的日志消息,我们可以查看它们的工作是如何并行调度的。首先,主线程设置必要的队列和子线程,然后等待所有线程完成。两个子线程一启动,fetch 线程就开始处理主线程传递的 URL,而 parse 线程在等待解析响应时会迅速暂停。

解析线程在没有工作时使用urls.join(),所以每当它用完工作时,它就等待,直到获取线程完成它发送的所有工作。这在图 7-7 中可见,因为解析行总是在提取行完成后恢复。

img/481001_1_En_7_Fig7_HTML.png

图 7-7

清单 7-5 中三个线程的时序图

fetch 线程不使用任何队列的join()方法,它使用get()来阻塞,直到有一些工作要做。这样,可以看到获取线程在解析线程仍在执行时恢复。最后,解析线程向获取线程发送一个 sentinel 值以结束,当两者都退出时,主线程中的线程池上下文管理器退出,执行返回到主线程。

其他同步原语

我们在前面的例子中使用的队列同步比我们前面使用的锁行为更复杂。事实上,在标准库中还有许多其他的同步原语。这些允许您构建更复杂的线程安全协调行为。

重入锁

Lock对象非常方便,但它不是唯一用于跨线程同步代码的系统。也许其他最重要的是可重入锁,它可以作为threading.RLock获得。可重入锁是可以被多次获取的锁,只要这些获取是嵌套的。

from concurrent.futures import ThreadPoolExecutor
import threading

num = 0

numlock = threading.RLock()

def fiddle_with_num():
    global num
    with numlock:
        if num == 4:
            num = -50

def increment():
    global num
    with numlock:
        num += 1
        fiddle_with_num()

if __name__ == "__main__":
    with ThreadPoolExecutor() as pool:
        for i in range(8):
            pool.submit(increment)
    print(num)

Listing 7-6An example of nested locking using RLocks

这样做的好处是,依赖于一个锁的函数可以调用其他依赖于同一个锁的函数,在第一个锁释放它之前,第二个函数不会阻塞。这大大简化了使用锁的 API 的创建。

清单输出示例 7-6

> python .\listing7-06-reentrantlocks.py
-46

情况

与我们目前使用的锁不同,条件声明变量已经准备好,而不是它正忙。队列在内部使用条件来实现get()put(...)join()的阻塞行为。条件允许比获取锁更复杂的行为。

条件是一种告诉其他线程该检查数据的方式,这些数据必须独立存储。等待数据的线程调用上下文管理器中条件的wait_for(...)函数,而提供数据的线程调用notify()方法。没有规定一个线程不能在不同的时间同时做这两件事;但是,如果所有线程都在等待数据,而没有一个线程在发送数据,那么就有可能引入死锁。

例如,当调用队列的get(...)方法时,代码立即通过其内部not_empty条件获取队列的单锁,然后检查队列的内部存储是否有任何可用数据。如果是,则返回一个项并释放锁。此时保持锁定可以确保没有其他用户可以同时检索该项,因此没有重复的风险。然而,如果内存中没有数据,那么就调用not_empty.wait()方法。这释放了单个锁,允许其他线程操作队列,并且直到条件通知已经添加了一个新项目时才重新获取锁并返回。

有一种叫做notify_all()notify()方法。标准的notify()方法只唤醒一个正在等待的线程,而notify_all()唤醒所有正在等待的线程。使用notify_all()代替notify()总是安全的,但是当预计只有一个线程将被解除阻塞时,notify()避免了唤醒多个线程。

一个条件本身仅足以发送一个信息位:该数据已经可用。为了实际检索数据,我们必须以某种方式存储它,比如队列的内部存储。

清单 7-7 中的例子创建了两个线程,每个线程从一个共享的data列表中取出一个数字,然后将这个数字以 2 为模推送到一个共享的results列表中。该代码使用两个条件来实现这一点,一个条件是确保有数据可供处理,另一个条件是确定何时应该关闭线程。

from concurrent.futures import ThreadPoolExecutor
import sys
import time
import threading

data = []
results = []
running = True

data_available = threading.Condition()
work_complete = threading.Condition()

def has_data():
    """ Return true if there is data in the data list """
    return bool(data)

def num_complete(n):
    """Return a function that checks if the results list has the length specified by n"""

    def finished():
        return len(results) >= n

    return finished

def calculate():
    while running:
        with data_available:
            # Acquire the data_available lock and wait for has_data
            print("Waiting for data")
            data_available.wait_for(has_data)
            time.sleep(1)
            i = data.pop()
        with work_complete:
            if i % 2:
                results.append(1)
            else:
                results.append(0)
            # Acquire the work_complete lock and wake listeners
            work_complete.notify_all()

if __name__ == "__main__":
    with ThreadPoolExecutor() as pool:
        # Schedule two worker functions
        workers = [pool.submit(calculate), pool.submit(calculate)]

        for i in range(200):
            with data_available:
                data.append(i)
                # After adding each piece of data wake the data_available lock
                data_available.notify()
        print("200 items submitted")

        with work_complete:
            # Wait for at least 5 items to be complete through the work_complete lock
            work_complete.wait_for(num_complete(5))

        for worker in workers:
            # Set a shared variable causing the threads to end their work
            running = False
        print("Stopping workers")

    print(f"{len(results)} items processed")

Listing 7-7An example program using conditions

清单输出示例 7-7

> python .\listing7-07-conditions.py
Waiting for data
Waiting for data
200 items submitted
Waiting for data
Waiting for data
Waiting for data
Stopping workers
Waiting for data
Waiting for data
7 items processed

障碍

屏障是 Python 中概念上最简单的同步对象。用已知数量的创建屏障。当一个线程调用wait()时,它会一直阻塞,直到等待的线程数量与关卡的参与方数量相同。也就是说,threading.Barrier(2)在第一次调用wait()时阻塞,但是第二次调用立即返回并释放第一次阻塞的调用。

当多个线程处理一个问题的多个方面时,障碍是有用的,因为它们可以防止工作积压。屏障允许您确保一组线程只与该组中最慢的成员运行得一样快。

在屏障的初始创建或任何wait()调用中可以包含超时。如果任何等待调用花费的时间超过其超时时间,那么所有等待线程都会引发一个BrokenBarrierException,任何试图等待该障碍的后续线程也会如此。

清单 7-8 中的例子演示了同步一组五个线程,每个线程都等待一段随机的时间,这样一旦最后一个线程准备好了,它们都会继续执行。

from concurrent.futures import ThreadPoolExecutor
import random
import time
import threading

barrier = threading.Barrier(5)

def wait_random():
    thread_id = threading.get_ident()
    to_wait = random.randint(1, 10)
    print(f"Thread {thread_id:5d}: Waiting {to_wait:2d} seconds")
    start_time = time.time()
    time.sleep(to_wait)
    i = barrier.wait()
    end_time = time.time()
    elapsed = end_time - start_time

    print(
        f"Thread {thread_id:5d}: Resumed in position {i} after {elapsed:3.3f} seconds"
    )

if __name__ == "__main__":
    with ThreadPoolExecutor() as pool:
        # Schedule two worker functions
        for i in range(5):
            pool.submit(wait_random)

Listing 7-8Example of using a barrier

清单输出示例 7-8

> python .\listing7-08-barriers.py
Thread 21812: Waiting  8 seconds
Thread 17744: Waiting  2 seconds
Thread 13064: Waiting  4 seconds
Thread 14064: Waiting  6 seconds
Thread 22444: Waiting  4 seconds
Thread 21812: Resumed in position 4 after 8.008 seconds
Thread 17744: Resumed in position 0 after 8.006 seconds
Thread 22444: Resumed in position 2 after 7.999 seconds
Thread 13064: Resumed in position 1 after 8.000 seconds
Thread 14064: Resumed in position 3 after 7.999 seconds

事件

事件是另一种简单的同步方法。任何数量的线程都可以调用事件上的wait()方法,该方法会一直阻塞,直到事件被触发。通过调用set()方法可以在任何时候触发事件,这将唤醒所有等待事件的线程。对wait()方法的任何后续调用都会立即返回。

与屏障一样,事件对于确保多线程保持同步非常有用,而不是一些线程抢在前面。事件的不同之处在于,它们只有一个线程来决定何时该组可以继续,因此它们非常适合于一个线程专用于管理其他线程的程序。

event 方法也可以使用clear()方法重置,因此将来对wait()的任何调用都将被阻塞。可以用is_set()方法来检查事件的当前状态。清单 7-9 中的例子使用一个事件将一组线程与一个主线程同步,这样它们等待的时间至少与主线程一样长,但不会更长。

from concurrent.futures import ThreadPoolExecutor
import random

import time
import threading

event = threading.Event()

def wait_random(master):
    thread_id = threading.get_ident()
    to_wait = random.randint(1, 10)
    print(f"Thread {thread_id:5d}: Waiting {to_wait:2d} seconds " f"(Master: {master})")
    start_time = time.time()
    time.sleep(to_wait)
    if master:
        event.set()
    else:
        event.wait()
    end_time = time.time()
    elapsed = end_time - start_time
    print(
        f"Thread {thread_id:5d}: Resumed after {elapsed:3.3f} seconds"
    )

if __name__ == "__main__":
    with ThreadPoolExecutor() as pool:

        # Schedule two worker functions
        for i in range(4):
            pool.submit(wait_random, False)
        pool.submit(wait_random, True)

Listing 7-9Example of using events to set a minimum wait time

清单示例控制台输出 7-9

> python .\listing7-09-events.py
Thread 19624: Waiting  9 seconds (Master: False)
Thread  1036: Waiting  1 seconds (Master: False)
Thread  6372: Waiting 10 seconds (Master: False)
Thread 16992: Waiting  1 seconds (Master: False)
Thread 22100: Waiting  6 seconds (Master: True)
Thread 22100: Resumed after 6.003 seconds
Thread 16992: Resumed after 6.005 seconds
Thread  1036: Resumed after 6.013 seconds
Thread 19624: Resumed after 9.002 seconds
Thread  6372: Resumed after 10.012 seconds

旗语

最后,信号量在概念上更复杂,但却是一个非常古老的概念,因此在许多语言中都很常见。信号量类似于锁,但是它可以被多个线程同时获取。创建信号量时,必须给它一个值。该值是可以同时获取的次数。

信号量对于确保依赖稀缺资源的操作(例如使用大量内存或开放网络连接的操作)不会在超过某个阈值的情况下并行运行非常有用。例如,清单 7-10 展示了等待随机时间的五个线程,但是一次只能有三个线程等待。

from concurrent.futures import ThreadPoolExecutor
import random
import time
import threading

semaphore = threading.Semaphore(3)

def wait_random():

    thread_id = threading.get_ident()
    to_wait = random.randint(1, 10)
    with semaphore:
        print(f"Thread {thread_id:5d}: Waiting {to_wait:2d} seconds")
        start_time = time.time()
        time.sleep(to_wait)

        end_time = time.time()
        elapsed = end_time - start_time
        print(
            f"Thread {thread_id:5d}: Resumed after {elapsed:3.3f} seconds"
        )

if __name__ == "__main__":
    with ThreadPoolExecutor() as pool:
        # Schedule two worker functions
        for i in range(5):
            pool.submit(wait_random)

Listing 7-10Example of using semaphores to ensure only one thread waits at once

清单示例控制台输出 7-10

> python .\listing7-10-semaphore.py
Thread 10000: Waiting 10 seconds
Thread 24556: Waiting  1 seconds
Thread 15032: Waiting  6 seconds

Thread 24556: Resumed after 1.019 seconds
Thread 11352: Waiting  8 seconds
Thread 15032: Resumed after 6.001 seconds
Thread  6268: Waiting  4 seconds
Thread 11352: Resumed after 8.001 seconds
Thread 10000: Resumed after 10.014 seconds
Thread  6268: Resumed after 4.015 seconds

ProcessPoolExecutors

正如我们已经看到使用ThreadPoolExecutor将代码的执行委托给不同的线程,这导致我们违反了 GIL 的限制,如果我们愿意放弃所有共享状态,我们可以使用ProcessPoolExecutor在多个进程中运行代码。

当在进程池中执行代码时,开始时可用的任何状态都可用于子进程。但是,两者之间没有协调。数据只能作为提交给池的任务的返回值传递回控制流程。对全局变量的更改不会以任何方式反映出来。

尽管多个独立的 Python 进程不受 GIL 规定的同一种一次一个的执行方法的约束,但它们也有很大的开销。对于 IO 绑定的任务(即,大部分时间都在等待,因此不持有 GIL 的任务),进程池通常比线程池慢。

另一方面,涉及大量计算的任务非常适合委托给子流程,尤其是那些长时间运行的任务,与并行执行的节省相比,设置的开销更小。

让我们的代码多线程化

我们要并行化的函数是get_data_points(...);当处理 1 个或 500 个传感器时,实现命令行和数据库连接的函数没有显著变化;没有特别的理由将它的工作分成线程。将这项工作放在主线程中可以更容易地处理错误和报告进度,所以我们只重写了add_data_from_sensors(...)函数。

使用 ThreadPoolExecutor 的 add_data_from_sensors 的实现

def add_data_from_sensors(
    session: Session, servers: t.Tuple[str], api_key: t.Optional[str]
) -> t.List[DataPoint]:

    threads: t.List[Future] = []
    points: t.List[DataPoint] = []
    with ThreadPoolExecutor() as pool:
        for server in servers:
            points_future = pool.submit(get_data_points, server, api_key)
            threads.append(points_future)
        for points_future in threads:
            points += handle_result(points_future, session)
    return points

def handle_result(execution: Future, session: Session) -> t.List[DataPoint]:
    points: t.List[DataPoint] = []
    result = execution.result()
    for point in result:
        session.add(point)
        points.append(point)
    return points

因为我们会在第一次调用result()方法之前将所有的任务提交给ThreadPoolExecutor,所以它们会被排队等待在线程中同时执行。触发阻塞的是result()方法和with阻塞的结尾;提交作业不会导致程序阻塞,即使您提交的作业多于可以同时处理的数量。

与原始线程方法或非阻塞 IO 方法相比,这种方法对程序流的干扰要小得多,但它仍然涉及到更改执行流,以处理这些函数现在处理Future对象而不是直接处理数据的事实。

异步

当谈到 Python 并发性时,AsyncIO 是房间里的大象,这主要是因为它是 Python 3 的旗舰特性之一。这是一种语言特性,它允许像非阻塞 IO 示例那样工作,但使用与ThreadPoolExecutor有点类似的 API。API 并不完全相同,但是提交任务和能够阻塞以等待结果的基本概念是两者共有的。

异步代码是协同多任务的。也就是说,代码永远不会被中断以允许另一个函数执行;只有当某个功能阻塞时,才会发生切换。这一改变使得推断代码将如何运行变得更加容易,因为像num += 1这样简单的语句不会被中断。

在使用 asyncio 时,您经常会看到两个新的关键字,即asyncawait关键字。async关键字将某些控制流块(特别是defforwith)标记为使用 asyncio 流,而不是标准流。这些块的含义仍然与标准同步 Python 中的相同,但是底层代码路径可能会有很大不同。

相当于ThreadPoolExecutor本身的是事件循环。当执行异步代码时,事件循环对象负责跟踪所有要执行的任务,并协调将它们的返回值传递回调用代码。

打算从同步上下文和异步上下文中调用的代码之间有严格的区别。如果您意外地从同步上下文中调用异步代码,您会发现自己使用的是协程对象而不是您期望的数据类型,如果您从异步上下文中调用同步代码,您可能会无意中引入阻塞 IO,从而导致性能问题。

为了加强这种分离,并允许 API 作者有选择地支持同步和异步使用他们的对象,async修饰符被添加到forwith中,以指定您正在使用异步兼容的实现。这些变量不能用于同步上下文或者没有异步实现的对象(比如元组或者列表,在async for的情况下)。

异步定义

我们可以像定义函数一样定义新的协程。但是,def关键字变成了async def。这些协程像其他协程一样返回值。因此,我们可以在一个 asyncio 方法中实现清单 7-3 中的相同行为,如清单 7-11 所示。

import asyncio

async def increment():
    return 1

async def decrement():
    return -1

async def onehundred():
    num = 0
    for i in range(100):
        num += await increment()
        num += await decrement()
    return num

if __name__ == "__main__":
    asyncio.run(onehundred())

Listing 7-11Example of concurrent increment and decrement coroutines

其行为方式相同:运行两个协程,检索它们的值,并根据这些函数的结果调整 num 变量。主要区别在于,这些协同程序不是提交给线程池,而是将onehundred()异步函数传递给事件循环来运行,该函数负责调用完成工作的其他协同程序。

当我们调用一个被定义为异步的函数时,我们会收到一个协程对象作为结果,而不是让函数执行。

async def hello_world():
    return "hello world"

>>> hello_world()
<coroutine object hello_world at 0x03DEDED0>

asyncio.run(...)函数是异步代码的主要入口点。它会一直阻塞,直到传递的函数以及该函数调度的所有其他函数都完成为止。结果是同步代码一次只能启动一个协同程序。

等待

关键字await是阻塞的触发器,直到异步函数完成。但是,这只会阻塞当前的异步调用堆栈。您可以同时执行多个异步函数,在这种情况下,在等待结果的同时执行另一个函数。

await关键字相当于ThreadPoolExecutor示例中的Future.result()方法:它将一个可获得的对象转换成它的结果。它可以出现在任何使用异步函数调用的地方;编写打印图 7-8 所示函数结果的三种变体中的任何一种都同样有效。

img/481001_1_En_7_Fig8_HTML.png

图 7-8

await 关键字的三种等效用法

一旦使用了 await,底层的 await 就会被使用。这是不可能的

data = get_data()
if await data:
    print(await data)

可应用对象是实现__await__()方法的对象。这是一个实现细节;你不需要写一个__await__()方法。相反,您将使用为您提供的各种不同的内置对象。例如,任何使用async def定义的协程都有一个__await__()方法。

除了协程之外,另一个常见的 awawait 是Task,它可以用asyncio.create_task(...)函数从协程中创建。通常的用法是用asyncio.run(...)调用一个函数,然后用asyncio.create_task(...)调度下一个函数。

async def example():
    task = asyncio.create_task(hello_world())
    print(task)
    print(hasattr(task, "__await__"))
    return await task

>>> asyncio.run(example())

<Task pending coro=<hello_world() running at <stdin>:1>>
True
'hello world'

任务是已经被调度用于并行执行的协程。当您await一个协程时,您让它被调度执行,然后立即阻塞等待它的结果。create_task(...)功能允许您在需要任务结果之前安排任务。如果您有多个操作要执行,每个操作都执行一些阻塞 IO,但是您直接await了协程,那么在前一个操作完成之前,一个操作不会被调度。将协程调度为任务首先允许它们并行运行,如表 7-3 所示。

表 7-3

用于并行等待的任务和裸协同程序的比较

| *直接等待协程*`import asyncio``import time``async def slow():``start = time.time()``await asyncio.sleep(1)``await asyncio.sleep(1)``await asyncio.sleep(1)``end = time.time()``print(end - start)``>>> asyncio.run(slow())``3.0392887592315674` | *首先转换为任务*`import asyncio``import time``async def slow():``start = time.time()``first = asyncio.create_task(asyncio.sleep(1))``second = asyncio.create_task(asyncio.sleep(1))``third = asyncio.create_task(asyncio.sleep(1))``await first``await second``await third``end = time.time()``print(end - start)``>>> asyncio.run(slow())``1.0060641765594482` |

有一些有用的便利函数来处理基于协程的调度任务,最著名的是asyncio.gather(...)。该方法接受任意数量的可应用对象,将它们调度为任务,等待它们,并按照它们的协同程序/任务最初给出的顺序返回它们的返回值元组的可应用对象。

当多个 awaitables 应该并行运行时,这非常有用:

async def slow():
    start = time.time()
    await asyncio.gather(
        asyncio.sleep(1),
        asyncio.sleep(1),
        asyncio.sleep(1)
    )
    end = time.time()
    print(end - start)

>>> asyncio.run(slow())
1.0132906436920166

异步用于

async for构造允许迭代一个对象,其中迭代器本身由异步代码定义。在同步迭代器上使用async for是不正确的,因为同步迭代器仅仅是在异步上下文中使用,或者恰好包含 awaitables。

我们使用的常见数据类型都不是异步迭代器。如果你有一个元组或者一个列表,那么你就使用标准的 for 循环,不管它们包含什么,也不管它们是用在同步还是异步代码中。

本节包含了异步函数中三种不同的循环方法的例子。类型提示在这里特别有用,因为这里的数据类型略有不同,它清楚地表明每个函数需要哪些类型。

清单 7-12 展示了一个可迭代的。它包含两个异步函数:一个协程返回一个数字 11 ,另一个将一个可迭代表的内容相加。也就是说,add_all(...)函数期望来自number(...)的协程(或任务)的标准迭代。numbers()功能是同步的;它返回一个包含两次调用number(...)的标准列表。

import asyncio
import typing as t

async def number(num: int) -> int:
    return num

def numbers() -> t.Iterable[t.Awaitable[int]]:
    return [number(2), number(3)]

async def add_all(numbers: t.Iterable[t.Awaitable[int]]) -> int:
    total = 0
    for num in numbers:
        total += await num
    return total

if __name__ == "__main__":
    to_add = numbers()
    result = asyncio.run(add_all(to_add))
    print(result)

Listing 7-12Looping over a list of awaitables

add_all(...)函数中,循环是标准的 for 循环,因为它在遍历一个列表。列表的内容是number(2)number(3)的结果,所以需要等待这两个调用来检索它们各自的结果。

另一种写法是颠倒 iterable 和 awaitable 之间的关系。也就是说,不是传递一个整型变量列表,而是传递一个整型变量列表的。这里,numbers()被定义为一个协程,它返回一个整数列表。

import asyncio
import typing as t

async def number(num: int) -> int:
    return num

async def numbers() -> t.Iterable[int]:
    return [await number(2), await number(3)]

async def add_all(nums: t.Awaitable[t.Iterable[int]]) -> int:
    total = 0
    for num in await nums:
        total += num
    return total

if __name__ == "__main__":
    to_add = numbers()
    result = asyncio.run(add_all(to_add))
    print(result)

Listing 7-13Awaiting a list of integers

numbers()协程现在负责等待单个number(...)协程。我们仍然使用标准的 for 循环,但是现在我们不是等待 for 循环的内容,而是等待我们正在循环的值。

对于这两种方法,第一个number(...)调用在第二个调用之前被等待,但是对于第一种方法,控制传递回两者之间的add_all(...)函数。在第二种情况下,只有在所有的数字都被单独等待并组合成一个列表后,控制权才会被传递回来。使用第一种方法,每个number(...)协程在需要时被处理,但是使用第二种方法,number(...)调用的所有处理都发生在第一个值被使用之前。

第三种方法是使用async for。为此,我们将清单 7-13 中的numbers()协程转换成一个生成器函数,从而得到清单 7-14 中的代码。这与在同步 Python 代码中使用的避免高内存使用率的方法相同,同样的代价是值只能迭代一次。

import asyncio
import typing as t

async def number(num: int) -> int:
    return num

async def numbers() -> t.AsyncIterator[int]:
    yield await number(2)
    yield await number(3)

async def add_all(nums: t.AsyncIterator[int]) -> int:
    total = 0
    async for num in nums:
        total += num
    return total

if __name__ == "__main__":
    to_add = numbers()
    result = asyncio.run(add_all(to_add))
    print(result)

Listing 7-14Asynchronous generator

我们仍然需要在numbers()方法中使用await关键字,因为我们想要迭代number(...)方法的结果,而不是结果的占位符。像第二个版本一样,这隐藏了等待来自sum(...)函数的单个number(...)调用的细节,而不是信任迭代器来管理它。然而,它也保留了第一个属性,即每个number(...)调用只在需要时才被评估:它们并不都被提前处理。

对于一个支持被 For 迭代的对象,它必须实现一个返回迭代器的__iter__方法。迭代器是一个对象,它实现了一个__iter__方法(返回自身)和一个__next__方法来推进迭代器。实现了__iter__但没有实现__next__的对象不是迭代器而是可迭代。Iterables 可以被迭代;迭代器也知道它们的当前状态。

同样,实现异步方法__aiter__的对象是一个AsyncIterable。如果__aiter__返回self并且还提供了一个__anext__异步方法,那么它就是一个AsyncIterator

一个对象可以实现所有四种方法,以支持同步和异步迭代。这只有在你实现一个行为类似于 iterable 的类时才有意义,无论是同步的还是异步的。创建异步 iterable 最简单的方法是使用异步函数的yield构造,这对于大多数用例来说已经足够了。

在前面所有的例子中,我们直接使用了协程。当函数指定它们在typing.Awaitable上工作时,我们可以确定如果我们传递任务而不是协程,相同的代码将会工作。第二个例子,我们在等待一个列表,相当于使用内置的asyncio.gather(...)函数。两者都返回一系列结果。因此,这可能是你最常看到的方法,尽管如清单 7-15 所示。

import asyncio
import typing as t

async def number(num: int) -> int:
    return num

async def numbers() -> t.Iterable[int]:
    return await asyncio.gather(
        number(2),
        number(3)
    )

async def add_all(nums: t.Awaitable[t.Iterable[int]]) -> int:
    total = 0
    for num in await nums:
        total += num
    return total

if __name__ == "__main__":
    to_add = numbers()
    result = asyncio.run(add_all(to_add))
    print(result)

Listing 7-15Using gather to process tasks in parallel

异步方式

with语句还有一个异步对应物async with,用于帮助编写依赖异步代码的上下文管理器。这在异步代码中很常见,因为许多 IO 操作都涉及安装和拆卸阶段。

就像async for使用__aiter__而不是__iter__一样,异步上下文管理器定义了__aenter____aexit__方法来替换__enter____exit__.,如果合适的话,对象可以再次选择实现所有四个方法来在两个上下文中工作。

在异步函数中使用同步上下文管理器时,可能会在正文的第一行之前和最后一行之后阻塞 IO。使用async with和兼容的上下文管理器允许事件循环在阻塞 IO 期间调度一些其他的异步代码。

我们将在接下来的两章中更详细地介绍使用和创建上下文管理器,但两者都等同于 try/finally 构造,但标准上下文管理器在其进入和退出方法中使用同步代码,而异步上下文管理器使用异步代码。

异步锁定原语

尽管异步代码不像线程那样容易受到并发安全问题的影响,但是仍然有可能编写出存在并发错误的异步代码。基于等待结果而不是线程被中断的交换模型可以防止大多数意外错误,但不能保证正确性。

例如,在清单 7-16 中,我们有一个我们查看线程时的增量示例的 asyncio 版本。在num +=行中有一个await,并引入了一个offset()协程来返回将加到 num 中的 1。这个offset()函数也使用asyncio.sleep(0)来阻塞几分之一秒,这模拟了阻塞 IO 请求的行为。

import asyncio
import random

num = 0

async def offset():
    await asyncio.sleep(0)
    return 1

async def increment():
    global num
    num += await offset()

async def onehundred():
    tasks = []
    for i in range(100):
        tasks.append(increment())
    await asyncio.gather(*tasks)
    return num

if __name__ == "__main__":
    print(asyncio.run(onehundred()))

Listing 7-16Example of an unsafe asynchronous program

尽管这个程序应该打印 100,但它也可以打印任何低至 1 的数字,这取决于事件循环对调度任务做出的决定。为了防止这种情况,我们需要将 await offset()调用移到不属于+=构造的部分,或者锁定num变量。

AsyncIO 提供线程库中LockEven t、ConditionSemaphore的直接等价物。这些变体使用相同 API 的异步版本,因此我们可以修复清单 7-17 中所示的事件函数。

import asyncio
import random

num = 0

async def offset():
    await asyncio.sleep(0)
    return 1

async def increment(numlock):
    global num
    async with numlock:
        num += await offset()

async def onehundred():
    tasks = []
    numlock = asyncio.Lock()

    for i in range(100):
        tasks.append(increment(numlock))
    await asyncio.gather(*tasks)
    return num

if __name__ == "__main__":
    print(asyncio.run(onehundred()))

Listing 7-17Example of asynchronous locking

也许同步原语的线程版本和异步版本的最大区别在于异步原语不能在全局范围内定义。更准确地说,它们只能从正在运行的协程中实例化,因为它们必须向当前事件循环注册自己。

使用同步库

到目前为止,我们编写的代码依赖于我们拥有一个完全异步的库和函数堆栈,以便从我们的异步代码中调用。如果我们引入一些同步代码,那么我们在执行它的时候会阻塞所有的任务。我们可以通过使用time.sleep(...)方法阻塞一段时间来演示这一点。早先我们使用asyncio.sleep(...)来建模一个长期运行的异步感知任务;混合这些让我们看看这样一个混合系统的性能:

import asyncio
import time

async def synchronous_task():
    time.sleep(1)

async def slow():
    start = time.time()
    await asyncio.gather(
        asyncio.sleep(1),
        asyncio.sleep(1),
        synchronous_task(),
        asyncio.sleep(1)
    )
    end = time.time()
    print(end - start)

>>> asyncio.run(slow())

2.006387243270874

在这种情况下,我们的三个异步任务都需要 1 秒钟,并且是并行处理的。阻塞任务也需要 1 秒,但它是串行处理的,这意味着总时间是 2 秒。为了确保所有四个函数并行运行,我们可以使用loop.run_in_executor(...)函数。这会分配一个ThreadPoolExecutor(或者您选择的另一个执行器)并在该上下文中运行指定的任务,而不是在主线程中。

import asyncio
import time

async def synchronous_task():
    loop = asyncio.get_running_loop()
    await loop.run_in_executor(None, time.sleep, 1)

async def slow():
    start = time.time()
    await asyncio.gather(
        asyncio.sleep(1),
        asyncio.sleep(1),
        synchronous_task(),
        asyncio.sleep(1)
    )
    end = time.time()
    print(end - start)

>>> asyncio.run(slow())
1.0059468746185303

run_in_executor(...)函数的工作原理是将问题切换到一个易于异步处理的问题上。它使用一个线程(或进程)来执行代码,而不是试图将任意 Python 函数从同步转换为异步,找到正确的位置将控制权交还给事件循环,在正确的时间被唤醒等等。由于线程和进程是一种操作系统结构,因此它们天生适合异步控制。这将需要与 asyncio 系统兼容的范围缩小到启动一个线程并等待它完成。

让我们的代码异步

让我们的代码在异步上下文中工作的第一步是选择一个函数作为异步函数链中的第一个。我们希望将同步和异步代码分开,所以我们需要在调用栈中选择一个足够高的位置,使得所有需要异步的东西都可以(可能是间接地)被这个函数调用。

在我们的代码中,get_data_points(...)函数是我们唯一想要在异步上下文中运行的函数。由add_data_from_sensors(...)调用,?? 本身由standalone(...)调用,依次由collect_sensor_data(...)调用。这四个函数中的任何一个都可以成为asyncio.run(...)的自变量。

collect_sensor_data(...)函数是 click 入口点,所以不能是异步函数。get_data_points(...)函数需要被多次调用,因此它比异步流的主入口点更适合协程。这就剩下standalone(...)add_data_from_sensors(...)

standalone(...)函数已经完成了数据库的设置;这也是设置事件循环的好地方。因此,我们需要让add_data_from_sensors(...)成为一个异步函数,并调整如何从standalone(...)调用它。

def standalone(
    db_uri: str, servers: t.Tuple[str], api_key: t.Optional[str], echo: bool = False
) -> None:
    engine = create_engine(db_uri, echo=echo)
    sm = sessionmaker(engine)
    Session = sm()
    asyncio.run(add_data_from_sensors(Session, servers, api_key))
    Session.commit()

我们现在需要改变底层函数的实现,使之不调用任何阻塞同步代码。目前,我们使用请求库进行 HTTP 调用,这是一个阻塞的同步库。

作为替代,我们将切换到aiohttp模块来发出 HTTP 请求。Aiohttp 是一个本地异步 http 库,支持客户端和服务器应用。该接口不像 requests 那样精致,但是非常有用。

API 中最大的区别是 HTTP 请求涉及许多上下文管理器,如下所示:

    async with aiohttp.ClientSession() as http:
        async with http.get(url) as request:
            result = await request.json()

顾名思义,ClientSession代表了具有共享 cookie 状态和 HTTP 头配置的会话的思想。在这种情况下,请求是通过 get 这样的异步上下文管理器发出的。上下文管理器的结果是一个对象,该对象具有可以等待以检索响应内容的方法。

不可否认,前面的结构比等效的 using requests 要冗长得多,它允许在许多地方让出执行流来绕过阻塞 IO。最明显的是await行,它在等待响应被检索并解析为 JSON 时放弃控制权。不太明显的是http.get(...)上下文管理器的入口和出口,它可以建立套接字连接,允许像 DNS 解析这样的事情不阻塞执行。当进入和退出一个ClientSession时,执行流也有可能被产出。

所有这些就是说,虽然前面的构造比使用请求的相同代码更冗长,但它确实允许透明地设置和拆除与 HTTP 会话相关的共享资源,并且是以不会显著减慢该过程的方式这样做的。

在我们的add_data_from_sensors(...)函数中,我们需要处理现在需要这个会话对象的事实,最好是在我们的多个请求之间共享客户端会话。我们还需要保留请求协程调用的记录,这样我们就可以并行调度它们并检索它们的数据。

async def add_data_from_sensors(
    session: Session, servers: t.Tuple[str], api_key: t.Optional[str]
) -> t.List[DataPoint]:
    todo: t.List[t.Awaitable[t.List[DataPoint]]] = []
    points: t.List[DataPoint] = []
    async with aiohttp.ClientSession() as http:
        for server in servers:
            todo.append(get_data_points(server, api_key, http))
        for a in await asyncio.gather(*todo):
            points += await handle_result(a, session)
    return points

在这个函数中,我们定义了两个变量,一个 awaitables 列表,每个变量返回一个DataPoint对象列表,以及一个在处理 awaitables 时填充的DataPoint对象列表。然后,我们设置ClientSession并遍历服务器,为每个服务器添加一个get_data_points(...)调用。在这个阶段,这些是协程,因为它们没有被调度为任务。我们可以依次等待它们,但是这样会导致每个请求按顺序发生。相反,我们使用asyncio.gather(...)将它们调度为任务,并允许我们迭代结果,每个结果都是一个DataPoint对象的列表。

接下来,我们需要将数据添加到数据库中。我们在这里使用 SQLAlchemy,它是一个同步库。对于生产质量的代码,我们需要确保这里没有阻塞的机会。下面的实现不能保证session.add(...)方法会因为数据与数据库会话同步而阻塞。

不应在生产代码中使用的 handle_result 占位符

async def handle_result(result: t.List[DataPoint], session: Session) -> t.List[DataPoint]:
    for point in result:
        session.add(point)
    return result

我们将在下一章探讨在并行执行环境中处理数据库集成的方法,但这对于原型来说已经足够好了。

最后,我们需要做获取数据的实际工作。该方法与同步版本有很大的不同,除了它也需要传入ClientSession,并且必须做一些小的更改以适应 HTTP 请求 API 中的差异。

使用 aiohttp 实现 get _ data _ points

async def get_data_points(server: str, api_key: t.Optional[str], http: aiohttp.ClientSession) -> t.List[DataPoint]:
    if not server.endswith("/"):
        server += "/"
    url = server + "v/2.0/sensors/"
    headers = {}
    if api_key:
        headers["X-API-KEY"] = api_key
    async with http.get(url) as request:
        result = await request.json()
        ok = request.status == 200
    now = datetime.datetime.now()
    if ok:
        points = []
        for value in result["sensors"]:
            points.append(
                DataPoint(
                    sensor_name=value["id"], collected_at=now, data=value["value"]
                )
            )
        return points
    else:
        raise ValueError(
            f"Error loading data from {server}: "
            + result.json().get("error", "Unknown")
        )

与多线程或多进程模型相比,这种方法有许多不同的选择。多进程模型允许真正的并发处理,多线程方法可以获得一些非常小的性能增益,这要归功于较少限制的切换保证,但在我看来,异步代码具有更自然的接口。

asyncio 方法的主要缺点是,只有使用异步库才能真正实现这些优点。通过结合使用 asyncio 和 threaded 方法,仍然可以使用其他库,这两种方法之间的良好集成使这变得很容易,但是将现有代码转换为异步方法有很大的重构需求,同样,首先要习惯于编写异步代码也有很大的学习曲线。

比较

本章附带的代码中有所有四种方法的实现,因此我们可以运行一个简单的基准来比较它们的速度。以这种方式对提议的优化进行基准测试总是很困难;除了真实世界的测试之外,很难从任何东西得到真实的数字,所以下面的内容应该有所保留。

这些数字是通过在一次调用中多次从同一个传感器提取数据而生成的。除了对这些调用进行计时的机器上的其他负载之外,这些数字是不现实的,因为它们不涉及查找许多不同目标的连接信息,并且因为返回所请求数据的服务器在它能够服务的同时请求的数量上是有限的。

从图 7-9 中可以看出,线程化和 asyncio 方法在耗时方面几乎没有区别。我们因其复杂性而拒绝的非阻塞 IO 方法也是可比较的。多进程方法明显较慢,但与其他三种方法相似。标准的同步方法的行为类似于仅从一个或两个传感器收集数据,但是较大的结果集很快变得病态,比并行方法花费更长的数量级。

我们应该从中获得的信息是,这种工作负载非常适合并行化。asyncio 在我们的基准测试中快了 20%,这一事实并不一定等同于它是一种更快的技术,只是在这个特定的测试中更快而已。未来对代码库的改变,以及不同的测试条件,很容易改变技术之间的关系。

img/481001_1_En_7_Fig9_HTML.png

图 7-9

使用不同的并行化方法从 1、2、5、10、20 或 50 个 HTTP APIs 加载数据所花费的时间

做出选择

在撰写本文时,Python 社区中流传着两个关于 asyncio 的恶意谎言。首先,asyncio 在并发性方面取得了“胜利”。第二是它不好,不应该使用。毫不奇怪,真相就在中间。Asyncio 对于很大程度上受限于 io 的网络客户端来说非常出色,但不是万能的。

在选择不同的方法时,首先要问自己的问题是,您的代码是将大部分时间用于等待 IO,还是将大部分时间用于处理数据。等待一会儿然后进行大量计算的任务不太适合 asyncio,因为它可以并行化等待,但不能并行化执行,从而留下大量 CPU 受限的任务需要执行。同样,它也不是线程池的天然选择,因为 GIL 会阻止各种线程真正并行运行。多进程部署有更高的开销,但是能够利用 CPU 绑定代码中的真正并行化。

如果任务等待的时间确实比执行代码的时间长,那么 asyncio 或基于线程的并行化方法可能是最佳选择。根据经验,我建议对调用服务器但自己不等待网络请求的应用使用 asyncio,对接受入站连接的应用使用进程池和线程池的组合。 12 表示这一点的决策树如图 7-10 所示。

img/481001_1_En_7_Fig10_HTML.jpg

图 7-10

客户机/服务器应用中并行化方法的决策树

这不是一个硬性规定;有太多的例外可以列出来,您应该考虑您的应用的细节并测试您的假设,但总的来说,我更喜欢针对服务器应用的抢占式多任务处理 13 的健壮且可预测的行为。

我们的传感器 API 端点完全是标准的 Python,但是通过 waste WSGI 服务器运行。WSGI 服务器为我们做出并发决策,用waitress-serve实例化一个四线程线程池来处理入站请求。

收集器进程在每次调用中都需要大量的等待,并且完全是客户端的,因此使用 asyncio 来实现其并发行为是一个很好的选择。

摘要

在本章中,我们已经了解了两种最常见的并行化类型,线程化和异步化,以及其他不太常用的方法。并发性是一个困难的话题,我们还没有讨论完使用 asyncio 可以实现的事情,但是我们现在将线程放在后面。

异步编程是一个非常强大的工具,所有 Python 程序员都应该知道,但线程和 asyncio 的权衡非常不同,一般来说,在任何给定的程序中,只有一个是有用的。

如果您需要用 Python 编写一个依赖于并发性的程序,我强烈建议您尝试不同的方法,找出最适合您的问题的方法。我也鼓励你确保理解我们在本章中使用的所有同步原语的用法,因为锁的适当使用可以决定一个缓慢而难以理解的程序和一个快速而直观编写的程序之间的差别。

额外资源

下面的链接包含了一些有用的背景信息,这些信息与我在本章中讨论的主题以及一些其他不太常见的方法有关:

八、高级异步

既然我们已经决定 asyncio 是一种适用于我们的聚合流程的技术,我们需要确保我们工作的代码是生产质量的。到目前为止,我们已经忽略了 apd.aggregation 代码库中的任何测试;是时候解决这个问题了,还有我们在前一章中顺便提到的阻塞数据库集成的问题。

测试异步代码

我们可以使用现有的工具来测试我们的异步代码,但是我们需要做一些小的调整来设置异步环境。一种方法是修改单个测试函数,通过包装函数调用asyncio.run(...)。这确保了测试系统是完全同步的,但是对于每一个单独的测试,都会建立一个事件循环,调度一个协同程序,并阻塞执行,直到它完成。

我们可以通过编写一个包含任何异步安装和拆卸的异步函数来实现这一点;然后,任何同步设置、拆卸和断言都被添加到主测试函数中。

def test_get_data_points_fails_with_bad_api_key(self, http_server):
    async def wrapped():
        async with aiohttp.ClientSession() as http:
            return await collect.get_data_points(http_server, "incorrect", http)

    with pytest.raises(
        ValueError,
        match=f"Error loading data from {http_server}: Supply API key in " f"X-API-Key header",
    ):
        asyncio.run(wrapped())

前面的例子使用了一个http_server fixture,它将 URL 返回给一个 API 服务器,然后创建一个协程来建立一个 aiohttp 会话并调用测试中的方法get_data_points(...)。这里在清晰度方面有很大的牺牲:代码是无序的。首先列出异步代码,接着是断言,然后是同步代码。通常,我们根据程序的流程更自由地混合代码和断言。尽管我们可以将一些断言工作转移到测试的异步部分,但是总会有额外的代码为内部函数设置异步环境。

另一种方法是使用 pytest 插件来自动处理包装。这样做,使得混合标准测试方法和测试协程成为可能。任何使用 pytest 标记系统标记为 asyncio 测试的协程都是在异步环境中执行的,所有包装工作都在插件中透明地进行。

使用插件允许更清晰的执行流,不需要任何样板代码来弥合同步和异步代码之间的差距,如下所示:

@pytest.mark.asyncio
async def test_get_data_points_fails_with_bad_api_key(self, http_server):
    with pytest.raises(
        ValueError,
        match=f"Error loading data from {http_server}: Supply API key " f"in X-API-Key header",
    ):
        async with aiohttp.ClientSession() as http:
            await collect.get_data_points(http_server, "incorrect", http)

Caution

我们在这里引入了一个依赖项,尽管它只在运行测试时适用。我们没有在setup.cfg中列出测试依赖项,只是选择将它们作为开发依赖项包含在 Pipfile 中。因此,我们可以用

pipenv install --dev pytest-asyncio

这在大多数情况下是没问题的,但是在较大的代码库中,您可能需要测试组件和版本的组合,而不是只有一个 Pipfile。可以在setup.cfg中列出测试依赖关系,以避免重复。为此,创建一个名为“test”的新的[options.extras_require]行,并在那里列出测试依赖项。有一个遗留的 setuptools 特性叫做 tests_require,你可能有时会看到,但是我总是推荐一个额外的,因为它提供了对是否安装测试依赖的更明确的控制。

测试我们的代码

编写异步测试函数的能力是一个很好的开始,但是我们还需要设置一些装置来提供聚合代码传感器端点进行询问。对此有两种方法:我们可以提供模拟数据作为聚合测试的一部分,或者让聚合测试依赖于服务器代码并启动一个真实的(尽管是临时的)服务器。

这两种选择都不是特别有吸引力的前景;它们都有明显的缺点。如果我们编写测试来检查一个已知的 HTTP 响应,那么每次底层 API 改变时都需要更新这个响应。希望这不会经常发生,但是当人们阅读测试代码时,不透明的 JSON 块很难推理。

通常,操作大量数据的测试是通过复制输入数据,运行测试,然后使用输出数据编写一个assert语句来编写的。这是一种有点危险的做法,因为它测试的是确保什么都没有改变,而不是检查某件事情是否正确。

另一种方法是运行后端服务器并连接到后端服务器,这是一种更现实的方法,可以避免在测试中使用原始 JSON,但是这增加了测试对服务器代码的依赖性。因此,所有的测试都需要创建一个套接字连接,并且增加了服务器安装和拆卸的开销。

这个困境和我们在第五章中面临的问题是一样的,我们必须在测试命令行界面的输出和直接测试传感器的功能之间做出选择。一旦我们认识到这一点,决定做什么就容易多了。功能测试为检查事情是否按预期运行提供了广泛的基础,但是更快、更专业的测试更容易开发。至关重要的是,两者都有助于我们区分底层平台发生变化时的测试失败和更快的测试对真实行为建模不佳时的测试失败。

因此,我将添加相同的标记来将这些测试声明为功能测试。在第五章中,我们在单个测试方法上用@pytest.mark.functional做了这些,还有一个定义了功能标记的pytest.ini文件。因为我们对这个包的所有功能测试都在一个不包含任何非功能测试的模块中,所以我们可以标记整个模块。通过设置pytestmark模块变量来引用标记,类或模块可以有一个标记,如下所示:

import pytest

pytestmark = [pytest.mark.functional]

拆除测试服务器和 pytest 装置

对于我们的测试设置,我们需要做的第一件事是实例化一个测试服务器。服务器需要提供 HTTP 套接字,因为我们正在测试发出 HTTP 请求的代码。我们需要一个监听我们可以指定的端口的服务器,这样我们可以避免与其他软件的端口冲突;我们可能需要多台服务器同时运行,以测试数据是否可以从多个端点聚合。

在我们最初的apd.sensors包中,我们创建了一个set_up_config(...)函数,它接受配置值和一个可选的app参数,然后将这些配置变量应用到应用中。如果没有提供app,那么使用默认的应用(在已知的 URL 上设置各种 API 版本)。

为了创建具有不同配置的多个 flask 应用,我们需要能够创建功能上等同于默认应用的 flask 应用,这对于我们的测试来说意味着它们必须在/v/2.0上提供 v2.0 API。我们可以通过复制来自apd.sensors的一些代码来创建一个新的get_independent_flask_app(...)函数,如清单 8-1 所示。

from concurrent.futures import ThreadPoolExecutor
import typing as t
import wsgiref.simple_server

import flask
import pytest

from apd.sensors.wsgi import v20
from apd.sensors.wsgi import set_up_config

def get_independent_flask_app(name: str) -> flask.Flask:
    """ Create a new flask app with the v20 API blueprint loaded, so multiple copies
    of the app can be run in parallel without conflicting configuration """
    app = flask.Flask(name)
    app.register_blueprint(v20.version, url_prefix="/v/2.0")
    return app

def run_server_in_thread(name: str, config: t.Dict[str, t.Any], port: int) -> t.Iterator[str]:
    # Create a new flask app and load in required code, to prevent config # conflicts
    app = get_independent_flask_app(name)
    flask_app = set_up_config(config, app)
    server = wsgiref.simple_server.make_server("localhost", port, flask_app)

    with ThreadPoolExecutor() as pool:
        pool.submit(server.serve_forever)
        yield f"http://localhost:{port}/"
        server.shutdown()

@pytest.fixture(scope="session")
def http_server() -> t.Iterator[str]:
    yield from run_server_in_thread(
        "standard", {"APD_SENSORS_API_KEY": "testing"}, 12081
    )

Listing 8-1Helper functions and a fixture to run a HTTP server

这个函数允许我们创建具有独立配置的 flask 应用,但所有应用都在正确的 URL 上包含 v2.0 API。run_server_in_thread(...)实用函数是一个更高级的函数,用于创建 flask 应用,对其进行配置,并使其服务于请求。

Note

对于是否值得向测试方法中添加类型定义,还存在一些争议。我发现 PyTest 对类型支持的缺乏移除了大部分的实用程序,但是它在很大程度上依赖于您的代码库。如果你对类型有很好的了解,你会发现这是值得的。我个人推荐类型检查实用函数,在测试方法和 fixtures 中添加返回类型注释。这通常足以确保您的测试助手在使用时进行类型检查,但是我建议在测试方法的类型方面更加务实,我经常跳过这一点。

为了服务请求,我们将使用标准库中的 wsgiref 服务器。我们之前使用它的serve_forever()函数来处理请求,作为测试apd.sensors HTTP 服务器的一部分。这几乎正是我们想要的,因为它采用了一个 WSGI 应用,并通过 HTTP 使它可用;但是它是以阻塞的方式实现的。一旦我们调用serve_forever(),服务器正常运行,直到用户用<CTRL+c>中断它。这不是我们想要的测试设备,所以我们需要卸载它来并发运行。

线程化的执行模型非常适合这一点:我们可以产生一个新的线程来处理serve_forever()调用,并在我们处理完服务器后中断它。与我们以前编写的 fixtures 不同,我们不只是想创建一个值并将其传递给测试方法,我们还想进行设置、传递一个值,然后进行拆卸以清理我们已经创建的线程。

进行设置和拆卸的 Pytest fixtures 使用关键字yield而不是return,有效地使 fixture 成为一个单项生成器。在yield关键字之前的任何东西都被正常执行,产生的值是作为参数提供给测试函数的。yield之后的任何操作仅在夹具拆除后执行。默认情况下,夹具在每次测试结束时都会被拆除。我们可以将范围更改为“session”,这意味着每次 pytest 调用时,fixture 应该只设置和拆除一次,而不是在每次测试之后。

这种结构允许在最后一个需要http_server的测试完成后进行server.shutdown()调用和线程池清理。

Note

shutdown 方法是标准库中 WSGIServer 的一个实现细节,但它是一个关键的细节。一旦我们的测试方法执行完毕,我们想要关闭服务请求的线程。如果我们不这样做,那么测试程序将挂起,等待线程完成,但是线程在正常操作中永远不会终止。shutdown 方法操作一个内部标志,wsgiref 服务器每 500 毫秒检查一次该标志。如果它被设置,serve_forever()调用返回,因此导致线程退出。

线程中运行的任何东西都必须在进程完成之前被明确关闭。在这种情况下,我们很幸运这个 API 在设计时就考虑到了这一点,但是如果你使用的是其他不提供关闭功能的 API,你可能需要创建自己的共享变量,并在提交给池的函数中检查它。不可能从外部强制线程停止;你的线程必须在不再需要的时候停止。

utility 函数允许我们创建多个这样的测试服务器,仅在配置上有所不同,并将它们的地址传递给测试方法。我们可以创建尽可能多的装置,向每个装置传递不同的数据。例如,下面给出了一个设置服务器的 fixture,该服务器使用不同的 API 键,因此会拒绝请求:

@pytest.fixture(scope="session")
def bad_api_key_http_server():
    yield from run_server_in_thread(
        "alternate", {"APD_SENSORS_API_KEY": "penny"}, 12082
    )

这里最后要提到的是夹具本身的yield from结构。一个yield from表达式在构建发电机时非常有用。当给定一个 iterable 时,它放弃值,然后将执行传递给下一行。这允许编写遵从另一个迭代器的迭代器,作为更复杂的实现的一部分,例如,在现有迭代器的开头和结尾附加附加项的迭代器。它还可以用来将多个迭代器链接在一起,尽管标准库中的itertools.chain函数可能更适合这个目的。22

def additional(base_iterator):
    yield "Start"
    yield from base_iterator
    yield "End"

Pytest 对待 fixture 的值与对待 fixture 的值是不同的,所以尽管我们不想操作我们正在包装的迭代器,但是我们需要对它进行迭代并产生单个值,以便 pytest 知道这个 fixture 有设置和拆卸。Pytest 通过内省 fixture 函数并检查它是否是一个生成器函数来确定这一点。 3 如果包装函数体是return run_server_in_thread(...),那么,尽管调用函数的实际结果是一样的,但函数本身不会被认为是生成器函数。这是一个返回生成器的函数。

自省函数允许 fixtures 有意返回生成器,比如下面的例子返回一个只有一个值的生成器。如果这个 fixture 被用在一个测试函数中,那么这个函数将被赋予生成器本身,而不是它的单个值。

@pytest.fixture
def single_item_iterator():
    def gen_func():
        yield "An item"
    return gen_func()

Fixture scoping

默认情况下,所有的 fixture 都在测试级别,这意味着 fixture 代码对于依赖它们的每个测试都运行一次。我们创建一个新的 HTTP 服务器的 fixtures 的作用域是在会话级别,这意味着它们只运行一次,并且所有测试共享这个值。

夹具可以使用其他夹具,作为在多个夹具和测试之间共享设置代码的一种方式。例如,在未来,作为apd.sensors的服务器设置的一部分,我们可能需要更多的配置值。在这种情况下,我们不想为每个正在设置的 HTTP 服务器都重复它们;我们希望将默认配置放在一个夹具中,如清单 8-2 所示。这样,HTTP 服务器设备和任何需要配置值的测试都可以读取它。

import copy

@pytest.fixture(scope="session")
def config_defaults():
    return {
        "APD_SENSORS_API_KEY": "testing",
        "APD_SOME_VALUE": "example",
        "APD_OTHER_THING": "off"
    }

@pytest.fixture(scope="session")
def http_server(config_defaults) -> t.Iterator[str]:
    config = copy.copy(config_defaults)
    yield from run_server_in_thread("standard", config, 12081)

@pytest.fixture(scope="session")
def bad_api_key_http_server(config_defaults) -> t.Iterator[str]:
    config = copy.copy(config_defaults)
    config["APD_SENSORS_API_KEY"] = "penny"
    yield from run_server_in_thread(
        "alternate", config, 12082
    )

Listing 8-2Changes to the fixtures to support a common config fixture

这个假设的config_defaults fixture 已经设置了scope="session",因为它也在会话范围级别运行。然而,这是由会话范围的 fixtures 使用的逻辑结果,而不是自由选择。如果config_defaults夹具的范围更窄,那么就会出现矛盾。应该根据狭窄的范围设置和拆除它,还是在拆除依赖于它的会话范围的项目之后设置和拆除它?

我们的例子可能看起来无害,但是如果 fixture 返回动态值,或者设置一些资源,那么行为需要一致。因此,任何试图使用范围比正在使用它的 fixture 更窄的 fixture 的操作都会导致 pytest 失败,并出现范围不匹配错误,如下所示:

ScopeMismatch: You tried to access the 'function' scoped fixture 'config_defaults' with a 'session' scoped request object, involved factories
tests\test_http_get.py:57:  def http_server(config_defaults)
tests\test_http_get.py:49:  def config_defaults()

开发人员可以使用几个作用域;这些是(从最窄到最宽)functionclassmodulepackage、 4 、session。缺省值是 function,任何定义了显式作用域的 fixture 必须只依赖于使用该作用域或更宽作用域的 fixture。例如,任何类范围的 fixture 都可以依赖于类、模块、包或会话 fixture,但不能依赖于函数范围的 fixture。

有点令人困惑的是,还有第二种类型的范围适用于 fixtures,它们的可发现性。这由代码库中定义 fixture 的位置来定义。它决定了哪些函数可以使用 fixture,但是对如何在测试之间共享 fixture 调用没有影响。

我们之前创建的 HTTP 服务器设备被指定为在会话范围内,但是它们被定义在一个测试模块中,这使得它们的可发现性等同于模块范围。有三种可能的可发现性范围,相当于类、模块和包。在conftest.py模块中定义的夹具可用于代码库中的所有测试;在一个测试模块中定义的可用于该模块中的所有测试;而那些被定义为测试类的方法的测试对该类中的所有测试都是可用的。

发现范围与定义范围不同是很常见的,特别是当 fixture 的默认范围是 function 时,它没有等价的可发现性范围。如果可发现性比声明的范围更广,那么在整个测试过程中,可以多次设置、使用和拆卸夹具。如果是相同的,那么夹具将被设置、使用,然后立即拆除。最后,如果一个测试声明的范围比它的可发现性更广,那么它将不会被拆除,直到测试运行中的某个稍后的点,可能是在不再需要它之后很久。表 8-1 展示了这三种可能性。

表 8-1

15 种不同范围组合的效果

|   |

scope=function

|

scope=class

|

scope=module

|

scope=package

|

scope=session

|
| --- | --- | --- | --- | --- | --- |
| 定义在一个类中 | 多次调用 | 一次祈祷 | 延迟拆卸 | 延迟拆卸 | 延迟拆卸 |
| 在模块中定义 | 多次调用 | 多次调用 | 一次祈祷 | 延迟拆卸 | 延迟拆卸 |
| 在 conftest.py 中定义 | 多次调用 | 多次调用 | 多次调用 | 一次祈祷 | 延迟拆卸 |

如果存在多个同名的装置,那么每个测试使用发现范围最窄的一个。也就是说,在conftest.py中定义的 fixture 可用于所有的测试,但是如果一个模块有一个同名的 fixture,那么这个 fixture 将用于模块内的测试。如果一个类有一个同名的 fixture,情况也是如此。

Caution

这种超越仅仅是关于发现;对夹具的寿命及其拆卸行为没有影响。如果您有一个设置和拆除资源的 fixture,比如我们的 HTTP 服务器,并且您为一个类覆盖了它,那么同一 fixture 的其他版本可能已经设置好了,但是还没有拆除。 5 任何时候你定义一个 fixture,其中使用的最窄覆盖和使用的最宽声明范围在表 8-1 中被列为“延迟拆卸”,你必须确保你的 fixture 不试图持有相同的资源,例如 TCP/IP 套接字。

我们的代码中确实有不匹配的地方:我们的 HTTP 服务器 fixture 是在一个测试模块中定义的,但是使用了会话范围,所以它可能会遭受延迟拆卸。我们可以通过将 fixtures 移动到conftest.py或者将声明的范围更改为module来解决这个问题。我们需要决定我们是否希望我们的 fixture 与测试运行保持一致,并且可供任何测试使用,或者我们是否希望它只供test_http_get.py测试模块使用,并且一旦这些测试被执行,它就被拆除。

由于我们不打算创建一个需要使用这个 fixture 的功能测试的扩展测试套件,我将把它留在测试模块中,并缩小匹配的范围。

模仿对象以简化单元测试

为了编写代码的单元测试,我们需要找到一种替代方法来启动 aiohttp 库要连接的服务器。如果我们使用 requests 库发出 HTTP 请求,我们可能会使用 responses 测试工具,该工具会修补请求内部的某些部分,以允许覆盖特定的 URL。

如果我们的get_data_points(...)实现是同步的,我们将注册我们想要用响应覆盖的 URL,并确保为测试方法激活了包。使用响应的测试函数,比如如下所示的假设函数,不会以牺牲可读性为代价引入过多的复杂性。

@responses.activate
def test_get_data_points(self, mut, data) -> None:
    responses.add(responses.GET, 'http://localhost/v/2.0/sensors/',
              json=data, status=200)
    datapoints = mut("http://localhost", "")
    assert len(datapoints) == len(data["sensors"])
    for sensor in data["sensors"]:
        assert sensor["value] in (datapoint.data for datapoint in datapoints)
        assert sensor["id"] in (datapoint.sensor_name for datapoint in datapoints)

我们希望能够为 aiohttp 库做一些类似的事情,但是我们有一点优势,因为我们的函数期望将一个 http 客户端对象传递给get_data_points(...)函数。我们可以编写一个模拟版本的ClientSession对象,它的行为与真实对象非常相似,允许我们注入假数据,而不必像 responses 那样修补真实的实现。

对于简单的对象,我们经常使用标准库中内置的unittest.mock功能。模仿允许我们实例化对象并定义各种操作的结果。我们需要的对象有一个get(...)方法,它返回一个上下文管理器。这个上下文管理器的 enter 方法返回一个响应对象,它有一个status属性和一个json()协程,这是一组相对复杂的需求。清单 8-3 展示了一个使用unittest.mock构建这个对象的夹具。

from unittest.mock import Mock, MagicMock, AsyncMock

import pytest

@pytest.fixture
def data() -> t.Any:
    return {
        "sensors": [
            {
                "human_readable": "3.7",
                "id": "PythonVersion",
                "title": "Python Version",
                "value": [3, 7, 2, "final", 0],
            },
            {
                "human_readable": "Not connected",
                "id": "ACStatus",
                "title": "AC Connected",
                "value": False,
            },
        ]
    }

@pytest.fixture
def mockclient(data):
    client = MagicMock()
    response = Mock()
    response.json = AsyncMock(return_value=data)
    response.status = 200
    client.get.return_value.__aenter__ = AsyncMock(return_value=response)
    return client

Listing 8-3Using unittest’s mocking to mock a complex object

这个对象不太容易推理:mockclient中的代码相当密集,它依赖于理解不同类型的可用模拟类之间的差异,以及上下文管理器的实现。您不能一眼看出如何从测试夹具中使用这个对象。

我们可以通过创建定制类来编写相同的功能,这些定制类反映了我们想要替换的真实类的功能,如清单 8-4 所示。这种方法导致代码非常长,所以一些开发人员更喜欢前面提到的通用模仿方法。

import contextlib
from dataclasses import dataclass
import typing as t

import pytest

@pytest.fixture
def data() -> t.Any:
    return {
        "sensors": [
            {
                "human_readable": "3.7",
                "id": "PythonVersion",
                "title": "Python Version",
                "value": [3, 7, 2, "final", 0],
            },
            {
                "human_readable": "Not connected",
                "id": "ACStatus",
                "title": "AC Connected",
                "value": False,
            },
        ]
    }

@dataclass
class FakeAIOHttpClient:
    data: t.Any

    @contextlib.asynccontextmanager
    async def get(self, url: str, headers: t.Optional[t.Dict[str, str]]=None) -> FakeAIOHttpResponse:
        yield FakeAIOHttpResponse(json_data=self.data, status=200)

@dataclass
class FakeAIOHttpResponse:
    json_data: t.Any
    status: int

    async def json(self) -> t.Any:
        return self.json_data

@pytest.fixture
def mockclient(data) -> FakeAIOHttpClient:
    return FakeAIOHttpClient(data)

Listing 8-4Manually mocking a complex object

使用这种方法的设置时间大约是两倍,但是一眼就能看出所涉及的对象是什么要容易得多。这两种方法之间的差异很大程度上是个人偏好的差异。就我个人而言,在大多数情况下我更喜欢第二种方法,因为我觉得它有一些具体的优点。

unittest.mock方法为所有属性访问创建模拟。这可能会引入微妙的测试错误,因为代码可能会开始依赖于一个新的属性,而这在默认情况下会被模拟出来。例如,如果我们编写了一些使用了if response.cookies:的代码,那么第一种模拟方法将总是在模拟会话中对True求值,但是第二种方法将引发AttributeError。我通常更愿意知道我的模仿是不完整的,通过异常,而不是不正确的行为。

然后,当编写包含分支逻辑的模拟时,前一种方法更难使用。它们非常适合断言遵循了什么代码路径,但是不太适合根据情况返回不同的数据。例如,如果我们想要一个模拟会话,它可以为不同的 URL 返回不同的数据,那么对定制对象的更改就相对清楚了。使用模拟对象时的等效变化要复杂得多。

带有分支逻辑的模拟

要使用Fake*对象引入每个 url 的模拟响应,只需要修改FakeAIOHttpClient类及其在mockclient中的调用,这些修改是非常标准的 Python 逻辑。

@dataclass
class FakeAIOHttpClient:
    responses: t.Dict[str, str]

    @contextlib.asynccontextmanager
    async def get(self, url: str, headers: t.Optional[t.Dict[str, str]]=None) -> FakeAIOHttpResponse:
        if url in self.responses:
            yield FakeAIOHttpResponse(json_data=self.responses[url], status=200)
        else:
            yield FakeAIOHttpResponse(json_data=None, status=404)

然而,对基于 unittest 的模拟系统的等效更改需要更多的支持代码,并且需要对一些工作进行重构,以更类似于我们的定制模拟方法。

def FakeAIOHTTPClient(response_data):
    client = Mock()
    def find_response(url):
        get_request = MagicMock()
        response = Mock()
        if url in response_data:
            response.json = AsyncMock(return_value=response_data[url])()
            response.status = 200
        else:
            response.json = AsyncMock(return_value=None)()
            response.status = 404
        get_request.__aenter__ = AsyncMock(return_value=response)
        return get_request
    client.get = find_response
    return client

@pytest.fixture
def mockclient(data):
    return FakeAIOHTTPClient({
        "http://localhost/v/2.0/sensors/": data
    })

数据类别

你可能已经注意到了前面几个类中的@dataclass装饰,因为我们还没有用到它们。数据类是 3.7 版本中引入的 Python 特性。它们大致相当于旧版本 Python 中广泛使用的命名元组特性;它们是定义数据容器的一种方式,可以最大限度地减少所需的样板文件。

通常,当定义一个类来存储数据时,我们必须定义一个__init__(...)方法来获取参数(可能带有默认值),然后将这些参数设置为实例属性。每个字段名出现三次,一次在参数列表中,一次在赋值操作的两边,例如,我们的假响应对象的以下变体,它只存储两条数据:

class FakeAIOHttpResponse:
    def __init__(self, body: str, status: int):
        self.body = body
        self.status = status

许多 Python 开发人员都非常熟悉这种类结构,因为我们经常需要创建存储结构化数据的方法,这些方法使用属性访问来检索字段。collections.namedtuple(...)函数是一种以声明方式实现这一点的方法:

import collections

FakeAIOHttpResponse = collections.namedtuple("FakeAIOHttpResponse", ["body", "status"])

除了减少声明只包含样板代码的类的需要之外,这样做还有一个好处,即确保返回对象的有用文本表示,以及像==!=这样的比较操作符的行为符合预期。我们前面提到的原始类不比较类上的值,所以FakeAIOHttpResponse("", 200) == FakeAIOHttpResponse("", 200)用类版本评估为 False,用命名的元组版本评估为 True。

命名元组是一种特殊类型的元组;可以使用带有字段名称的属性访问或带有索引的项目访问来访问项目。即对于一个FakeAIOHttpResponsex.body == x[0]的实例。最后,它们提供了一个_asdict()实用方法,该方法返回一个字典,其中包含与命名元组实例相同的数据。

命名元组的最大缺点是它们不容易添加方法。可以对命名元组进行子类化,并以这种方式添加方法,但我不建议这样做,因为可读性较差。

class FakeAIOHttpResponse(collections.namedtuple("", ["body", "status"])):
    async def json(self) -> t.Any:
        return json.loads(self.body)

这就是数据类的闪光点。通过在类定义上使用@dataclasses.dataclass decorator,可以将一个类变成一个数据类。使用类型语法定义字段,可以选择使用默认值。dataclass decorator 负责将这些类变量转换成定制的__init__(...)__repr__()__eq__(...)和其他方法。

@dataclass
class FakeAIOHttpResponse:
    body: str
    status: int = 200

    async def json(self) -> t.Any:
        return json.loads(self.body)

Tip

有时候,除了存储值之外,您还想在__init__方法中添加其他代码。您可以通过定义一个__post_init__方法来对数据类执行此操作,该方法将在__init__中的样板文件完成后被调用。

尽管数据类提供了许多与命名元组相同的特性,但它们并不完全与命名元组提供的 API 兼容。它们不实现条目访问、 6 ,并且到字典和元组的转换是通过dataclasses.asdict(...)dataclasses.astuple(...)函数完成的,而不是通过类本身的方法。

数据类相对于命名元组的另一个优势是它们是可变的,尽管我们在这里没有用到。在数据类对象被实例化之后,可以改变它的属性值。命名元组就不一样了。此功能是可选的;用@dataclass(frozen=True)定义的类不支持在实例化后改变属性。冻结一个数据类的好处是它也可以被哈希(??),这意味着它可以作为集合的一部分或者字典的键来存储。

Caution

尽管被冻结的数据类不允许它们的值被替换为,但是如果其中一个值是可变的,那么这个字段有可能被就地改变。如果你使用列表、集合或字典作为值类型,我不推荐使用frozen=True选项。

还有一些其他的选项可以传递给@dataclass装饰器:eq=False抑制等式函数的生成,这样相同值的实例就不会相等。或者,传递order=True会额外生成丰富的比较字段,其中对象的排序与它们的值的元组一样,按顺序排列。

对于一些高级用例,可以指定每个字段的元数据。例如,我们可能希望响应的 repr 看起来像FakeAIOHttpResponse(url='http://localhost', status=200),也就是说,添加一个 URL 项并从 repr 中省略主体。我们可以通过使用一个field对象来做到这一点,这与编写自定义__repr__()方法的标准方法相反。两种方法的比较如表 8-2 所示。

表 8-2

使用和不使用 dataclass 助手的自定义 repr 行为的比较

| *使用字段(...)自定义默认 repr*`from dataclasses import dataclass, field``@dataclass``class FakeAIOHttpResponse:``url: str``body: str = field(repr=False)``status: int = 200``async def json(self) -> t.Any:``return json.loads(self.body)` | *使用自定义 __repr__*`from dataclasses import dataclass``@dataclass``class FakeAIOHttpResponse:``url: str``body: str``status: int = 200``def __repr__(self):``name = type(self).__name__``url = self.url``status = self.status``return f"{name}({url=}, {status=})"``async def json(self) -> t.Any:``return json.loads(self.body)` |

field(...)方法的优点是明显更短,尽管稍微不太直观。__repr__()方法允许完全控制,代价是需要重新实现默认行为。

在某些情况下,field 方法是强制的:支持默认为可变对象的字段,比如 list 或 dict。这与建议不要使用可变对象作为函数的默认值是出于同样的原因,因为它们被就地修改会导致数据在实例间溢出。

字段对象接受一个default_factory参数,这是一个可调用的参数,为每个实例生成默认值。这可以是用户指定的函数,也可以是不带参数的类构造函数。

options: t.List[str] = field(default_factory=list)

上下文库

与我们使用yield分割 pytest fixture 的安装和拆卸部分一样,我们可以使用标准库中的contextlib的装饰器来创建上下文管理器,而不必显式实现__enter__()__exit__(...)方法对。

装饰器是创建上下文管理器最简单的方法,尤其是我们在这里使用的非常简单的方法。上下文管理器最常见的用途是创建一些资源,并确保它在之后被正确清理。表 8-3 显示,如果我们正在制作一个上下文管理器,其行为方式与之前的 HTTP 服务器 fixture 相同,那么代码几乎是相同的。

表 8-3

具有拆卸功能的 pytest fixture 与上下文管理器的比较

| *Pytest fixture 创建 HTTP 服务器*`import pytest``@pytest.fixture(scope="module")``def http_server():``yield from run_server_in_thread(``"standard", {``"APD_SENSORS_API_KEY": "testing"``}, 12081``)` | *上下文管理器创建一个 HTTP 服务器*`import contextlib``@contextlib.contextmanager``def http_server():``yield from run_server_in_thread(``"standard", {``"APD_SENSORS_API_KEY": "testing"``}, 12081``)` |

更复杂的上下文管理器,比如需要处理发生在它们包装的代码中的异常的上下文管理器,需要将yield语句视为可能引发异常的语句。因此,yield语句通常应该在try / finally块或with块中,以确保任何资源都被正确地拆除。

FakeAIOHttpClient上的get(...)方法是异步上下文管理器,而不是标准上下文管理器。@contextlib.contextmanager装饰器从生成器方法中创建__enter__()__exit__(...)方法;我们需要的是一个装饰器来从一个生成器协程创建__aenter__()__aexit__(...)协程。这可以作为@contextlib.asynccontextmanager装饰器获得。

测试方法

既然我们已经准备好支持代码的快速集成测试,我们就可以开始编写实际的测试函数了。首先,我们可以在没有 HTTP 服务器开销的情况下验证get_data_points(...)方法的行为。 7 然后我们可以根据get_data_points(...)add_data_from_sensors(...)方法添加测试。最后,我们需要测试来确保应用的数据库部分正常工作,我们仍然需要修改它来消除阻塞行为。

清单 8-5 中显示的测试方法结合了我们目前使用的技术。对get_data_points(...)的测试使用定制对象生成的mockclient。这是所有依赖于 HTTP 库的准确行为的一组测试中的第一个。另一方面,add_data_from_sensors测试使用一个unittest.mock.Mock()对象来模拟数据库会话,因为我们只需要断言某些方法在我们期望的时候被调用。

patch_aiohttp()夹具结合了这两种方法,以及夹具的安装和拆卸功能。只要上下文管理器是活动的,unittest.mock.patch(...)上下文管理器就获取一个 Python 对象的位置并用一个 mock 替换它。由于add_data_from_sensors(...)方法不接受ClientSession作为参数,所以我们不能将自定义的模拟传递给它。这允许我们将我们的定制模拟方法移植到 aiohttp 库,每当我们的测试代码创建一个ClientSession时就返回,就像 responses 对 requests 库所做的那样。

from unittest.mock import patch, Mock, AsyncMock

import pytest

import apd.aggregation.collect

class TestGetDataPoints:
    @pytest.fixture
    def mut(self):
        return apd.aggregation.collect.get_data_points

    @pytest.mark.asyncio
    async def test_get_data_points(
        self, mut, mockclient: FakeAIOHttpClient, data
    ) -> None:
        datapoints = await mut("http://localhost", "", mockclient)

        assert len(datapoints) == len(data["sensors"])
        for sensor in data["sensors"]:
            assert sensor["value"] in (datapoint.data for datapoint in datapoints)
            assert sensor["id"] in (datapoint.sensor_name for datapoint in datapoints)

class TestAddDataFromSensors:
    @pytest.fixture
    def mut(self):
        return apd.aggregation.collect.add_data_from_sensors

    @pytest.fixture(autouse=True)
    def patch_aiohttp(self, mockclient):
        # Ensure all tests in this class use the mockclient
        with patch("aiohttp.ClientSession") as ClientSession:
            ClientSession.return_value.__aenter__ = AsyncMock(return_value=mockclient)
            yield ClientSession

    @pytest.fixture
    def db_session(self):
        return Mock()

    @pytest.mark.asyncio
    async def test_datapoints_are_added_to_the_session(self, mut, db_session) -> None:
        # The only times data should be added to the session are when # running the MUT
        assert db_session.add.call_count == 0
        datapoints = await mut(db_session, ["http://localhost"], "")
        assert db_session.add.call_count == len(datapoints)

Listing 8-5The various approaches of test methods for apd.aggregation

最终的测试并不过分复杂,并且覆盖了与功能测试相同的一般功能。它们为未来的测试提供了一个基础,功能测试提供了一个退路,让我们确信我们的测试有有用的断言。这里的集成测试都是阳性,确认正常情况下有效。我们还没有任何证据证明不寻常的或边缘的情况得到了正确的处理,但它们是一个很好的起点。

异步数据库

到目前为止,我们一直使用 SQLAlchemy ORM 来处理数据库和 Python 代码之间的所有交互,因为它允许将数据库的许多特性放到一边,以支持看起来正常的 Python 代码。不幸的是,SQLAlchemy ORM 不适合在纯异步环境中使用。SQLAlchemy 不保证 SQL 查询只在响应session.query(...)调用时运行;访问对象的属性时也可以运行查询,更不用说插入和事务管理查询了。所有这些调用都会阻塞执行,严重影响 asyncio 应用的性能。

这并不意味着 SQLAlchemy ORM 在异步上下文中运行时会更慢;阻塞通常是最小的,并且仍然存在于 SQLAlchemy 的同步使用中。相反,这意味着在异步代码中使用 SQLAlchemy ORM 会导致性能下降到与同步代码相同的水平,从而抵消了使用 asyncio 的许多好处。

如果我们愿意牺牲 SQLAlchemy 的 ORM 组件,只将其用作 SQL 语句生成器和接口,就不会出现无意查询的风险。这是一个真正的损失,是我们到目前为止考虑的与使我们的代码异步相关的最大损失,因为 SQLAlchemy ORM 是一个设计如此良好的库。

在撰写本文时,数据库连接还没有完美的解决方案;但是,我觉得语句生成方法是一个很好的折衷方案。只要您没有编写异步服务器应用,并且能够承受性能下降的风险,您就应该考虑使用 ORM 的实用方法,尽一切努力避免在主线程中调用阻塞代码。

经典的 SQLAlchemy 风格

在我们的例子中,我们将使用语句生成方法。我们不能继续使用之前创建的基于declarative_base的类,因为这可能会无意中触发 SQL 查询。使用“经典”样式(即,不是直接从它们所代表的 Python 类派生的显式表对象)并且不配置 ORM 来链接表和我们的 Python 对象,这让我们可以安全地使用DataPoint对象,而不会触发隐式查询。清单 8-6 中给出了我们现有表格的实现。

这种方法意味着我们将不会直接在数据库层处理我们的自定义对象,我们将处理表,并将负责我们的对象和 SQLAlchemy API 之间的转换。然而,我们只是改变了我们表示数据库的方式,而不是数据库结构,所以我们不需要为这种改变创建任何迁移。

from dataclasses import dataclass, field
import datetime
import typing as t

import sqlalchemy
from sqlalchemy.dialects.postgresql import JSONB, TIMESTAMP
from sqlalchemy.schema import Table

metadata = sqlalchemy.MetaData()

datapoint_table = Table(
    "sensor_values",
    metadata,
    sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True),
    sqlalchemy.Column("sensor_name", sqlalchemy.String),
    sqlalchemy.Column("collected_at", TIMESTAMP),
    sqlalchemy.Column("data", JSONB),
)

@dataclass
class DataPoint:
    sensor_name: str
    data: t.Dict[str, t.Any]
    id: int = None
    collected_at: datetime.datetime = field(default_factory=datetime.datetime.now)

Listing 8-6The “classic” style, with independent table and data classes

在我们做任何事情之前,我们应该更新我们的alembic/env.py脚本,因为它需要引用metadata对象来生成迁移。之前是导入Base,然后接入Base.metadata;我们必须修改这些行来使用我们的新元数据对象,apd.aggregation.database.metadata

我们不能再通过实例化一个DataPoint对象并将其添加到会话中来创建数据库记录;相反,我们直接对datapoint_table结构进行插入调用。

stmt = datapoint_table.insert().values(
    sensor_name="ACStatus",
    collected_at=datetime.datetime(2020,4,1,12,00,00),
    data=False
)
session.execute(stmt)

stmt对象是 SQLAlchemy 中Insert的一个实例。此对象代表要执行的 SQL 语句的结构;它不是直接传递给数据库的字符串。虽然可以查看表示语句的字符串,但是我们需要指定它用于哪种数据库,以便获得准确的结果。这是 SQLAlchemy 通过基于连接信息的stmt.compile(dialect=...)方法调用在内部完成的。不同数据库的 SQL 标准和指定插值的方式略有不同;编译步骤是应用特定于数据库的语法。作为防止 SQL 注入漏洞工作的一部分,所有的变体都将从 SQL 结构中传递的值分开。

未编译

INSERT INTO datapoints (sensor_name, collected_at, data) VALUES (:sensor_name, :collected_at, :data)
{'sensor_name': 'ACStatus', 'collected_at': datetime.datetime(2020, 4, 1, 12, 0), 'data': False}

数据库

INSERT INTO datapoints (sensor_name, collected_at, data) VALUES (:sensor_name, :collected_at, :data)

{'sensor_name': 'ACStatus', 'collected_at': datetime.datetime(2020, 4, 1, 12, 0), 'data': False}

关系型数据库

INSERT INTO datapoints (sensor_name, collected_at, data) VALUES (%s, %s, %s)
['ACStatus', datetime.datetime(2020, 4, 1, 12, 0), False]

一种数据库系统

INSERT INTO datapoints (id, sensor_name, collected_at, data) VALUES (%(id)s, %(sensor_name)s, %(collected_at)s, %(data)s)
{'id': None, 'sensor_name': 'ACStatus', 'collected_at': datetime.datetime(2020, 4, 1, 12, 0), 'data': False}

数据库

INSERT INTO datapoints (sensor_name, collected_at, data) VALUES (?, ?, ?)
['ACStatus', datetime.datetime(2020, 4, 1, 12, 0), False]

除了好奇,我们不需要看这些字符串,也不需要手动编译 insert 语句。我们通过 SQLAlchemy 建立的会话在使用session.execute(stmt)执行时直接处理一个Insert对象。

这个execute(...)方法将语句发送到数据库并等待响应。例如,如果有一个 SQL 锁需要等待,这个 Python 语句就可以阻塞。session.commit()调用也可能导致阻塞,因为这是前面的插入命令被终结的地方。简而言之,使用这种方法,我们需要确保任何涉及会话的调用总是发生在不同的线程中。

忽略 SQL 生成的细节而只调用table.insert().values(...)的能力展示了我们通过使用 SQLAlchemy 保留的一些优势,即使是以这种更有限的方式。通过编写在两种数据类型之间转换的实用函数,我们可以做得更好。我们最初可能会尝试使用**dataclasses.asdict(...)来生成values(...)调用的主体,但这将包括id=None。我们不想在 SQL insert 中将 id 设置为None,我们想从参数列表中省略它,以便数据库设置它。为了使这更容易,我们将在数据类(清单 8-7 )上创建一个调用asdict(self)的函数,但该函数只包含显式设置的 id。

from dataclasses import dataclass, field, asdict
import datetime
import typing as t

@dataclass
class DataPoint:
    sensor_name: str
    data: t.Dict[str, t.Any]
    id: int = None
    collected_at: datetime.datetime = field(default_factory=datetime.datetime.now)

    def _asdict(self):
        data = asdict(self)
        if data["id"] is None:
            del data["id"]
        return data

Listing 8-7Implementation of DataPoint class with a helper method for database queries

使用运行执行程序

我们在前一章简单讨论了run_in_executor(...)函数,以允许time.sleep(1)asyncio.sleep(1)并行运行而不是顺序运行为例。这是一个相当不自然的例子,但是将数据库调用转移到一个新的线程中非常适合。

Caution

run_in_executor(...)方法与我们之前使用的with ThreadPoolExecutor()结构不可互换。两者都将工作委托给一个线程;池执行器构造建立一个池,提交工作,然后等待所有工作完成,而run_in_executor(...)方法创建一个长时间运行的池,允许您提交任务并等待来自异步代码的值。

到目前为止,我们使用的许多 asyncio 帮助函数,如asyncio.gather(...)asyncio.create_task(...)asyncio.Lock(),都会自动检测当前的 asyncio 事件循环。run_in_executor(...)功能有点不一样;它只能作为事件循环实例上的方法使用。我们需要用asyncio.get_running_loop()自己获取当前事件循环,然后用它来提交要在执行器中运行的函数。我建议提交一个同步任务来完成您需要的所有工作,而不是为每个低级调用提交单独的任务并用 asyncio 逻辑将它们粘合在一起,例如,创建一个为一组对象生成插入查询的handle_result(...)函数(清单 8-8 ),而不是为每个要插入的对象创建一个函数调用。

def handle_result(result: t.List[DataPoint], session: Session) -> t.List[DataPoint]:
    for point in result:
        insert = datapoint_table.insert().values(**point._asdict())
        sql_result = session.execute(insert)
        point.id = sql_result.inserted_primary_key[0]
    return result

async def add_data_from_sensors(
    session: Session, servers: t.Tuple[str], api_key: t.Optional[str]
) -> t.List[DataPoint]:
    tasks: t.List[t.Awaitable[t.List[DataPoint]]] = []
    points: t.List[DataPoint] = []
    async with aiohttp.ClientSession() as http:
        tasks = [get_data_points(server, api_key, http) for server in servers]
        for results in await asyncio.gather(*tasks):
            points += results
    loop = asyncio.get_running_loop()
    await loop.run_in_executor(None, handle_result, points, session)
    return points

Listing 8-8Database integration function for adding data points

loop.run_in_executor的参数是(executor, callable, *args),其中executor必须是ThreadPoolExecutorNone的一个实例(使用默认的执行程序,必要时创建它)。

Tip

如果您正在适应大量的同步任务,我建议您直接管理线程池。这将允许您设置他们的工人数量,从而设置他们将执行的同时任务的数量。这还将允许您在决定需要添加什么锁来使代码线程安全时,更有效地推理哪些代码可以同时执行。

在这个执行器中,callable函数将作为一个任务被调用,其位置参数在*args中指定。您不能将关键字参数作为此 API 的一部分指定给 callable。

使用需要关键字参数的函数的最佳方式是使用functools.partial(...)函数。它将一个函数转换成另一个参数更少的函数。如果我们将handle_result(...)函数包装在一个分部函数中,如下所示,那么下面的函数调用将是等效的:

>>> only_points = functools.partial(handle_result, session=Session)
>>> only_session = functools.partial(handle_result, points=points)
>>> no_args = functools.partial(handle_result, points=points, session=Session)

>>> handle_result(points=points, session=Session)
[DataPoint(...), DataPoint(...)]

>>> only_points(points=points)
[DataPoint(...), DataPoint(...)]

>>> only_session(session=Session)
[DataPoint(...), DataPoint(...)]

>>> no_args()
[DataPoint(...), DataPoint(...)]

除了像run_in_executor(...)这样不支持关键字参数的 API 之外,在传递函数时使用一些参数集而不使用其他参数集有时是很有用的,例如,不需要将数据库会话或 web 请求传递给每个函数。

Django’s ORM

许多从事 Web 工作的 Python 开发人员会在职业生涯的某个阶段使用 Django,他们可能想知道从异步代码(比如从通道)与 Django ORM 交互的等效过程是什么。

我对 Django 的建议是像平常一样使用 ORM,但是只能从同步函数中使用。您可以使用实用程序方法@channels.db.database_sync_to_async调用同步函数,该方法可以用作同步函数的修饰器,使它们成为可调用的。这个装饰器通过一个显式的线程池委托给run_in_executor(...),但是也执行一些特定于 Django 的数据库连接管理。

from channels.db import database_sync_to_async

@database_sync_to_async
def handle_result(result: t.List[t.Dict[str, t.Any]]) -> t.List[DataPoint]:
    points: t.List[DataPoints] = []
    for data in result:
        point = DataPoint(**data)
        point.save()
        points.append(point)
    return points

如果在 Django 通道的上下文中使用假设的handle_result(...),前面的代码将是一个示例。由于 Django 强烈建议在给出响应之前提前执行所有的数据收集操作,这是一个次优但可行的解决方案。

查询数据

使用 SQLAlchemy 的 ORM 时,查询数据和接收 Python 对象是一件简单的事情。尽管如此,由于我们只使用了 SQLAlchemy 的查询构建和执行部分,这有点复杂。在支持 ORM 的 SQLAlchemy 中,我们会找到 PythonVersion 传感器的所有DataPoint条目

db_session.query(DataPoint).filter(DataPoint.sensor_name=="PythonVersion")

但是我们需要使用 table 对象,并从c属性中引用它的列,如下所示:

db_session.query(datapoint_table).filter(datapoint_table.c.sensor_name=="PythonVersion")

我们拿回来的对象不是DataPoint对象,而是 SQLAlchemy 自己内部的命名元组实现,叫做轻量级命名元组。对于没有设置类映射器的任何查询,都将返回这些。

这些内部命名元组提供了一个_asdict()方法,因此将result对象转换为DataPoint对象的最佳方式是DataPoint(**result._asdict()).不幸的是,这些对象是动态生成的,被认为是 SQLAlchemy 的实现细节。因此,我们不能在函数的类型定义中使用这些对象。一旦我们添加了一个用于将命名元组转换为数据类的帮助器方法,我们的最终代码与清单 8-9 相同。

from dataclasses import dataclass, field, asdict
import datetime
import typing as t

@dataclass
class DataPoint:
    sensor_name: str
    data: t.Dict[str, t.Any]
    id: int = None
    collected_at: datetime.datetime = field(default_factory=datetime.datetime.now)

    @classmethod
    def from_sql_result(cls, result):
        return cls(**result._asdict())

    def _asdict(self):
        data = asdict(self)
        if data["id"] is None:
            del data["id"]
        return data

Listing 8-9Final implementation of DataPoint class that supports manual object mapping to SQLAlchemy

我们现在可以使用 SQLAlchemy 进行查询,这些查询返回我们的对象,但是结果对象与数据库没有任何直接连接,这可能会导致发出意外的查询。

results = map(
    DataPoint.from_sql_result,
    db_session.query(datapoint_table).filter(datapoint_table.c.sensor_name=="PythonVersion")
)

我们也可以在编写测试时使用这种方法,使它们几乎和使用 ORM 风格的相同代码一样清晰。

    @pytest.mark.asyncio
    async def test_datapoints_can_be_mapped_back_to_DataPoints(
        self, mut, db_session, table, model
    ) -> None:
        datapoints = await mut(db_session, ["http://localhost"], "")
        db_points = [
            model.from_sql_result(result) for result in db_session.query(table)
        ]
        assert db_points == datapoints

Tip

如果您正在使用 Pandas 数据分析框架,DataFrame 对象提供了加载和存储来自 SQLAlchemy 查询的信息的专用方法。这些read_sql(...)to_sql(...)方法在加载大型数据集时非常有用。

避免复杂的查询

经常可以看到人们在 ORM 中构建非常复杂的查询,比如涉及多个连接、 8 条件和子查询的查询。有几个技巧可以让我们更容易理解代表复杂条件的代码。对于 SQLAlchemy 来说,这是@hybrid_property特性,而对于 Django 来说,这相当于定制查找和转换。

在第六章中,我们看了 SQLAlchemy 如何改变映射类中类属性的行为,使得列可以表示字段的值,或者 SQL 可以表示列,这取决于属性访问是在类的实例上进行的还是在类本身上进行的。混合属性允许将相同的方法扩展到您的定制逻辑。

这里的好处是重新组织代码,所以为了演示它在哪里有用,我们首先需要一个受益于重构的特性需求。我们很可能想要查看某一天常见值的汇总。显示传感器名称、它们的不同值以及今天发生的所有条目的值被看到的次数的查询可以在 SQLAlchemy 中表示为非常长的查询:

value_counts = (
    db_session.query(
        datapoint_table.c.sensor_name,
        datapoint_table.c.data,
        sqlalchemy.func.count(datapoint_table.c.id)
    )
    .filter(
        sqlalchemy.cast(datapoint_table.c.collected_at, DATE)
        == sqlalchemy.func.current_date()
    )
    .group_by(datapoint_table.c.sensor_name, datapoint_table.c.data)
)

这有几个问题。首先,namedata列出现了两次,因为我们希望根据它们进行分组,但是我们还需要能够看到哪个结果与哪个分组相关联,因此它们也必须出现在输出列中。其次,我们得到的过滤器很复杂,既要读取又要执行。读取很困难,因为它涉及到对 SQLAlchemy 函数的多次调用,而不是简单的比较。执行起来很困难,因为我们正在用强制转换修改collected_at属性,这会使该列上的任何索引无效(如果我们已经设置了任何索引的话)。

Note

我用sqlalchemy.func.current_date()来表示当前日期。数据库中任何可用的函数都可以通过sqlalchemy.func按名称访问。这纯粹是一种风格选择;使用datetime.date.today()或其他任何被数据库解释为日期的东西并不会更快或更慢。

查看 PostgreSQL 如何解释查询的最简单方法是打开一个数据库 shell,并在那里用EXPLAIN ANALYZE修饰符运行查询。 9 输出格式相当复杂,但是有很多 PostgreSQL 的资源深入讲解了如何阅读它们以及优化方法。

目前,我们的目标是创建一个既易读又不会不必要地慢的查询。首先,让我们将公共列移到变量中以减少重复。

headers = datapoint_table.c.sensor_name, datapoint_table.c.data
value_counts = (
    db_session.query(*headers, sqlalchemy.func.count(datapoint_table.c.id))
    .filter(
        sqlalchemy.cast(datapoint_table.c.collected_at, DATE)
        == sqlalchemy.func.current_date()
    )
    .group_by(*headers)
)

这使得滤波器部分成为速度和可读性的瓶颈。我建议的下一步是在底层表中的collected_atsensor_name字段上添加一些索引。我们通过将index=True添加到表上的字段并生成一个新的 alembic 修订来实现这一点,如下所示:

datapoint_table = Table(
    "datapoints",
    metadata,
    sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True),
    sqlalchemy.Column("sensor_name", sqlalchemy.String, index=True),
    sqlalchemy.Column("collected_at", TIMESTAMP, index=True),
    sqlalchemy.Column("data", JSONB),
)

> pipenv run alembic revision --autogenerate -m "Add indexes to datapoints"
> pipenv run alembic upgrade head

不幸的是,这不足以改变我们的执行计划,因为作为比较的一部分,我们正在操作collected_at列。这使得索引无效,因为CAST()函数的结果不是索引可以缓存的操作之一。可以在数据库中创建一个函数,返回给定时间戳的日期,并对该函数的结果进行索引,但是这种方法不会使我们的代码更容易阅读。

相反,我建议使用@hybrid_property将这个条件分解到类的一个属性中。我们可以复制相同的条件,但这只会使代码更容易阅读,而不是更有效地执行。将该条件分解出来的一个优点是可读性和效率之间的平衡发生了变化:如果它隐藏在一个具有有用名称的实用函数后面,而不是分散在整个代码库中,那么我们可以拥有一个更有效但可读性更差的条件。

除了具有可选的expression=update_expression=comparator=属性之外,@hybrid_property装饰器的工作方式与标准的@property装饰器相似。一个expression是一个类方法,它返回一个可选择的(即表示 SQLAlchemy 值的东西),比如CAST(datapoint_table.c.collected_at, DATE)update_expression是一个类方法,它接受一个值并返回列的 2 元组列表和它们的新值,作为expression的逆操作,允许更新列。这两种方法允许柱的外观与原生柱的行为相同。混合属性通常用于全名之类的东西,用来连接名和姓。 10 通常只有expression被实现,而没有update_expression。在这种情况下,该属性是只读的。

comparator属性有一点不同:它不能与expressionupdate_expression特性结合使用,但是它允许实现更复杂的情况,比较操作符的两个部分都可以在发送到数据库之前定制。这种用法通常用于小写电子邮件地址或用户名,尽量使它们不区分大小写。 11

比较器和表达式不兼容的原因是,expression特性是通过使用默认的比较器ExprComparator实现的,所以我们不能提供自己的比较器,除非它覆盖处理expression的代码。因为我们想要使用这两个特性,我们可以子类化ExprComparator来使用它必须委托给表达式的能力,但是也覆盖比较器函数的实现。

我们可以创建一个@hybrid_property,将日期时间转换为一个日期,同时使用一个定制的比较器来利用一些特定于数据库的优化。Postgres 将日期视为等同于时间部分为午夜的 datetime。我们可以确保右边是指定日期的午夜或更晚时间,并且在第二天的午夜之前,而不是确保比较的两边都是日期。我们可以通过确保比较的右边是一个日期并加 1 找到第二天来实现这一点。这允许我们使用索引进行两次比较,以获得与不使用索引的一次比较相同的结果。清单 8-10 中给出了更新的数据点实现。

from __future__ import annotations

from dataclasses import dataclass, field, asdict
import datetime
import typing as t

import sqlalchemy
from sqlalchemy.dialects.postgresql import JSONB, DATE, TIMESTAMP
from sqlalchemy.ext.hybrid import ExprComparator, hybrid_property
from sqlalchemy.orm import sessionmaker
from sqlalchemy.schema import Table

metadata = sqlalchemy.MetaData()

datapoint_table = Table(
    "sensor_values",
    metadata,
    sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True),
    sqlalchemy.Column("sensor_name", sqlalchemy.String, index=True),
    sqlalchemy.Column("collected_at", TIMESTAMP, index=True),
    sqlalchemy.Column("data", JSONB),
)

class DateEqualComparator(ExprComparator):

    def __init__(self, fallback_expression, raw_expression):
        # Do not try and find update expression from parent
        super().__init__(None, fallback_expression, None)
        self.raw_expression = raw_expression

    def __eq__(self, other):
        """ Returns True iff on the same day as other """
        other_date = sqlalchemy.cast(other, DATE)
        return sqlalchemy.and_(
            self.raw_expression >= other_date,
            self.raw_expression < other_date + 1,
        )

    def operate(self, op, *other, **kwargs):
        other = [sqlalchemy.cast(date, DATE) for date in other]
        return op(self.expression, *other, **kwargs)

    def reverse_operate(self, op, other, **kwargs):
        other = [sqlalchemy.cast(date, DATE) for date in other]
        return op(other, self.expression, **kwargs)

@dataclass

class DataPoint:
    sensor_name: str
    data: t.Dict[str, t.Any]
    id: t.Optional[int] = None
    collected_at: datetime.datetime = field(default_factory=datetime.datetime.now)

    @classmethod
    def from_sql_result(cls, result) -> DataPoint:
        return cls(**result._asdict())

    def _asdict(self) -> t.Dict[str, t.Any]:
        data = asdict(self)
        if data["id"] is None:
            del data["id"]
        return data

    @hybrid_property
    def collected_on_date(self):
        return self.collected_at.date()

    @collected_on_date.comparator
    def collected_on_date(cls):
        return DateEqualComparator(
            cls,
            sqlalchemy.cast(datapoint_table.c.collected_at, DATE),
            datapoint_table.c.collected_at,
        )

Listing 8-10DataPoint table and model, with transparent optimized comparator for dates

ExprComparator类型的构造函数有三个参数,模型类、表达式和混合属性。__init__(...)中的class=hybrid_property=参数用于实现更新行为,但是由于我们不需要这个特性,我们将简化接口并将None传递给这些参数。expression 参数是我们希望用于查询和任何比较的参数(除非另有说明)。在__init__(...)函数中,我们为底层列添加了一个新参数,这样我们就可以在自定义的比较函数中访问原始数据。

operate(...)reverse_operate(...)函数实现了各种比较。它们允许对比较双方的参数进行操作,我们需要确保被比较的对象是 PostgreSQL 中的CAST()DATE__eq__(...)方法是我们的自定义等式检查器,在这里我们实现了一个更有效的版本来检查两边是否是同一个日期,如前所述。

所有这些的效果是,我们可以无缝地比较两个 datetime 值,并获得正确的结果。两边都是CAST()DATE,除非是相等检查(我们试图优化的检查),在这种情况下,只有参数是CAST()DATE,允许左边的列使用索引。表 8-4 显示了可能的 Python 表达式、它们被翻译成的 SQL 或 Python,以及是否可以使用索引。

表 8-4

每个操作对混合属性的影响摘要

|

Python 表达式

|

评估结果

|

使用的索引

|
| --- | --- | --- |
| DataPoint.collected_on_date | CAST(sensor_values.collected_at AS DATE) | 不 |
| DataPoint(...).collected_on_date | datetime.date(2020, 4, 1) | 不适用(在 Python 中评估) |
| DataPoint.collected_on_date == other_date | sensor_values.collected_at >= CAST(%(param_1)s AS DATE) AND sensor_values.collected_at < CAST(%(param_1)s AS DATE) + %(param_2)s | 是(仅在处收集,不在右侧收集) |
| DataPoint.collected_on_date < other_date | CAST(sensor_values.collected_at AS DATE) < CAST(%(param_1)s AS DATE) | 不 |
| DataPoint(...).collected_on_date == other_date | datetime.date(2020, 4, 1) == other_date | 不适用(在 Python 中评估) |
| DataPoint(...).collected_on_date < other_date | datetime.date(2020, 4, 1) < other_date | 不适用的(用 Python 评估) |

有了这个collected_on_date表达式和比较器,我们可以大大简化查询代码。当阅读代码时,使用这个作为条件更容易理解,并且我们已经确保生成了利用索引的高效 SQL。

headers = table.c.sensor_name, table.c.data
value_counts = (
    db_session.query(*headers, sqlalchemy.func.count(table.c.id))
    .filter(
        model.collected_on_date == sqlalchemy.func.current_date()
    )
    .group_by(*headers)
)

Django’s ORM (Redux)

Django 的 ORM 以不同的方式处理这类问题,但是等效的功能确实存在。本小节给出了如何实现这一点的简要说明(对于已经熟悉 Django 的人来说)。有关更多详细信息,请查看本章末尾的其他资源。

Django 没有与@hybrid_property或在变量中存储任意 SQL 结构等价的东西。使用查找和转换将代码分解成可重用的组件。

这些在查询中以类似于连接的方式被引用,所以如果前面的代码是 Django 模型,我们将能够使用

DataPoints.objects.filter(collected_at__date=datetime.date.today())

这在日期时间字段上使用内置的date转换,将日期时间转换为一个日期。定义了一个转换器,用一个lookup_name属性指定它可用的名称,用一个output_field属性指定它创建的类型。它可以有一个function属性(如果它直接映射到一个单参数数据库函数),或者它可以定义一个定制的as_sql(...)方法。

查找的工作方式类似于转换器,但是它不能被链接,因此没有输出类型。它提供了一个lookup_name属性和一个as_sql(...)方法来生成相关的 SQL。这些也可以通过__name访问,如果没有指定其他的,名为exact的查找是默认的。

转换器和查找都需要注册才能使用。它们可以根据字段类型或另一个变压器进行注册。如果它们注册在一个字段上,它们将总是在任何具有该类型的表达式上可用,但是如果它们注册在一个转换器上,它们只有在紧跟转换器之后时才有效。我们可以通过在collected_at__date中使用的TruncDate转换器上定义一个自定义的exact查找来构建一个自定义的等式检查,如清单 8-11 所示。每当我们使用datetimefield__date时,这都适用,但在使用本地日期列时不适用。

from django.db import models
from django.db.models.functions.datetime import TruncDate

@TruncDate.register_lookup
class DateExact(models.Lookup):
    lookup_name = 'exact'

    def as_sql(self, compiler, connection):
        # self.lhs (left-hand-side of the comparison) is always TruncDate, we # want its argument
        underlying_dt = self.lhs.lhs
        # Instead, we want to wrap the rhs with TruncDate
        other_date = TruncDate(self.rhs)
        # Compile both sides
        lhs, lhs_params = compiler.compile(underlying_dt)
        rhs, rhs_params = compiler.compile(other_date)
        params = lhs_params + rhs_params + lhs_params + rhs_params
        # Return ((lhs >= rhs) AND (lhs < rhs+1)) - compatible with # postgresql only!
        return '%s >= %s AND %s < (%s + 1)' % (lhs, rhs, lhs, rhs), params

Listing 8-11Implementation of a date comparison in Django’s ORM

与 SQLAlchemy 版本一样,这允许在使用collected_at__date=datetime.date.today()时进行高效的自定义查找,但是对于collected_at__date__le==datetime.date.today()和其他比较,会退回到效率较低的强制转换行为。

根据视图查询

在整个代码库中,很多地方都需要一个很难用 ORM 表示的查询。由于指定连接的方式,这在使用 Django ORM 时稍微常见一些,但是在使用 SQLAlchemy 时确实会发生。一个典型的例子是关联一个表中的多行,特别是按日期或地理位置,而不是与另一个表中的一行相关。例如,一个存储用户和旅行计划并希望查询在给定日期哪些用户对彼此接近的数据库很难在 ORM 中表示。

在这种情况下,您可能会发现创建数据库视图并对其进行查询更容易。它不会改变性能特征, 12 ,但确实允许将复杂的查询像表一样处理,大大简化了等式的 Python 一侧。

SQLAlchemy 支持从视图派生的表,因此我们可以使用我们之前创建的查询,将其转换为视图,然后将其作为表映射回 SQLAlchemy。我们可以在数据库控制台中手动创建视图,但是我建议创建一个新的 alembic 版本来发出CREATE VIEW语句,这样它就可以更容易地跨实例部署。创建不带--autogenerate标志的 alembic 版本,并修改结果文件,如清单 8-12 所示。

"""Add daily summary view

Revision ID: 6962f8455a6d
Revises: 4b2df8a6e1ce
Create Date: 2019-12-03 11:50:24.403402

"""
from alembic import op

# revision identifiers, used by Alembic.
revision = "6962f8455a6d"
down_revision = "4b2df8a6e1ce"
branch_labels = None
depends_on = None

def upgrade():
    create_view = """
    CREATE VIEW daily_summary AS
      SELECT
        datapoints.sensor_name AS sensor_name,
        datapoints.data AS data,
        count(datapoints.id) AS count
    FROM datapoints
    WHERE
        datapoints.collected_at >= CAST(CURRENT_DATE AS DATE)
        AND
        datapoints.collected_at < CAST(CURRENT_DATE AS DATE) + 1
    GROUP BY
        datapoints.sensor_name,
        datapoints.data;
    """
    op.execute(create_view)

def downgrade():
    op.execute("""DROP VIEW daily_summary""")

Listing 8-12New migration to add a view with raw SQL

我们现在可以创建一个表对象来引用这个视图,允许我们在 SQLAlchemy 中生成查询:

daily_summary_view = Table(
    "daily_summary",
    metadata,
    sqlalchemy.Column("sensor_name", sqlalchemy.String),
    sqlalchemy.Column("data", JSONB),
    sqlalchemy.Column("count", sqlalchemy.Integer),
    info={"is_view": True},
)

info 行允许我们设置任意的元数据。在这种情况下,在env.py文件中使用is_view元数据来配置 alembic,以便在自动生成修订时忽略带有该标记的表。如果没有这些,alembic 将试图创建与我们的视图相冲突的匹配表。需要修改env.py文件以包含清单 8-13 中给出的函数,并且两个context.configure(...)函数调用必须将include_object=include_object添加到参数中。

from logging.config import fileConfig

from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context

from apd.aggregation.database import metadata as target_metadata

def include_object(object, name, type_, reflected, compare_to):
    if object.info.get("is_view", False):
        return False
    return True

def run_migrations_online():
    connectable = engine_from_config(
        config.get_section(config.config_ini_section),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )

    with connectable.connect() as connection:
        context.configure(
            connection=connection,
            target_metadata=target_metadata,
            include_object=include_object,
        )

        with context.begin_transaction():
            context.run_migrations()

Listing 8-13Changes to env.py to enable Table objects to represent views

通过前面的更改,在执行相同的 SQL 语句时,可以将汇总 SQL 语句简化为db_session.query(daily_summary_view)。每次使用视图时,都应该仔细考虑这种变化。在 SQL 语句上使用视图通常不会更清楚,但是对于更复杂的查询,我建议您记住这种未被充分利用的技术。

可供选择的事物

对于在异步上下文中与 SQL 数据库进行交互,我推荐部分使用 SQLAlchemy,但这还不够完美。根据您的使用情况,有一些替代方法可能是合适的。

还有一些 async-native ORM 正在开发中,比如乌龟 ORM 。它从根本上支持 asyncio,所以它不会遇到 SQLAlchemy 遇到的潜在阻塞问题。它目前是一个年轻的项目,所以虽然它是一个有趣的方法,我会继续关注它,但我现在不能推荐它用于生产代码。

另一种方法是使用类似于 asyncpg 的工具降低到较低的数据库集成级别。这允许与数据库进行完全异步的交互,而不需要将工作交给线程。缺点是没有内置的 SQL 生成器,所以它明显不太用户友好,并且您更容易出错。一些需要特别快的数据库连接的简单应用确实使用了这种方法,但是我不建议在一般情况下使用这种方法。

最后,对于 SQLAlchemy 导致查询阻塞的风险,有一种实用的方法,我在本章前面提到过。有时,最好的解决方案是接受风险,因为使用 SQLAlchemy 的好处自然会超过性能损失的后果。这在服务器端应用中是绝对不可接受的,在服务器端应用中,阻塞和减速会导致客户端性能严重下降,但是在客户端应用中,使用 asyncio 来提高代码的性能(否则代码将是单线程的),只使用 SQLAlchemy 并尽最大努力在执行器中运行阻塞代码几乎没有什么负面影响。

异步代码中的全局变量

尤其是在 web 开发中,经常会发现自己处于这样一种情况:你总是需要访问一个特定的对象,这意味着你所有的函数都需要将这个对象作为参数。这通常是请求对象,表示服务器当前正在处理的 HTTP 请求。还有一个配置对象也很常见,在我们的异步代码中,我们发现自己向许多函数签名添加了一个ClientSession对象,而不是为每个 HTTP 请求实例化一个新的对象。

所有这些都是全局变量的概念吸引人的地方。Django 和 Flask 都提供了访问配置的全局方式(django.settingsflask.current_app.config),Flask 还通过flask.request提供请求。

你经常听到人们批评使用全局变量的代码,说这证明你的应用没有被正确设计。我采取了一种更务实的观点:几乎每个函数潜在需要的对象不应该存在,但有时它们会存在。因此,它们应该是全局可用的,以防止它们污染整个系统的函数签名。

让我们使用 Python 的contextvars特性使我们的ClientSession对象成为这些全局可用的项目之一。上下文变量是线程局部变量思想的发展:变量是全局范围的,但是对于不同的并发代码可以有不同的值。通过threading.Local()创建的线程局部变量允许通过属性访问来存储和检索任意数据,但只能在一个线程内。任何其他并发线程将看不到其他线程存储的数据;每个线程都可以有自己的变量值。

我们的代码不是线程化的;它使用异步函数调用来引入并发性,因此线程局部变量将总是向所有并发任务显示相同的数据。这就是上下文变量有用的地方;它们为任意范围的值提供相同的范围,而不是将范围限制为总是当前线程。

上下文变量是用contextvars.ContextVar(...)构造函数定义的,它将变量的名称作为参数。

from contextvars import ContextVar
import aiohttp

http_session_var: ContextVar[aiohttp.ClientSession] = ContextVar("http_session")

ContextVar对象不直接存储值;它静默地委托给上下文对象。您可以手动实例化上下文对象,并使用该上下文执行一个函数,但是不需要使用异步代码来执行。 13 每当一个协程被调度为一个任务时,就会分配一个新的上下文,并从父任务的上下文中复制值。

可以使用set(...)方法为ContextVar设置值,并使用get()方法检索值。如果一段代码试图在当前上下文中没有设置的上下文变量上调用get(),就会引发 LookupError。必要的修改如表 8-5 所示。

表 8-5

get_data_points(...)所以 HTTP 客户端是作为上下文变量而不是参数传递的

| `http = http_session_var.get()``to_get = http.get(url, headers=headers)``async with to_get as request:``result = await request.json()``ok = request.status == 200` | `async with aiohttp.ClientSession() as http:``http_session_var.set(http)``tasks = [``get_data_points(server, api_key)``for server in servers``]` |

也可以使用set(...)的返回值临时覆盖上下文变量的值。这通常是不必要的,但是如果您确实需要在协程中更改一个变量,然后再将它改回来,那么这是首选模式:

reset_token = http_session_var.set(mockclient)
try:
    datapoints = await get_data_points("http://localhost", "")
finally:
    http_session_var.reset(reset_token)

Exercise 8-1: Extending The API

本章介绍了许多新概念,并涉及一些复杂的测试设置。这段代码很复杂,但是我们需要有信心在新版本发布时更新它。

目前,除了传感器的 URL 之外,我们没有传感器的任何标识符,并且随着 IP 地址的重新分配,这种标识符会随着时间而改变。我们应该创建一种识别传感器端点的方法,这样我们就可以更容易地从单个传感器中找到数据。向提供新端点的 apd.sensors 包添加新的 v2.1 API。这个端点应该是

@version.route("/deployment_id")
def deployment_id() -> t.Tuple[t.Dict[str, t.Any], int, t.Dict[str, str]]:
    headers = {"Content-Security-Policy": "default-src 'none'"}
    data = {"deployment_id": flask.current_app.config["APD_SENSORS_DEPLOYMENT_ID"]}
    return data, 200, headers

您将需要修改测试设置的许多部分来适应这种变化,包括以前 API 的 fixture 代码。记住,目的不是让旧 API 的测试代码永远不变,只是面向用户的 API 本身。

一旦完成了这些,更新 apd.aggregation 包以将deployment_id存储为DataPoint的属性,并使用 v2.1 API 从端点检索部署 ID。

这是一个显著的变化,相当于 apd.sensors 包的一个主要版本,也可能是本书中最困难的练习。然而,这是您迟早要在实际代码中进行的那种更改,所以练习一下是有好处的。

这两个更改的完整版本都在本章附带的代码中。

摘要

在本章中,我们已经讨论了运行异步代码的许多实际问题,尤其是在异步环境中使用数据库时可能会遇到的一些困难。要记住的最重要的事情是,无论是处理 SQLAlchemy、Django ORM,还是连接到另一个使用同步代码的数据库类型,run_in_executor 模式都是必要的,以避免显著降低性能的阻塞行为。但是,需要在性能优势和代码可读性优势之间取得平衡。这可能是您在编写异步代码时应该记住的最重要的平衡。

我们还讨论了许多在编写 Python 代码时通常有用的技术,无论是异步的还是其他的。使用contextlib的定制数据类和上下文管理器是非常有用的功能,您将在许多不同的上下文中使用它们。上下文变量和高效的 ORM 查询都非常有用,但程度较低。

在本章的过程中,apd.aggregation包已经成长了很多,达到了足以在生产中使用的质量。在下一章,我们将着眼于分析数据和构建有用的用户界面来显示报告。

额外资源

我推荐以下资源,以获取本章所涵盖主题的更多信息:

九、查看数据

在前一章的结尾,我们开始研究我们可能感兴趣的查询类型,但是我们还没有编写任何例程来帮助我们理解我们正在收集的数据。在这一章中,我们将回到 Jupyter 笔记本,这一次是作为数据分析工具,而不是原型制作工具。

IPython 和 Jupyter 无缝支持同步和异步函数调用。我们在这两种类型的 API 之间有一个(大部分)自由的选择。由于apd.aggregation包的其余部分是异步的,我建议我们创建一些实用程序协程来提取和分析数据。

查询功能

Jupyter 笔记本可以自由地导入和使用 SQLAlchemy 函数,但这需要用户了解大量关于聚合系统数据结构的内部信息。这实际上意味着我们已经创建的表和模型成为公共 API 的一部分,对它们的任何更改都可能意味着增加主版本号并为最终用户记录更改。

相反,让我们创建一些返回DataPoint记录供用户交互的函数。这样,只有DataPoint对象和函数签名是我们必须为人们维护的 API 的一部分。随着我们发现额外的需求,我们可以随时添加更多的功能。

首先,我们需要的最重要的特性是找到数据记录的能力,这些数据记录是按照收集时间排序的。这允许用户编写一些分析代码来分析传感器随时间变化的值。我们可能还想通过传感器类型、部署标识符和日期范围对此进行过滤。

我们必须决定函数的形式。它应该返回对象的列表或元组还是迭代器?元组可以让我们轻松地计算检索到的条目数,并多次遍历列表。另一方面,迭代器将允许我们最小化 RAM 的使用,这可能有助于我们支持更大的数据集,但限制我们只能迭代一次数据。我们将创建迭代器函数,因为它们允许更高效的代码。迭代器可以被调用代码转换成元组,所以我们的用户可以选择迭代元组。

在编写这个函数之前,我们需要一种方法让用户建立数据库连接。因为我们的目标之一是对我们的最终用户隐藏数据库的细节,所以我们不想要求为此使用 SQLAlchemy 函数。我们创建的用于连接数据库的定制函数(清单 9-1 )也可以设置上下文变量来表示我们的连接,从而避免了对所有搜索函数的显式会话参数的需求。

import contextlib
from contextvars import ContextVar
import functools
import typing as t

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm.session import Session

db_session_var: ContextVar[Session] = ContextVar("db_session")

@contextlib.contextmanager
def with_database(uri: t.Optional[str] = None) -> t.Iterator[Session]:
    """Given a URI, set up a DB connection, and return a Session as a context manager """
    if uri is None:
        uri = "postgresql+psycopg2://localhost/apd"
    engine = create_engine(uri)
    sm = sessionmaker(engine)
    Session = sm()
    token = db_session_var.set(Session)
    try:
        yield Session
        Session.commit()
    finally:
        db_session_var.reset(token)
        Session.close()

Listing 9-1query.py with a context manager to connect to the database

该函数充当(同步)上下文管理器,建立数据库连接和相关会话,在进入相关with块的主体之前,返回该会话并将其设置为db_session_var上下文变量的值。当上下文管理器退出时,它还会取消设置此会话,提交所有更改,并关闭会话。这确保了数据库中没有延迟锁,数据是持久的,并且使用db_session_var变量的函数只能在上下文管理器的主体中使用。

如果我们确保已经安装了聚合包的环境在 Jupyter 中注册为内核,我们就可以开始在笔记本中编写实用函数了。我还建议安装一些助手包,这样我们可以更容易地可视化结果。

> pipenv install ipython matplotlib
> pipenv run ipython kernel install --user --name="apd.aggregation"

我们现在可以启动一个新的 Jupyter 笔记本(清单 9-2 ,选择apd.aggregation内核并连接到数据库,使用新的with_database(...)装饰器。为了测试连接,我们可以使用产生的会话和我们的datapoint_table对象手动查询数据库。

from apd.aggregation.query import with_database
from apd.aggregation.database import datapoint_table

with with_database("postgresql+psycopg2://apd@localhost/apd") as session:
    print(session.query(datapoint_table).count())

Listing 9-2Jupyter cell to find number of sensor records

我们还需要编写返回DataPoint对象供用户分析的函数。最终,我们将不得不处理由于处理大量数据而导致的性能问题,但是您为解决问题而编写的第一个代码不应该被优化,一个简单的实现既更容易理解,也更可能不会因为太聪明而受到影响。我们将在下一章研究一些优化技术。

*Premature Optimization

调试比一开始写代码要难两倍。因此,如果你尽可能聪明地编写代码,从定义上来说,你没有足够的聪明去调试它。

-布莱恩·金格

Python 不是最快的编程语言;编写代码来最小化固有的缓慢可能很诱人,但是我强烈建议抵制这种冲动。我见过“高度优化”的代码需要一个小时来执行,而当替换为相同逻辑的简单实现时,只需要两分钟就可以完成。

这并不常见,但是当你使你的代码更加精细时,你的工作就变得更加困难。

如果您编写了一个方法的最简单版本,您可以将其与后续版本进行比较,以确定您是在使代码变得更快还是更复杂。

我们将实现的第一个版本的get_data()返回数据库中所有的DataPoint对象,而不必担心处理任何 SQLAlchemy 对象。我们已经决定创建一个生成器协程,而不是一个返回DataPoint对象列表的函数(或协程),所以我们最初的实现是清单 9-3 中的那个。

async def get_data() -> t.AsyncIterator[DataPoint]:
    db_session = db_session_var.get()
    loop = asyncio.get_running_loop()
    query = db_session.query(datapoint_table)
    rows = await loop.run_in_executor(None, query.all)
    for row in rows:
        yield DataPoint.from_sql_result(row)

Listing 9-3Simplest implementation of get_data()

该函数从由with_database(...)设置的上下文变量中获取会话,构建查询对象,然后使用执行器运行该对象的 all 方法,在 all 方法运行时让位于其他任务。迭代查询对象而不是调用query.all()会导致循环运行时触发数据库操作,所以我们必须小心,只在异步代码中设置查询,并将all()函数调用委托给执行器。这样做的结果是一个 SQLAlchemy 的轻量级结果列表,名为 rows 变量中的 tuples,然后我们可以对其进行迭代,产生匹配的DataPoint对象。

由于rows变量包含所有结果对象的列表,我们知道在执行返回到我们的get_data()函数之前,所有数据都已经被数据库处理过,并在执行器中被解析为 SQLAlchemy。这意味着在第一个DataPoint对象对最终用户可用之前,我们使用了存储完整结果集所需的所有 RAM。当我们不知道我们是否需要所有这些数据时,存储所有这些数据是有点内存和时间效率低下的,但是在迭代器中对数据进行分页的复杂方法将是过早优化的一个例子。不要改变这种天真的方法,直到它成为一个问题。

我们总是不得不处理检索 SQLAlchemy 行对象的内存和时间开销,但是表 9-1 中的数字让我们知道通过将它们转换成DataPoint类我们给系统增加了多少开销。一百万行将涉及额外的 152 兆字节的 RAM 和额外的 1.5 秒的处理时间。这两者都在现代计算机的能力范围之内,并且适合于不经常执行的任务,所以它们不是当前的问题。

表 9-1

SQLAlchemy 行和我们的 DataPoint 类的 RAM 使用和实例化时间的比较

|

目标

|

尺寸 1

|

时间实例化 2

|
| --- | --- | --- |
| SQLAlchemy 结果行 | 80 字节 | 0.4 微秒 |
| 数据点 | 152 字节 | 1.5 微秒 |

*结果可能因 Python 实现和可用处理能力而异

然而,因为我们正在创建一个迭代器,所以不能保证我们的DataPoint对象会立刻驻留在内存中。如果消费代码没有保存对它们的引用,那么在它们被使用后,它们可以立即被垃圾回收。例如,在清单 9-4 中,我们使用两个新的助手函数来计算行数,而没有任何数据点对象驻留在内存中。

from apd.aggregation.query import with_database, get_data

with with_database("postgresql+psycopg2://apd@localhost/apd") as session:
    count = 0
    async for datapoint in get_data():
        count += 1
    print(count)

Listing 9-4Jupyter cell to count data points using our helper context manager

仅仅计算数据点并不是分析数据的有趣方式。我们可以开始尝试通过在散点图上绘制数值来理解这些数据。让我们从一个简单的健全性检查开始,绘制出RelativeHumidity传感器的值与日期的关系(列表 9-5 )。这是一个很好的开始,因为存储的数据是浮点数而不是基于字典的结构,所以我们不需要解析值。

matplotlib 库可能是 Python 中最流行的绘图库。它的plot_date(...)函数非常适合绘制一系列随时间变化的数值。它需要 x 轴的值列表和 y 轴的相应值列表,以及在绘制点 3 时使用的样式和一个标志来设置哪个轴包含日期值。我们的get_data(...)函数不直接返回我们需要的 x 和 y 参数,它返回数据点对象的异步迭代器。

我们可以使用 list comprehension 将数据点对象的异步 iterable 转换为包含来自单个传感器的日期和值对的元组列表。此时,我们有了一个日期和值对的列表,可以使用内置的zip(...) 4 函数将分组转换为一对列表,一个用于日期,另一个用于值。

from apd.aggregation.query import with_database, get_data

from matplotlib import pyplot as plt

async def plot():
    points = [
        (dp.collected_at, dp.data)
        async for dp in get_data()
        if dp.sensor_name=="RelativeHumidity"
    ]
    x, y = zip(*points)
    plt.plot_date(x, y, "o", xdate=True)

with with_database("postgresql+psycopg2://apd@localhost/apd") as session:
    await plot()
plt.show()

Listing 9-5Relative humidity plotting jupyter cell, with the output chart it generates

img/481001_1_En_9_Figa_HTML.jpg

过滤数据

最好在查询阶段过滤数据,而不是在迭代时丢弃所有不符合我们标准的传感器数据。现在,选择每一条数据,创建一个结果对象,然后是一个DataPoint对象,只有这样才跳过不相关的条目。为此,我们可以向get_data(...)方法添加一个额外的参数,该参数决定是否将sensor_data上的过滤器应用于生成的查询。

async def get_data(sensor_name: t.Optional[str] = None) -> t.AsyncIterator[DataPoint]:
    db_session = db_session_var.get()
    loop = asyncio.get_running_loop()
    query = db_session.query(datapoint_table)
    if sensor_name:
        query = query.filter(datapoint_table.c.sensor_name == sensor_name)
    query = query.order_by(datapoint_table.c.collected_at)

这种方法节省了大量开销,因为这意味着只有相关的传感器数据点被传递给最终用户,而且这是一个更自然的接口。用户希望能够指定他们想要的数据,而不是绝对获取所有数据并手动过滤。清单 9-6 中的函数版本用不到一秒的时间来执行我的样本数据集(相比之下,前一版本用了 3 秒多),但显示的是相同的图表。

from apd.aggregation.query import with_database, get_data

from matplotlib import pyplot as plt

async def plot():
    points = [(dp.collected_at, dp.data) async for dp in get_data(sensor_name="RelativeHumidity")]
    x, y = zip(*points)
    plt.plot_date(x, y, "o", xdate=True)

with with_database("postgresql+psycopg2://apd@localhost/apd") as session:
    await plot()
plt.show()

Listing 9-6Delegating filtering to the get_data function

这个绘图函数很短,不太复杂;它代表了从数据库加载数据的一个非常自然的接口。不利的一面是,将多个部署混合在一起会导致图表不清晰,因为给定时间有多个数据点。Matplotlib 支持用不同的逻辑结果集多次调用plot_date(...),然后用不同的颜色显示。我们的用户可以通过在迭代get_data(...)调用的结果时创建多个点列表来实现这一点,如清单 9-7 所示。

import collections

from apd.aggregation.query import with_database, get_data

from matplotlib import pyplot as plt

async def plot():
    legends = collections.defaultdict(list)
    async for dp in get_data(sensor_name="RelativeHumidity"):
        legends[dp.deployment_id].append((dp.collected_at, dp.data))

    for deployment_id, points in legends.items():
        x, y = zip(*points)
        plt.plot_date(x, y, "o", xdate=True)

with with_database("postgresql+psycopg2://apd@localhost/apd") as session:
    await plot()
plt.show()

Listing 9-7Plotting all sensor deployments independently

img/481001_1_En_9_Figb_HTML.jpg

这又使得界面不自然;对于最终用户来说,更合理的做法是迭代部署,然后迭代传感器数据值,而不是迭代所有数据点,然后手动将它们组织到列表中。另一种方法是创建一个列出所有部署 id 的新函数,然后允许get_data(...)通过deployment_id进行过滤。这将允许我们遍历单个部署,并进行新的get_data(...)调用以仅获取该部署的数据。清单 9-8 展示了这一点。

async def get_deployment_ids():
    db_session = db_session_var.get()
    loop = asyncio.get_running_loop()
    query = db_session.query(datapoint_table.c.deployment_id).distinct()
    return [row.deployment_id for row in await loop.run_in_executor(None, query.all)]

async def get_data(
    sensor_name: t.Optional[str] = None,
    deployment_id: t.Optional[UUID] = None,
) -> t.AsyncIterator[DataPoint]:
    db_session = db_session_var.get()
    loop = asyncio.get_running_loop()
    query = db_session.query(datapoint_table)
    if sensor_name:
        query = query.filter(datapoint_table.c.sensor_name == sensor_name)
    if deployment_id:
        query = query.filter(datapoint_table.c.deployment_id == deployment_id)
    query = query.order_by(
        datapoint_table.c.collected_at,
    )

Listing 9-8Extended data collection functions for deployment_id filtering

这个新函数可用于循环多个对get_data(...)的调用,而不是 plot 函数循环并将结果数据点分类到独立的列表中。清单 9-9 展示了一个非常自然的接口,用于循环单个传感器的所有部署,其行为与之前的版本相同。

import collections

from apd.aggregation.query import with_database, get_data, get_deployment_ids

from matplotlib import pyplot as plt

async def plot(deployment_id):
    points = []
    async for dp in get_data(sensor_name="RelativeHumidity", deployment_id=deployment_id):
        points.append((dp.collected_at, dp.data))

    x, y = zip(*points)
    plt.plot_date(x, y, "o", xdate=True)

with with_database("postgresql+psycopg2://apd@localhost/apd") as session:
    deployment_ids = await get_deployment_ids()
    for deployment in deployment_ids:
        await plot(deployment)
plt.show()

Listing 9-9Plotting all deploymens using the new helper functions

这种方法允许最终用户单独询问每个部署,因此一次只有传感器和部署组合的相关数据被载入 RAM。这是一个非常适合提供给最终用户的 API。

多级迭代器

我们之前修改了按传感器名称过滤的界面,以在数据库中进行过滤,从而避免重复不必要的数据。我们新的部署 id 过滤器不是用来排除我们不需要的数据,而是用来使独立遍历每个逻辑组变得更容易。我们不需要在这里使用过滤器,我们正在使用一个使界面更加自然。

如果您经常使用标准库中的itertools模块,您可能已经使用过groupby(...)函数。它接受一个迭代器和一个键函数,并返回一个迭代器的迭代器,第一个迭代器是键函数的值,第二个迭代器是与键函数的给定结果匹配的一系列值。这就是我们一直试图通过列出我们的部署,然后过滤数据库查询来解决的问题。

groupby(...)的关键函数通常是一个简单的 lambda 表达式,但它可以是任何函数,比如来自操作符模块的函数之一。比如operator.attrgetter("deployment_id")相当于lambda obj: obj.deployment_idoperator.itemgetter(2)相当于lambda obj: obj[2].

对于这个例子,我们将定义一个键函数,它返回一个模为 3 的整数的值,以及一个data()生成器函数,它产生一系列固定的数字,并在这个过程中打印它的状态。这让我们可以清楚地看到底层迭代器是何时高级的。

import itertools
import typing as t

def mod3(n: int) -> int:
    return n % 3

def data() -> t.Iterable[int]:
    for number in [0, 1, 4, 7, 2, 6, 9]:
        print(f"Yielding {number}")
        yield number

我们可以遍历 data()生成器的内容并打印 mod3 函数的值,这让我们看到第一组有一个项目,然后是一组三个项目,然后是一组一个项目,然后是一组两个项目。

>>> print([mod3(number) for number in data()])
data() is starting
Yielding 0
Yielding 1
Yielding 4
Yielding 7
Yielding 2
Yielding 6
Yielding 9
data() is complete
[0, 1, 1, 1, 2, 0, 0]

设置 groupby 不会消耗基础 iterable 当 groupby 被迭代时,它生成的每个项目都被处理。为了正确工作,groupby 只需要确定当前项是否与前一个项在同一个组中,或者是否有新的组开始,它不需要将 iterable 作为一个整体来分析。对于 key 函数来说,具有相同值的项只有在它们是输入迭代器中的连续块时才会被分组在一起,所以通常要确保底层迭代器是有序的,以避免分组。

通过用mod3(...) key 函数在我们的数据上创建一个 groupby,我们可以创建一个两级循环,首先迭代 key 函数的值,然后迭代产生那个键值的来自data()的值。

>>> for val, group in itertools.groupby(data(), mod3):
...     print(f"Starting new group where mod3(x)=={val}")
...     for number in group:
...         print(f"x=={number} mod3(x)=={mod3(val)}")
...     print(f"Group with mod3(x)=={val} is complete")
...
data() is starting
Yielding 0
Starting new group where mod3(x)==0
x==0 mod3(x)==0
Yielding 1
Group with mod3(x)==0 is complete
Starting new group where mod3(x)==1
x==1 mod3(x)==1
Yielding 4
x==4 mod3(x)==1
Yielding 7
x==7 mod3(x)==1
Yielding 2
Group with mod3(x)==1 is complete
Starting new group where mod3(x)==2
x==2 mod3(x)==2
Yielding 6
Group with mod3(x)==2 is complete
Starting new group where mod3(x)==0
x==6 mod3(x)==0
Yielding 9
x==9 mod3(x)==0
data() is complete
Group with mod3(x)==0 is complete

从 print 语句的输出中,我们可以看到 groupby 一次只提取一项,但是它管理迭代器的方式使得对值的循环很自然。每当内部循环请求一个新项时,groupby 函数都会从底层迭代器请求一个新项,然后根据该值决定其行为。如果 key 函数报告与前一项相同的值,它会向内部循环产生新值;否则,它表示内部循环完成,并保持该值,直到下一个内部循环开始。

如果我们有具体的条目列表,迭代器的行为就像我们预期的一样;如果不需要,就不需要迭代内部循环。如果我们在推进外循环之前没有完全迭代内循环,groupby 对象将透明地推进源 iterable,就像我们已经做的那样。在下面的例子中,我们跳过了三个 where mod3(...)==1的组,我们可以看到底层迭代器被 groupby 对象推进了三次:

>>> for val, group in itertools.groupby(data(), mod3):
...     print(f"Starting new group where mod3(x)=={val}")
...     if val == 1:
...         # Skip the ones
...         print("Skipping group")
...         continue
...     for number in group:
...         print(f"x=={number} mod3(x)=={mod3(val)}")
...     print(f"Group with mod3(x)=={val} is complete")
...
data() is starting
Yielding 0
Starting new group where mod3(x)==0
x==0 mod3(x)==0
Yielding 1
Group with mod3(x)==0 is complete
Starting new group where mod3(x)==1
Skipping group
Yielding 4
Yielding 7
Yielding 2
Starting new group where mod3(x)==2
x==2 mod3(x)==2
Yielding 6
Group with mod3(x)==2 is complete
Starting new group where mod3(x)==0
x==6 mod3(x)==0
Yielding 9
x==9 mod3(x)==0
data() is complete
Group with mod3(x)==0 is complete

当我们使用它时,行为是直观的,但是很难理解它是如何实现的。图 9-1 显示了一对流程图,一个用于外部循环,一个用于每个单独的内部循环。

img/481001_1_En_9_Fig1_HTML.jpg

图 9-1

演示 groupby 如何工作的流程图

如果我们有一个标准迭代器(与异步迭代器相反),我们可以通过deployment_id对数据进行排序,并使用itertools.groupby(...)来简化我们的代码以处理多个部署,而不需要查询单个部署。我们可以遍历这些组,并使用列表理解和zip(...),以我们已经使用的相同方式处理内部迭代器,而不是对每个组进行新的get_data(...)调用。

不幸的是,在撰写本文时,groupby 还没有完全异步的对等物。虽然我们可以编写一个函数来返回一个异步迭代器,它的值是数据点对的 UUID 和异步迭代器,但是没有办法将它们自动分组。

冒着编写聪明代码的风险,我们可以使用闭包编写一个自己处理异步代码的 groupby 实现。它将向最终用户公开多个迭代器,这些迭代器在同一个底层迭代器上工作,就像itertools.groupby(...)一样。如果有可用的库函数,最好使用库函数。

每当我们发现 key 函数的一个新值时,我们需要返回一个新的生成器函数,它维护了对底层源迭代器的引用。这样,当有人推进一个项迭代器时,它可以选择要么产生它接收的数据点,要么指示它是项迭代器的结尾,就像 groupby 函数所做的那样。同样,如果我们在一个 item 迭代器被消耗之前推进外部迭代器,它需要“快进”通过底层迭代器,直到找到一个新组的开始。

清单 9-10 中的代码是一个单独的函数,它委托给我们的 get data 函数,并将其包装在适当的 groupby 逻辑中,而不是一个可以适应任何迭代器的通用函数。

async def get_data_by_deployment(
    *args, **kwargs
) -> t.AsyncIterator[t.Tuple[UUID, t.AsyncIterator[DataPoint]]]:
    """Return an Async Iterator that contains two-item pairs.
    These pairs are a string (deployment_id), and an async iterator that contains
    the datapoints with that deployment_id.

    Usage example:

        async for deployment_id, datapoints in get_data_by_deployment():
            print(deployment_id)
            async for datapoint in datapoints:
                print(datapoint)
            print()
    """
    # Get the data, using the arguments to this function as filters
    data = get_data(*args, **kwargs)

    # The two levels of iterator share the item variable, initialise it # with the first item from the iterator. Also set last_deployment_id
    # to None, so the outer iterator knows to start a new group.
    last_deployment_id: t.Optional[UUID] = None
    try:
        item = await data.__anext__()
    except StopAsyncIteration:
        # There were no items in the underlying query, return immediately
        return

    async def subiterator(group_id: UUID) -> t.AsyncIterator[DataPoint]:
        """Using a closure, create an iterator that yields the current
        item, then yields all items from data while the deployment_id matches
        group_id, leaving the first that doesn't match as item in the enclosing
        scope."""
        # item is from the enclosing scope
        nonlocal item
        while item.deployment_id == group_id:
            # yield items from data while they match the group_id this iterator represents
            yield item
            try:
                # Advance the underlying iterator
                item = await data.__anext__()
            except StopAsyncIteration:
                # The underlying iterator came to an end, so end the subiterator too
                return

    while True:
        while item.deployment_id == last_deployment_id:
            # We are trying to advance the outer iterator while the
            # underlying iterator is still part-way through a group.# Speed through the underlying until we hit an item where
            # the deployment_id is different to the last one (or,
            # is not None, in the case of the start of the iterator)
            try:
                item = await data.__anext__()
            except StopAsyncIteration:
                # We hit the end of the underlying iterator: end this # iterator too
                return
        last_deployment_id = item.deployment_id
        # Instantiate a subiterator for this group
        yield last_deployment_id, subiterator(last_deployment_id)

Listing 9-10An implementation of get_data_by_deployment that acts like an asynchronous groupby

这使用await data.__anext__()来推进底层数据迭代器,而不是异步 for 循环,以使迭代器在多个地方被使用的事实更加明显。

这个生成器协程的实现在本章的代码中。我鼓励您尝试添加打印语句和断点,以帮助理解控制流。这段代码比您需要编写的大多数 Python 代码都要复杂(我要提醒您不要将这种复杂程度引入到生产代码中;把它作为一个自包含的依赖项更好),但是如果你能理解它是如何工作的,你就能彻底掌握生成器函数、异步迭代器和闭包的细节。随着异步代码在生产代码中的使用越来越多,提供这种迭代器复杂操作的库肯定会出现。

附加过滤器

我们为sensor_namedeployment_id添加了get_data(...)过滤器,但是选择显示的时间范围也很有用。我们可以用两个日期时间过滤器来实现这一点,这两个过滤器用于过滤collected_at字段。清单 9-11 中显示了支持此功能的get_data(...)的实现,但是因为get_data_by_deployment(...)将所有参数原封不动地传递给get_data(...),所以我们不需要修改该函数来允许我们的分析中的日期窗口。

async def get_data(
    sensor_name: t.Optional[str] = None,
    deployment_id: t.Optional[UUID] = None,
    collected_before: t.Optional[datetime.datetime] = None,
    collected_after: t.Optional[datetime.datetime] = None,
) -> t.AsyncIterator[DataPoint]:
    db_session = db_session_var.get()
    loop = asyncio.get_running_loop()
    query = db_session.query(datapoint_table)
    if sensor_name:
        query = query.filter(datapoint_table.c.sensor_name == sensor_name)
    if deployment_id:
        query = query.filter(datapoint_table.c.deployment_id == deployment_id)
    if collected_before:
        query = query.filter(datapoint_table.c.collected_at < collected_before)
    if collected_after:
        query = query.filter(datapoint_table.c.collected_at > collected_after)
    query = query.order_by(
        datapoint_table.c.deployment_id,
        datapoint_table.c.sensor_name,
        datapoint_table.c.collected_at,
    )

    rows = await loop.run_in_executor(None, query.all)
    for row in rows:
        yield DataPoint.from_sql_result(row)

Listing 9-11get_data method with sensor, deployment, and date filters

测试我们的查询功能

查询功能需要测试,就像其他任何功能一样。与我们到目前为止编写的大多数函数不同,查询函数带有大量可选参数,这些参数会显著改变返回数据的输出。虽然我们不需要为每个过滤器测试大范围的值(我们可以相信我们的数据库的查询支持工作正常),但我们需要测试每个选项是否按预期工作。

我们需要一些安装夹具来测试依赖于数据库的功能。虽然我们可以模拟数据库连接,但我不推荐这样做,因为数据库是非常复杂的软件,不太适合被模拟。

测试数据库应用最常见的方法是创建一个新的空数据库,并允许测试控制表和数据的创建。一些数据库软件,比如 SQLite,允许动态创建新的数据库,但是大多数都需要预先建立数据库。

假设我们有一个空的数据库,我们需要一个连接它的夹具,一个设置表的夹具,一个设置数据的夹具。连接夹具与with_database上下文管理器、 5 非常相似,填充数据库的函数将包括我们可以使用db_session.execute(datapoint_table.insert().values(...))插入的样本数据。

建立数据库表的设备是最困难的。最简单的方法是使用metadata.create_all(...),就像我们在引入数据库迁移的 alembic 之前所做的那样。这适用于大多数应用,因此通常是最佳选择。我们的应用包括一个数据库视图,它不是由 SQLAlchemy 管理的,而是由 Alembic 中的一个定制迁移管理的。因此,我们需要使用 Alembic 的升级功能来设置我们的数据库表。我们需要的相关夹具如清单 9-12 所示。

import datetime
from uuid import UUID

from apd.aggregation.database import datapoint_table

from alembic.config import Config
from alembic.script import ScriptDirectory
from alembic.runtime.environment import EnvironmentContext
import pytest

@pytest.fixture
def db_uri():
    return "postgresql+psycopg2://apd@localhost/apd-test"

@pytest.fixture
def db_session(db_uri):
    from sqlalchemy import create_engine
    from sqlalchemy.orm import sessionmaker

    engine = create_engine(db_uri, echo=True)
    sm = sessionmaker(engine)
    Session = sm()
    yield Session
    Session.close()

@pytest.fixture
def migrated_db(db_uri, db_session):
    config = Config()
    config.set_main_option("script_location", "apd.aggregation:alembic")
    config.set_main_option("sqlalchemy.url", db_uri)
    script = ScriptDirectory.from_config(config)

    def upgrade(rev, context):
        return script._upgrade_revs(script.get_current_head(), rev)

    def downgrade(rev, context):
        return script._downgrade_revs(None, rev)

    with EnvironmentContext(config, script, fn=upgrade):
        script.run_env()

    try:
        yield
    finally:
        # Clear any pending work from the db_session connection
        db_session.rollback()

        with EnvironmentContext(config, script, fn=downgrade):
            script.run_env()

@pytest.fixture
def populated_db(migrated_db, db_session):
    datas = [
        {
            "id": 1,
            "sensor_name": "Test",
            "data": "1",
            "collected_at": datetime.datetime(2020, 4, 1, 12, 0, 1),
            "deployment_id": UUID("b4c68905-b1e4-4875-940e-69e5d27730fd"),
        },
        # Additional sample data omitted from listing for brevity's sake
    ]
    for data in datas:
        insert = datapoint_table.insert().values(**data)
        db_session.execute(insert)

Listing 9-12Database setup fixtures

这为我们提供了一个环境,我们可以在其中编写测试来查询只包含已知值的数据库,因此我们可以编写有意义的断言。

参数化测试

Pytest 有一个特殊的功能,可以生成做一些非常相似的事情的多个测试:标记parameterize。如果一个测试函数被标记为参数化的,那么它可以有不对应于 fixtures 的附加参数,以及这些参数的一系列值。测试函数将运行多次,每个不同的参数值函数运行一次。我们可以使用这个特性编写函数来测试我们函数的各种过滤方法,而不需要大量的重复,如清单 9-13 所示。

class TestGetData:
    @pytest.fixture
    def mut(self):
        return get_data

    @pytest.mark.asyncio
    @pytest.mark.parametrize(
        "filter,num_items_expected",
        [
        ({}, 9),
        ({"sensor_name": "Test"}, 7),
        ({"deployment_id": UUID("b4c68905-b1e4-4875-940e-69e5d27730fd")}, 5),
        ({"collected_after": datetime.datetime(2020, 4, 1, 12, 2, 1),}, 3),
        ({"collected_before": datetime.datetime(2020, 4, 1, 12, 2, 1),}, 4),
        (
            {
                "collected_after": datetime.datetime(2020, 4, 1, 12, 2, 1),
                "collected_before": datetime.datetime(2020, 4, 1, 12, 3, 5),
            },
            2,
        ),
        ],
    )
    async def test_iterate_over_items(
        self, mut, db_session, populated_db, filter, num_items_expected
    ):
        db_session_var.set(db_session)
        points = [dp async for dp in mut(**filter)]
        assert len(points) == num_items_expected

Listing 9-13A parameterized get_data test to verify different filters

第一次运行这个测试时,它将filter={}, num_items_expected=9作为参数。第二次运行有filter={"sensor_name": "Test"}, num_items_expected=7,以此类推。这些测试功能中的每一个都将独立运行,并将被视为新的通过或未通过测试,视情况而定。

这将导致生成六个测试,名称类似于TestGetData.test_iterate_over_items[filter5-2]。这个名称是基于参数的,复杂的参数值(如filter)由它们的名称和列表中从零开始的索引来表示,简单的参数(如num_items_expected)直接包含在内。大多数情况下,您不需要关心名称,但是识别测试失败的变体是非常有用的。

显示多个传感器

我们现在有三个函数可以帮助我们连接到数据库,并以合理的顺序和可选的过滤迭代DataPoint对象。到目前为止,我们一直在使用matplotlib.pyplot.plot_dates(...)函数将成对的传感器值和日期转换成一个图表。这是一个辅助函数,通过在全局名称空间中提供各种绘图函数,使生成绘图变得更加容易。在制作多个图表时,这不是推荐的方法。

我们希望能够遍历我们的每种传感器类型,并为每种类型生成一个图表。如果我们要使用 pyplot API,我们将被限制使用一个单独的绘图,最高的值使轴倾斜,使得最低的值无法读取。相反,我们希望为每一个生成一个独立的图,并将它们并排显示。为此,我们可以使用matplotlib.pyplot.figure(...)figure.add_subplot(...)函数。子情节是一个对象,其行为大体上类似于matplotlib.pyplot,但是代表了一个更大的情节网格中的一个单独的情节。例如,figure.add_subplot(3,2,4)是三行两列网格图中的第四个图。

现在,我们的plot(...)函数假设它正在处理的数据是一个数字,可以直接传递给 matplotlib 以显示在我们的图表上。不过,我们的许多传感器都有不同的数据格式,例如温度传感器,它有一个温度字典,单位被用作它的值属性。这些不同的值需要先转换成数字,然后才能绘制出来。

我们可以在apd.aggregation中将我们的绘图函数重构为一个实用函数,以极大地简化我们的 Jupyter 笔记本,但我们需要确保它可以用于其他格式的传感器数据。每个图都需要为要绘制的传感器提供一些配置、绘制图的子图对象,以及从部署 id 到用于填充图的图例的面向用户的名称的映射。它还应该接受与get_data(...)相同的过滤参数,允许用户通过日期或部署 id 来约束他们的图表。

我们将把这个配置数据作为一个数据类的实例传递,它还包含一个对“clean”函数的引用。这个 clean 函数负责将 DataPoint 实例转换成一对可以由 matplotlib 绘制的值。clean 函数必须将DataPoint对象的 iterable 转换为 matplotlib 可以理解的(x, y)对的 iterable。对于RelativeHumidityRAMAvailable传感器,这是一个产生日期/浮点元组的简单问题,就像我们的代码到目前为止所做的那样。

async def clean_passthrough(
    datapoints: t.AsyncIterator[DataPoint],
) -> t.AsyncIterator[t.Tuple[datetime.datetime, float]]:
    async for datapoint in datapoints:
        if datapoint.data is None:
            continue
        else:
            yield datapoint.collected_at, datapoint.data

config 数据类还需要一些字符串参数,例如图表的标题、轴标签和 sensor_name,这些参数需要传递给get_data(...),以便找到该图表所需的数据。一旦定义了Config类,我们就可以创建两个 config 对象来表示两个传感器,这两个传感器使用原始浮点数作为它们的值类型,并创建一个函数来返回所有注册的配置。

将 matplotlib 中的图形函数与我们新的配置系统结合起来,我们可以编写一个新的plot_sensor(...)函数(清单 9-14 ),它可以使用 Jupyter 笔记本中的几行简单代码生成任意数量的图表。

@dataclasses.dataclass(frozen=True)
class Config:
    title: str
    sensor_name: str
    clean: t.Callable[[t.AsyncIterator[DataPoint]], t.AsyncIterator[t.Tuple[datetime.datetime, float]]]
    ylabel: str

configs = (
    Config(
        sensor_name="RAMAvailable",
        clean=clean_passthrough,
        title="RAM available",
        ylabel="Bytes",
    ),
    Config(
        sensor_name="RelativeHumidity",
        clean=clean_passthrough,
        title="Relative humidity",
        ylabel="Percent",
    ),
)

def get_known_configs() -> t.Dict[str, Config]:
    return {config.title: config for config in configs}

async def plot_sensor(config: Config, plot: t.Any, location_names: t.Dict[UUID,str], **kwargs) -> t.Any:
    locations = []
    async for deployment, query_results in get_data_by_deployment(sensor_name=config.sensor_name, **kwargs):
        points = dp async for dp in config['clean']
        if not points:
            continue
        locations.append(deployment)
        x, y = zip(*points)
        plot.set_title(config['title'])
        plot.set_ylabel(config['ylabel'])
        plot.plot_date(x, y, "-", xdate=True)
    plot.legend([location_names.get(l, l) for l in locations])
    return plot

Listing 9-14New config objects and plot function that uses it

有了这些新函数,我们可以修改 Jupyter 笔记本单元格来调用plot_sensor(...)函数,而不是在 Jupyter 中编写我们自己的绘图函数。由于这些助手函数,apd.aggregation 的最终用户需要编写的连接到数据库并呈现两个图表的代码(如清单 9-15 所示)大大缩短了。

import asyncio
from matplotlib import pyplot as plt

from apd.aggregation.query import with_database

from apd.aggregation.analysis import get_known_configs, plot_sensor
with with_database("postgresql+psycopg2://apd@localhost/apd") as session:

    coros = []
    figure = plt.figure(figsize = (20, 5), dpi=300)
    configs = get_known_configs()
    to_display = configs["Relative humidity"], configs["RAM available"]
    for i, config in enumerate(to_display, start=1):
        plot = figure.add_subplot(1, 2, i)
        coros.append(plot_sensor(config, plot, {}))
    await asyncio.gather(*coros)

display(figure)

Listing 9-15Jupyter cell to plot both Humidity and RAM Available, and their output

img/481001_1_En_9_Figc_HTML.jpg

由于TemperatureSolarCumulativeOutput传感器以{'unit': 'degC', 'magnitude': 8.4}的格式从pint包中返回序列化的对象,我们不能将这些与我们现有的clean_passthrough()函数一起使用;我们需要创造一个新的。最简单的是假设单位总是相同的,只提取量级线。这将会不正确地用不同的标度绘制任何温度,因为单位没有被校正。目前,我们所有的传感器都返回以摄氏度为单位的值,所以这不是一个严重的问题。

async def clean_magnitude(datapoints):
    async for datapoint in datapoints:
        if datapoint.data is None:
            continue
        yield datapoint.collected_at, datapoint.data["magnitude"]

如果我们使用这个新的 cleaner 函数来添加一个新的温度配置对象,我们会看到图 9-2 中的图表。从这些数据中我们可以清楚地看到,温度传感器并不完全可靠:我办公室的温度很少超过钢的熔点。

img/481001_1_En_9_Fig2_HTML.jpg

图 9-2

温度传感器输出有明显的误差,扭曲了数据

处理数据

我们采用的方法的一个优点是,我们可以对给定的数据执行相对任意的转换,从而允许我们丢弃我们认为不正确的数据点。在分析时丢弃数据通常比在收集时丢弃数据更好,因为检查数据点有效性的函数中的错误不会导致数据丢失,如果只是在分析时检查的话。我们总是可以在事后删除不正确的数据,但我们永远无法回忆起我们选择忽略的数据。

解决温度传感器问题的一种方法是让 clean 迭代器查看底层数据的移动窗口,而不是一次只查看一个DataPoint。这样,它可以使用传感器值的邻居来丢弃差异太大的值。

collections.deque类型对此很有用,因为它提供了一个具有可选最大大小的结构,所以我们可以将找到的每个温度添加到 deque 中,但是当读取它时,我们只能看到最后添加的n条目。deque 可以从左边缘或右边缘添加或移除项目,因此在将其用作受限窗口时,从同一端添加和弹出项目的一致性非常重要。

我们可以从过滤掉 DHT22 传感器、【6】支持范围之外的任何值开始,以去除最不正确的数据。这消除了许多(但不是全部)不正确的读数。过滤出单项峰值的一个简单方法是有一个三项窗口,产生中间项,除非它与两边的平均温度相差太大,如清单 9-16 所示。我们不想消除所有合理的波动,所以我们对“没有太大差异”的定义必须考虑到,诸如 21c、22c、21c 的读数运行是合理的,同时排除诸如 20c、60c、23c 的运行。

async def clean_temperature_fluctuations(
    datapoints: t.AsyncIterator[DataPoint],
) -> t.AsyncIterator[t.Tuple[datetime.datetime, float]]:
    allowed_jitter = 2.5
    allowed_range = (-40, 80)
    window_datapoints: t.Deque[DataPoint] = collections.deque(maxlen=3)

    def datapoint_ok(datapoint: DataPoint) -> bool:
        """Return False if this data point does not contain a valid temperature"""
        if datapoint.data is None:
            return False
        elif datapoint.data["unit"] != "degC":
            # This point is in a different temperature system. While it # could be converted
            # this cleaner is not yet doing that.
            return False
        elif not allowed_range[0] < datapoint.data["magnitude"] < allowed_range[1]:
            return False
        return True

    async for datapoint in datapoints:
        if not datapoint_ok(datapoint):
            # If the datapoint is invalid then skip directly to the next item
            continue

        window_datapoints.append(datapoint)
        if len(three_temperatures) == 3:
            # Find the temperatures of the datapoints in the window, then # average

            # the first and last and compare that to the middle point.
            window_temperatures = [dp.data["magnitude"] for dp in window_datapoints]
            avg_first_last = (window_temperatures[0] + window_temperatures[2]) / 2
            diff_middle_avg = abs(window_temperatures[1] - avg_first_last)
            if diff_middle_avg > allowed_jitter:
                pass
            else:
                yield window_datapoints[1].collected_at, window_temperatures[1]
        else:
            # The first two items in the iterator can't be compared to both # neighbors
            # so they should be yielded
            yield datapoint.collected_at, datapoint.data["magnitude"]
    # When the iterator ends the final item is not yet in the middle
    # of the window, so the last item must be explicitly yielded
    if datapoint_ok(datapoint):
        yield datapoint.collected_at, datapoint.data["magnitude"]

Listing 9-16An example implementation of a cleaner function for temperature

如图 9-3 所示,这种清洁功能可以产生更加平滑的温度趋势。清洗器过滤掉任何找不到温度的数据点以及任何严重的错误。它保留了温度趋势的细节;由于该窗口包含最后三个记录的数据点(甚至那些没有从数据集中排除的数据点),只要温度的突然变化持续至少两个连续读数,它就会开始反映在输出数据中。

img/481001_1_En_9_Fig3_HTML.jpg

图 9-3

使用适当的清洁剂时,相同数据的结果

Exercise 9-1: Add a Cleaner For Solarcumulativeoutput

SolarCumulativeOutput传感器返回瓦特小时数,其序列化方式与温度传感器相同。如果我们绘制这个图表,我们会看到一条不规则移动的上升趋势线。更有用的是看到某一时刻的发电量,而不是该时刻之前的总发电量。

为了实现这一点,我们需要将瓦特小时转换为瓦特,这意味着将瓦特小时数除以数据点之间的时间量。

编写一个clean_watthours_to_watts(...)迭代器协程,跟踪最后的时间和瓦特小时读数,找出差异,然后返回瓦特除以经过的时间。

例如,以下两个日期和值对将在下午 1 点产生一个值为 5.0 的输出条目。

[
    (datetime.datetime(2020, 4, 1, 12, 0, 0), {"magnitude": 1.0, "unit": "watt_hour"}),
    (datetime.datetime(2020, 4, 1, 13, 0, 0), {"magnitude": 6.0, "unit": "watt_hour"})
]

本章附带的代码包含本练习的一个工作环境,包括一个测试设置和一系列针对该功能的单元测试,但没有实现。本章的最终代码中还有一个 cleaner 的实现。

有了这些用于太阳能和温度的清洁器和配置条目,我们可以绘制一个 2x2 的图表网格。由于图表现在显示了所需的数据,现在是通过添加部署名称的值来提高可读性的好时机,这些值作为最终参数传递给清单 9-17 中的plot_sensor(...)

import asyncio
from uuid import UUID

from matplotlib import pyplot as plt

from apd.aggregation.query import with_database
from apd.aggregation.analysis import get_known_configs, plot_sensor

location_names = {
 UUID('53998a51-60de-48ae-b71a-5c37cd1455f2'): "Loft",
 UUID('1bc63cda-e223-48bc-93c2-c1f651779d69'): "Living Room",
 UUID('ea0683de-6772-4678-bfe7-6014f54ffc8e'): "Office",
 UUID('5aaa901a-7564-41fb-8eba-50cdd6fe9f80'): "Outside",
}

with with_database("postgresql+psycopg2://apd@localhost/apd") as session:
    coros = []
    figure = plt.figure(figsize = (20, 10), dpi=300)
    configs = get_known_configs().values()
    for i, config in enumerate(configs, start=1):
        plot = figure.add_subplot(2, 2, i)
        coros.append(plot_sensor(config, plot, location_names))
    await asyncio.gather(*coros)

display(figure)

Listing 9-17Final Jupyter cell to display 2x2 grid of charts

img/481001_1_En_9_Figd_HTML.jpg

与 Jupyter 小工具的交互性

到目前为止,我们生成图表的代码对最终用户没有交互性。我们目前显示所有记录的数据点,但是如果能够过滤,只显示一个时间段,而不需要修改代码来生成图表,将会非常方便。

为此,我们使用setup.cfgextras_require功能在ipywidgets上添加一个可选的依赖项,并使用pipenv install -e .[jupyter]在我们的环境中重新安装apd.aggregation包。

您可能还需要运行以下命令,以确保系统范围的 Jupyter 安装启用了对小部件的支持功能:

> pip install --user widgetsnbextension
> jupyter nbextension enable --py widgetsnbextension

安装了这个之后,我们可以请求 Jupyter 为每个参数创建交互式小部件,并使用用户选择的值调用函数。交互性允许查看笔记本的人选择任意输入值,而不需要修改单元格的代码,甚至不需要理解代码。

图 9-4 显示了一个将两个整数相加的函数示例,该函数已经连接到 Jupyter 的交互支持。在这种情况下,两个整数参数被赋予默认值 100,并呈现为滑块。用户可以操作这些滑块,函数的结果会自动重新计算。

img/481001_1_En_9_Fig4_HTML.jpg

图 9-4

加法函数的交互式视图

多重嵌套的同步和异步代码

我们不能将协程传递给interactive(...)函数,因为它被定义为一个标准的同步函数。它本身是一个同步函数,所以它甚至不可能await协程调用的结果。尽管 IPython 和 Jupyter 允许在通常不允许的地方使用await结构,但这是通过将单元封装在协程 7 中并将其作为任务调度来实现的;真正将同步和异步代码结合在一起的并不是深奥的魔法,而是一种为了方便而进行的黑客攻击。

我们的绘图代码需要等待plot_sensor(...)协程,所以 Jupyter 必须将单元格包装到协程中。协程只能由协程调用或直接在事件循环的run(...)函数上调用,因此异步代码通常会增长到整个应用都是异步的程度。拥有一组全同步或全异步的函数比混合这两种方法要容易得多。

我们在这里不能这样做,因为我们需要向interactive(...)提供一个函数,我们无法控制这个函数的实现。我们解决这个问题的方法是,我们必须将协程转换成一个新的同步方法。我们不希望仅仅为了适应interactive(...)函数而将所有代码重写为同步样式,所以用包装器函数来弥补这个差距是更好的选择。

协程需要访问一个事件循环,它可以使用该事件循环来调度任务,并负责调度任务。我们现有的事件循环不会这样做,因为它正忙于执行等待interactive(...)返回的协程。如果你还记得,在 asyncio 中是await关键字实现了协作多任务,所以我们的代码只有在遇到await表达式时才会在不同的任务之间切换。

如果我们正在运行一个协程,我们可以await另一个协程或任务,这允许事件循环执行其他代码。直到被等待的函数完成执行,执行才会返回到我们的代码,但是其他的协程可以同时运行。我们可以从异步上下文中调用类似interactive(...)的同步代码,但是这些代码会引入阻塞。因为这种阻塞不是对一个await语句的阻塞,所以在此期间执行不能传递给另一个协程。从异步函数调用任何同步函数都相当于保证一个代码块不包含await语句,这就保证了不会有其他协程的代码运行。

到目前为止,我们已经使用了asyncio.run(...)函数从同步代码中启动一个协程并阻塞等待它的结果,但是我们已经在对asyncio.run(main())的调用中,所以我们不能再这样做了。 8 由于interactive(...)调用在没有await表达式的情况下被阻塞,我们的包装器将运行在一个保证协程代码不能运行的环境中。尽管我们用来将异步协程转换为同步函数的包装函数必须安排协程的执行,但它不能依赖现有的事件循环来完成这项工作。

为了明确这一点,想象一个以两个函数作为参数的函数,如清单 9-18 所示。这两个函数都返回一个整数。这个函数调用作为参数传递的两个函数,将结果相加,然后返回这些整数的和。如果所有涉及的功能都是同步的,就没有问题。

import typing as t

def add_number_from_callback(a: t.Callable[[], int], b: t.Callable[[], int]) -> int:
    return a() + b()

def constant() -> int:
    return 5

print(add_number_from_callback(constant, constant))

Listing 9-18Example of calling only synchronous functions from a synchronous context

我们甚至可以从异步上下文中调用这个add_number_from_callback(...)函数并得到正确的结果,但要注意的是add_number_from_callback(...)会阻塞整个过程,这可能会抵消异步代码的好处。

async def main() -> None:
    print(add_number_from_callback(constant, constant))

asyncio.run(main())

我们特殊的调用是低风险的,因为我们知道没有 IO 请求可能会阻塞很长时间。但是,我们可能希望添加一个新函数,从 HTTP 请求中返回一个数字。如果我们已经有了一个协同例程来获取 HTTP 请求的结果,我们可能希望使用它,而不是将它重新实现为一个同步函数。获取数字(在本例中是从 random.org 随机数生成器服务获取)的协程示例如下:

import aiohttp

async def async_get_number_from_HTTP_request() -> int:
    uri = "https://www.random.org/integers/?num=1&min=1&max=100&col=1" "&base=10&format=plain"
    async with aiohttp.ClientSession() as http:
        response = await http.get(uri)
        return int(await response.text())

由于这是一个协程,我们不能将其直接传递给add_number_from_callback(...)函数。如果我们尝试,我们会看到 Python 错误TypeError: unsupported operand type(s) for +: 'int' and 'coroutine'9

您可以为async_get_number_from_HTTP_request编写一个包装函数来创建一个我们可以等待的新任务,但是这将把协程提交给现有的事件循环,我们已经决定这不是一个可行的解决方案。我们无法等待这个任务,因为在同步函数中使用await是无效的,以嵌套方式调用asyncio.run(...)也是无效的。等待这种情况的唯一方式是循环什么都不做,直到任务完成,但是这个循环阻止了事件循环调度任务,导致了矛盾。

def get_number_from_HTTP_request() -> int:
    task = asyncio.create_task(async_get_number_from_HTTP_request())
    while not task.done():
        pass
    return task.result()

main()任务不断地在task.done()检查中循环,从不命中await语句,因此永远不会让位于async_get_number_from_HTTP_request()任务。该函数会导致死锁。

Tip

也可以用任何不包含显式await语句或隐式语句(如async forasync with)的长期运行循环来创建阻塞异步代码。

您不需要像我们在这里所做的那样,编写一个循环来检查另一个协程的数据。你应该await协程而不是循环。如果您确实需要一个内部没有等待的循环,您可以通过等待一个什么也不做的函数(如await asyncio.sleep(0))来明确地给事件循环一个切换到其他任务的机会,只要您是在一个协程中循环,而不是在一个协程调用的同步函数中循环。

我们不能将整个调用堆栈转换成异步习惯用法,所以解决这个问题的唯一方法是启动第二个事件循环,允许两个任务并行运行。我们已经阻塞了当前的事件循环,但是我们可以启动第二个事件循环来执行异步 HTTP 代码。

这种方法使得从同步上下文中调用异步代码成为可能,但是在主事件循环中调度的所有任务仍然被阻塞,等待 HTTP 响应。这只解决了混合同步和异步代码时的死锁问题;性能损失仍然存在。您应该尽可能避免混合同步和异步代码。由此产生的代码难以理解,可能会引入死锁,并抵消 asyncio 的性能优势。

清单 9-19 给出了一个助手函数,它采用一个协程并在一个新线程中执行它,而不涉及当前运行的事件循环。这还包括一个协程,它利用这个包装器传递 HTTP 协程,就像它是一个同步函数一样。

def wrap_coroutine(f):
    @functools.wraps(f)
    def run_in_thread(*args, **kwargs):
        loop = asyncio.new_event_loop()
        wrapped = f(*args, **kwargs)
        with ThreadPoolExecutor(max_workers=1) as pool:
            task = pool.submit(loop.run_until_complete, wrapped)
        return task.result()
    return run_in_thread

async def main() -> None:
    print(
        add_number_from_callback(
            constant, wrap_coroutine(async_get_number_from_HTTP_request)
        )
    )

Listing 9-19Wrapper function to start a second event loop and delegate new async tasks there

我们可以使用同样的方法来允许我们的plot_sensor(...)协程在interactive(...)函数调用中使用,如清单 9-20 所示。

import asyncio
from uuid import UUID

import ipywidgets as widgets
from matplotlib import pyplot as plt

from apd.aggregation.query import with_database
from apd.aggregation.analysis import (get_known_configs, plot_sensor, wrap_coroutine)

@wrap_coroutine
async def plot(*args, **kwargs):
    location_names = {
     UUID('53998a51-60de-48ae-b71a-5c37cd1455f2'): "Loft",
     UUID('1bc63cda-e223-48bc-93c2-c1f651779d69'): "Living Room",
     UUID('ea0683de-6772-4678-bfe7-6014f54ffc8e'): "Office",
     UUID('5aaa901a-7564-41fb-8eba-50cdd6fe9f80'): "Outside",
    }

    with with_database("postgresql+psycopg2://apd@localhost/apd") as session:
        coros = []
        figure = plt.figure(figsize = (20, 10), dpi=300)
        configs = get_known_configs().values()
        for i, config in enumerate(configs, start=1):
            plot = figure.add_subplot(2, 2, i)
            coros.append(plot_sensor(config, plot, location_names, *args, **kwargs))
        await asyncio.gather(*coros)
    return figure

start = widgets.DatePicker(
    description='Start date',
)
end = widgets.DatePicker(
    description='End date',
)
out = widgets.interactive(plot, collected_after=start, collected_before=end)

display(out)

Listing 9-20Interactive chart filtering example, with output shown

img/481001_1_En_9_Fige_HTML.jpg

整理

我们现在在 Jupyter 细胞中有许多复杂的逻辑。我们应该把它移到一些更通用的实用函数中,这样终端用户就不需要处理如何绘制图表的细节。我们不希望用户必须处理将协程转换成包装函数以传递给交互系统的细节,因此我们可以提供一个助手函数供他们使用,如清单 9-21 所示。

async def plot_multiple_charts(*args: t.Any, **kwargs: t.Any) -> Figure:
    # These parameters are pulled from kwargs to avoid confusing function
    # introspection code in IPython widgets
    location_names = kwargs.pop("location_names", None)
    configs = kwargs.pop("configs", None)
    dimensions = kwargs.pop("dimensions", None)
    db_uri = kwargs.pop("db_uri", "postgresql+psycopg2://apd@localhost/apd")

    with with_database(db_uri):
        coros = []
        if configs is None:
            # If no configs are supplied, use all known configs
            configs = get_known_configs().values()
        if dimensions is None:
            # If no dimensions are supplied, get the square root of the # number
            # of configs and round it to find a number of columns. This will
            # keep the arrangement approximately square. Find rows by
            # multiplying out rows.
            total_configs = len(configs)
            columns = round(math.sqrt(total_configs))
            rows = math.ceil(total_configs / columns)
        figure = plt.figure(figsize=(10 * columns, 5 * rows), dpi=300)
        for i, config in enumerate(configs, start=1):
            plot = figure.add_subplot(columns, rows, i)
            coros.append(plot_sensor(config, plot, location_names, *args, **kwargs))
        await asyncio.gather(*coros)
    return figure

def interactable_plot_multiple_charts(
    *args: t.Any, **kwargs: t.Any
) -> t.Callable[..., Figure]:
    with_config = functools.partial(plot_multiple_charts, *args, **kwargs)
    return wrap_coroutine(with_config)

Listing 9-21Genericized versions of the plot functions

这给我们留下了 Jupyter 代码,它实例化小部件和位置名称,然后调用interactable_plot_multiple_charts(...)来生成传递给interactive(...)函数的函数。由此产生的 Jupyter 单元相当于前面的实现,但要短得多,如下所示:

import ipywidgets as widgets
from apd.aggregation.analysis import interactable_plot_multiple_charts

plot = interactable_plot_multiple_charts(location_names=location_names)
out = widgets.interact(plot, collected_after=start, collected_before=end)

display(out)

持久端点

我们可以做的下一个逻辑清理是将端点的配置移动到一个新的数据库表中。这将允许我们自动生成location_names变量,确保每个图表上使用的颜色在调用中保持一致,还允许我们更新所有传感器端点,而不必每次都传递它们的 URL。

为此,我们将创建一个新的数据库表和数据类来表示 apd.sensors 的部署。我们还需要命令行实用程序来添加和编辑部署元数据、实用程序函数来获取数据,并对所有这些进行测试。

Exercise 9-2: Implement Stored Deployments

在数据库中存储部署所涉及的更改需要创建新的表、新的控制台脚本、迁移和一些测试工作。

根据您认为有用的内容,实现以下任意或所有功能:

  • 包含 id、名称、URI 和 API 键的部署对象和表

  • 用于添加、编辑和列出部署的命令行脚本

  • 命令行脚本的测试

  • 使 collect_sensor_data 的服务器和 api_key 参数可选,如果省略,则使用存储的值

  • 通过 ID 获取部署记录的助手函数

  • 部署表中用于绘制数据的颜色的附加字段

  • 修改绘图函数以直接使用其数据库记录中的展开名称和线条颜色

所有这些都包含在本章附带的同一个实现中。

绘制地图和地理数据

在这一章中,我们一直关注 xy 值与时间的关系图,因为它代表了我们一直在检索的测试数据。有时我们需要根据其他坐标轴绘制数据。其中最常见的是纬度对经度,因此该图类似于一张地图。

如果我们从数据集中提取纬度和经度项(比方说,一个将坐标映射到英国各地温度记录的字典),我们可以将这些作为参数传递给plot(...)来查看它们的可视化,如清单 9-22 所示。

import matplotlib.pyplot as plt
fig, ax = plt.subplots()
lats = [ll[0] for ll in datapoints.keys()]
lons = [ll[1] for ll in datapoints.keys()]
ax.plot(lons, lats, "o")
plt.show()

Listing 9-22Plotting lat/lons using matplotlib, and the resulting chart

img/481001_1_En_9_Figf_HTML.jpg

数据的形状只是非常近似地像大不列颠的轮廓,如图 9-5 所示。大多数人看到这个情节时都不会这样认为。

img/481001_1_En_9_Fig5_HTML.jpg

图 9-5

包括英格兰、威尔士和苏格兰的大不列颠岛的轮廓

失真是因为我们是根据等矩形地图投影绘制的,其中纬度和经度是等间距网格,没有考虑地球的形状。没有一个正确的地图投影;这在很大程度上取决于地图的预期用途。

我们需要这张地图让大多数人看起来很熟悉,不管他们生活在哪个国家,他们都会非常熟悉这个国家的轮廓。我们希望看到它的人看到的是数据,而不是不寻常的投影。最常用的投影是墨卡托投影,OpenStreetMap (OSM)项目为其提供了多种编程语言的实现,包括 Python。 10 实现投影的merc_x(...)merc_y(...)函数不会包含在清单中,因为它们是相当复杂的数学函数。

Tip

当绘制显示数百平方公里区域的地图时,使用投影功能变得越来越重要,但对于小比例地图,可以使用ax.set_aspect(...)功能提供更熟悉的视图。改变纵横比将失真最小的点从赤道移动到另一个纬度;它不能校正失真。例如,ax.set_aspect(1.7)会将失真最小的点移到纬度 54 度,因为1.7等于1 / cos(54)

有了投影功能,我们可以重新运行绘图功能,看到这些点与我们期望的轮廓更加匹配,如图 9-6 所示。在这种情况下,轴上的标签不再显示坐标;它们显示无意义的数字。我们现在应该忽略这些标签。

img/481001_1_En_9_Fig6_HTML.jpg

图 9-6

使用来自 OSM 的 merc_x 和 merc_y 投影的地图

新的地块类型

这只向我们显示了每个数据点的位置,而不是与之相关的值。到目前为止,我们使用的绘图函数都绘制两个值,x 和 y 坐标。虽然我们可以用温度来标记绘图点,或者用刻度来标记颜色,但最终的图表并不容易阅读。相反,matplotlib 中有一些其他的绘图类型可以帮助我们:特别是tricontourf(...)。tricontour 绘图函数系列采用(x, y, value)的三维输入,并在它们之间进行插值,以创建一个带有代表一系列值的颜色区域的绘图。

当 tricontour 函数绘制颜色区域时,我们也应该绘制进行测量的点,尽管不太突出(清单 9-23 )。这与在图表上绘制多个数据集的方式相同;我们可以根据需要多次调用各种绘图函数来显示所有数据;它们不必是相同类型的图,只要轴是兼容的。

fig, ax = plt.subplots()

lats = [ll[0] for ll in datapoints.keys()]
lons = [ll[1] for ll in datapoints.keys()]
temperatures = tuple(datapoints.values())

x = tuple(map(merc_x, lons))
y = tuple(map(merc_y, lats))

ax.tricontourf(x, y, temperatures)
ax.plot(x, y, 'wo', ms=3)
ax.set_aspect(1.0)
plt.show()

Listing 9-23Color contours and scatter on the same plot

img/481001_1_En_9_Figg_HTML.jpg

一旦我们知道我们在看什么,这是可以理解的,但我们可以通过在地图上标出大不列颠岛的海岸线来进一步改进它。给定代表英国海岸线的坐标列表, 11 我们可以最后一次调用 plot 函数,这次指定我们要画一条线而不是点。我们绘图的最终版本(图 9-7 )更容易阅读,特别是如果我们能够通过调用plt.colorbar(tcf)来绘制图例,其中tcfax.tricontourf(...)函数调用的结果。

img/481001_1_En_9_Fig7_HTML.jpg

图 9-7

典型冬日英国周围的气温图

Tip

Python 和 Matplotlib 提供了许多 GIS 库,使复杂的地图变得更加简单。如果你打算画很多地图,我建议你看看 Fiona 和 Shapely,它们可以轻松地操作点和多边形。我向所有使用 Python 处理地理信息的人强烈推荐这些库;他们确实非常强大。

matplotlib 的底图工具包提供了非常灵活的地图绘制工具,但是维护人员已经决定不像标准 Python 包那样分发它,所以我无法推荐它作为地图绘制的通用解决方案。

apd.aggregation 中支持地图类型图表

我们需要对我们的配置对象进行一些更改来支持这些地图,因为它们的行为与我们到目前为止制作的所有其他图不同。之前,我们已经迭代了部署,并为每个部署绘制了一个图,代表一个传感器。要绘制地图,我们需要结合两个值(坐标和温度)并绘制一个代表所有部署的图。我们的个人部署可能会四处移动,并提供一个坐标传感器来记录他们在给定时间的位置。单独的自定义清理函数不足以组合多个数据点的值。

数据类中的向后兼容性

我们的Config对象包含一个sensor_name参数,它过滤作为绘图过程一部分的get_data_by_deployment(...)函数调用的输出。我们需要覆盖系统的这一部分;我们不再希望向get_data_by_deployment(...)函数传递单个参数;我们希望能够用自定义过滤来替换整个呼叫。

sensor_name=参数已成为可选参数,类型已更改为InitVar。我们还添加了一个新的 get_data 参数,这是一个可选的可调用参数,形状与get_data_by_deployment(...)相同。InitVars 是数据类的另一个有用的特性,它允许指定参数,这些参数没有被存储,但是在一个名为__post_init__(...)的创建后钩子中是可用的。在我们的例子中,如清单 9-24 所示,我们可以定义这样一个钩子来基于sensor_name=设置新的get_data=变量,保持与只传递一个sensor_name=的实现的向后兼容性。

@dataclasses.dataclass
class Config:
    title: str
    clean: t.Callable[[t.AsyncIterator[DataPoint]], t.AsyncIterator[t.Tuple[datetime.datetime, float]]]
    get_data: t.Optional[
        t.Callable[..., t.AsyncIterator[t.Tuple[UUID, t.AsyncIterator[DataPoint]]]]
    ] = None
    ylabel: str
    sensor_name: dataclasses.InitVar[str] = None

    def __post_init__(self, sensor_name=None):
        if self.get_data is None:
            if sensor_name is None:
                raise ValueError("You must specify either get_data or sensor_name")
            self.get_data = get_one_sensor_by_deployment(sensor_name)

def get_one_sensor_by_deployment(sensor_name):
    return functools.partial(get_data_by_deployment, sensor_name=sensor_name)

Listing 9-24Data class with get_data parameter and backward compatibility hook

自动调用__post_init__(...)函数,向其传递任何InitVar属性。当我们在__post_init__方法中设置get_data时,我们需要确保数据类没有被冻结,因为这算作修改。

这一改变允许我们改变传递给clean(...)函数的数据,但是该函数仍然期望返回一个传递给plot_date(...)函数的时间和浮点元组。我们需要改变clean(...)函数的形状。

我们将不再仅仅使用plot_date(...)来表达我们的观点;某些类型的图表需要等高线和点,因此我们还必须添加另一个自定义点来选择数据的绘制方式。Config类的新draw属性提供了这个功能。

为了支持这些新的函数调用签名,我们需要使Config成为一个泛型类,如清单 9-25 所示。这使得指定配置对象的基础数据成为可能(或者让类型系统从上下文中推断出它)。现有的数据类型是类型Config[datetime.datetime, float],但是我们的地图Config将是Config[t.Tuple[float, float], float]。也就是说,一些配置根据日期绘制一个浮点数,另一些配置根据一对浮点数绘制一个浮点数。

plot_key = t.TypeVar("plot_key")
plot_value = t.TypeVar("plot_value")

@dataclasses.dataclass
class Config(t.Generic[plot_key, plot_value]):
    title: str
    clean: t.Callable[
        [t.AsyncIterator[DataPoint]], t.AsyncIterator[t.Tuple[plot_key, plot_value]]
    ]
    draw: t.Optional[
        t.Callable[
            [t.Any, t.Iterable[plot_key], t.Iterable[plot_value], t.Optional[str]], None
        ]
    ] = None
    get_data: t.Optional[
        t.Callable[..., t.AsyncIterator[t.Tuple[UUID, t.AsyncIterator[DataPoint]]]]
    ] = None
    ylabel: t.Optional[str] = None
    sensor_name: dataclasses.InitVar[str] = None

    def __post_init__(self, sensor_name=None):
        if self.draw is None:
            self.draw = draw_date
        if self.get_data is None:
            if sensor_name is None:
                raise ValueError("You must specify either get_data or sensor_name")
            self.get_data = get_one_sensor_by_deployment(sensor_name)

Listing 9-25A generic Config type

现在,Config类中有许多复杂的类型信息。不过,这确实有好处:下面的代码引发了一个键入错误:

Config(
    sensor_name="Temperature",
    clean=clean_temperature_fluctuations,
    title="Ambient temperature",
    ylabel="Degrees C",
    draw=draw_map,
)

当我们阅读代码时,它也给了我们信心;我们知道函数的参数和返回类型是匹配的。因为这段代码涉及到将数据结构转换成元组(等)迭代器的大量操作。很容易弄不清楚到底需要什么。这是输入提示的完美用例。

我们希望用户使用自定义绘制和清理方法来创建自定义配置对象。拥有可靠的打字信息可以让他们更快地发现细微的错误。

我们需要处理现有的两种绘图类型的config.get_data(...)config.draw(...)函数是我们已经在本章中深入研究过的代码的重构,但是对于那些对细节感兴趣的人来说,它们可以在本章附带的代码中查看。

使用新配置绘制自定义地图

Config的更改允许我们定义基于地图的配置,但我们当前的数据不包括任何可以绘制为地图的数据,因为我们的部署都不包括位置传感器。我们可以使用新的config.get_data(...)选项来生成一些静态数据,而不是真实的聚合数据来演示功能。我们还可以通过扩展draw_map(...)函数来添加自定义海岸线(清单 9-26 )。

def get_literal_data():
    # Get manually entered temperature data, as our particular deployment
    # does not contain data of this shape
    raw_data = {...}
    now = datetime.datetime.now()
    async def points():
        for (coord, temp) in raw_data.items():
            deployment_id = uuid.uuid4()
            yield DataPoint(sensor_name="Location", deployment_id=deployment_id,
            collected_at=now, data=coord)
            yield DataPoint(sensor_name="Temperature", deployment_id=deployment_id,
            collected_at=now, data=temp)
    async def deployments(*args, **kwargs):
        yield None, points()
    return deployments

def draw_map_with_gb(plot, x, y, colour):
    # Draw the map and add an explicit coastline
    gb_boundary = [...]
    draw_map(plot, x, y, colour)
    plot.plot(
        [merc_x(coord[0]) for coord in gb_boundary],
        [merc_y(coord[1]) for coord in gb_boundary],
        "k-",
    )

country = Config(
    get_data=get_literal_data(),
    clean=get_map_cleaner_for("Temperature"),
    title="Country wide temperature",
    ylabel="",
    draw=draw_map_with_gb,
)

out = widgets.interactive(interactable_plot_multiple_charts(configs=configs + (country, )), collected_after=start, collected_before=end)

Listing 9-26Jupyter function to draw a custom map chart along with the registered charts

img/481001_1_En_9_Figh_HTML.jpg

Exercise 9-3: Add a Bar Chart For Cumulative Solar Power

我们为太阳能发电数据编写了一个清洁器,将它转换为瞬时功率,而不是累积功率。这使得随着时间的推移发电变得更加明显,但也使得理解每天的发电量变得更加困难。

编写一个新的 cleaner,返回每天的累积电量,并编写一个新的 draw 函数,以条形图的形式显示这些电量。

和往常一样,本章附带的代码包括一个起点和一个完整的示例版本。

摘要

在这一章中,我们回到了 Jupyter,它的目的是人们最熟悉的,而不是纯粹作为一个原型工具。我们在这里也使用了 Matplotlib,很多 Jupyter 的用户可能已经遇到过了。这两者共同构成了交流数据分析结果的强大工具。

我们已经编写了许多帮助函数,让人们可以轻松地在 Jupyter 中构建自定义界面来查看我们正在聚合的数据。这允许我们定义一个面向公众的 API,同时允许我们非常灵活地改变事情的实现方式。一个好的面向最终用户的 API 对于留住用户至关重要,所以值得花时间去做。

本章附带代码的最终版本包含了我们构建的所有函数,其中许多函数都包含很长的样本数据块。其中一些太长了,无法在出版物中包含,所以我建议您看看代码示例,并尝试一下。

最后,我们看了一些我们已经使用过的技术的更高级的用法,包括当缺省参数不够时使用数据类的__post_init__(...)钩子来保持向后兼容性,以及同步和异步代码的更复杂的组合。

额外资源

以下链接提供了本章所涵盖主题的其他背景信息:

*

十、加快速度

提高代码速度有两种主要方法:优化我们编写的代码和优化程序的控制流以运行更少的代码。人们通常专注于优化代码而不是控制流,因为更容易进行自包含的更改,但是最显著的好处通常是在更改流程时。

优化功能

优化一个函数的第一步是在做任何改变之前对它的性能有一个很好的理解。Python 标准库有一个 profile 模块来帮助实现这一点。Profile 会在代码运行时对代码进行内省,以了解每个函数调用所花费的时间。探查器可以检测对同一函数的多次调用,并监视任何间接调用的函数。然后,您可以生成一个报告,显示整个运行的函数调用图。

我们可以使用profile.run(...)函数来分析一条语句。这使用参考分析器,它总是可用的,但是大多数人在cProfile.run(...) 1 使用优化的分析器。分析器将对作为第一个参数传递的字符串进行exec,生成分析信息,然后自动将分析结果格式化成报告。

>>> from apd.aggregation.analysis import interactable_plot_multiple_charts
>>> import cProfile
>>> cProfile.run("interactable_plot_multiple_charts()()", sort="cumulative")
         164 function calls in 2.608 seconds

   Ordered by: cumulative time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1   0.001   0.001   2.608   2.608 {built-in method builtins.exec}
        1   0.001   0.001   2.606   2.606 <string>:1(<module>)
        1   0.004   0.004   2.597   2.597 analysis.py:327(run_in_thread)
        9   2.558   0.284   2.558   0.284 {method 'acquire' of '_thread.lock' objects}
        1   0.000   0.000   2.531   2.531 _base.py:635(__exit__)
...

这里显示的表格显示了一个函数被调用的次数( ncalls )、执行该函数所花费的时间( tottime )以及总时间除以调用次数( percall )。它还显示了执行该函数和所有被间接调用的函数所花费的累计时间,包括总计时间和除以调用次数的时间(累计时间和第二个 percall )。具有高累计时间和低总时间的函数意味着函数本身不能从优化中受益,但是涉及该函数的控制流可以。

Tip

一些 ide 和代码编辑器内置了对运行分析器和查看其输出的支持。如果您使用的是 IDE,那么这可能是一个更自然的界面。然而,分析器的行为仍然是相同的。

在 Jupyter 笔记本中运行代码时,您也可以使用“单元格魔术”功能生成相同的报告(图 10-1 )。单元魔术是在单元上的注释,用于在执行期间使用命名的插件,在本例中是一个分析器。如果您创建了单元格的第一行%%prun -s cumulative,那么一旦单元格执行完毕,笔记本就会显示一个包含整个单元格的概要报告的弹出窗口。

Caution

“cell magic”方法目前与 IPython 中的顶级 await 支持不兼容。如果你使用%%prun 细胞魔法,那么该细胞不能等待协程。

img/481001_1_En_10_Fig1_HTML.jpg

图 10-1

分析 jupiter 笔记本电脑单元的示例

分析和线程

前面的例子生成的报告列出了许多线程内部函数,而不是我们的实质性函数。这是因为我们的interactable_plot_multiple_charts(...)(...)函数 2 启动了一个新线程来处理底层协程的运行。分析器不会进入已启动的线程来启动分析器,所以我们只能看到主线程在等待工作线程完成。

我们可以通过改变代码将协程封装到线程中的方式来解决这个问题,让我们有机会在子线程中插入一个分析器。例如,我们可以添加一个debug=标志,然后如果debug=True被传递,就向线程池提交一个不同的函数,如清单 10-1 所示。

_Coroutine_Result = t.TypeVar("_Coroutine_Result")

def wrap_coroutine(
    f: t.Callable[..., t.Coroutine[t.Any, t.Any, _Coroutine_Result]], debug: bool=False,
) -> t.Callable[..., _Coroutine_Result]:
    """Given a coroutine, return a function that runs that coroutine
    in a new event loop in an isolated thread"""

    @functools.wraps(f)
    def run_in_thread(*args: t.Any, **kwargs: t.Any) -> _Coroutine_Result:
        loop = asyncio.new_event_loop()
        wrapped = f(*args, **kwargs)

        if debug:
            # Create a new function that runs the loop inside a cProfile
            # session, so it can be profiled transparently

            def fn():
                import cProfile

                return cProfile.runctx(
                    "loop.run_until_complete(wrapped)",
                    {},
                    {"loop": loop, "wrapped": wrapped},
                    sort="cumulative",
                )

            task_callable = fn
        else:
            # If not debugging just submit the loop run function with the
            # desired coroutine
            task_callable = functools.partial(loop.run_until_complete, wrapped)
        with ThreadPoolExecutor(max_workers=1) as pool:
            task = pool.submit(task_callable)
        # Mypy can get confused when nesting generic functions, like we do # here
        # The fact that Task is generic means we lose the association with
        # _CoroutineResult. Adding an explicit cast restores this.
        return t.cast(_Coroutine_Result, task.result())

    return run_in_thread

def interactable_plot_multiple_charts(
    *args: t.Any, debug: bool=False, **kwargs: t.Any
) -> t.Callable[..., Figure]:
    with_config = functools.partial(plot_multiple_charts, *args, **kwargs)
    return wrap_coroutine(with_config, debug=debug)

Listing 10-1Example of wrap_coroutine to optionally include profiling

在清单 10-1 中,我们使用分析器中的runctx(...)函数,而不是run(...)函数。runctx(...)允许将全局和局部变量传递给我们正在分析的表达式。 3 解释器不会自省代表要运行的代码的字符串来确定需要什么变量。您必须显式地传递它们。

有了这一改变,我们用来绘制所有带有交互元素的图表的代码也可以请求收集分析信息,因此 Jupyter 笔记本中的用户可以轻松地调试他们正在添加的新图表类型,如图 10-2 所示。

img/481001_1_En_10_Fig2_HTML.jpg

图 10-2

从 jupiter 使用集成分析选项

运行在子线程中的分析器在顶部仍然包括一些开销函数,但是我们现在可以看到我们想要分析的函数,而不仅仅是线程管理函数。如果我们只看与我们的代码相关的函数,输出如下:

ncalls   tottime  percall  cumtime  percall  filename:lineno(function)
    20   0.011    0.001    2.607    0.130    analysis.py:282(plot_sensor)
    12   0.028    0.002    2.108    0.176    analysis.py:304(<listcomp>)
  3491   0.061    0.000    1.697    0.000    analysis.py:146(clean_watthours_to_watts)
 33607   0.078    0.000    0.351    0.000 query.py:114(subiterator)
    12   0.000    0.000    0.300    0.025 analysis.py:60(draw_date)
 33603   0.033    0.000    0.255    0.000 query.py:39(get_data)
     3   0.001    0.000    0.254    0.085 analysis.py:361(plot_multiple_charts)
 16772   0.023    0.000    0.214    0.000 analysis.py:223(clean_passthrough)
 33595   0.089    0.000    0.207    0.000 database.py:77(from_sql_result)
  8459   0.039    0.000    0.170    0.000 analysis.py:175(clean_temperature_fluctuations)
    24   0.000    0.000    0.140    0.006 query.py:74(get_deployment_by_id)
     2   0.000    0.000    0.080    0.040 query.py:24(with_database)

看起来plot_sensor(...)函数被调用了 20 次,列表理解points = [dp async for dp in config.clean(query_results)]被调用了 12 次,clean_watthours_to_watts(...)函数被调用了 3491 次。大量报告的对 clean 函数的调用是由于分析器与生成器函数的交互方式。每次从生成器请求一个新项时,它都被归类为该函数的一个新调用。同样,每次一个项目被放弃,它被归类为调用返回。这种方法看起来比测量从第一次调用到生成器耗尽的时间更复杂,但是这意味着 tottime 和 cumtime 总计不包括迭代器空闲并等待其他代码请求下一项的时间。但是,这也意味着 percall 数字表示检索单个项目所花费的时间,而不是每次调用该函数所花费的时间。

Caution

分析器需要一个函数来确定当前时间。默认情况下,profile 使用time.process_time(),cProfile 使用time.perf_counter()。这些测量非常不同的东西。process_time()函数测量 CPU 忙碌的时间,但是perf_counter()测量真实世界的时间。现实世界的时间通常被称为“墙上时间”,意思是由墙上的时钟测量的时间。

解释配置文件报告

clean_watthours_to_watts(...)函数应该立即吸引你的注意力,因为它是一个相对低级的函数,具有非常高的累计时间。这个函数被用作绘制四个图表之一的支持函数,但是它占了plot_sensor(...)总执行时间的 65%。这个函数是我们开始优化的地方,但是如果我们比较 tottime 和 cumtime,我们可以看到它只花了这个函数总时间的 2%。

这种差异告诉我们,并不是我们在这个函数中直接编写的代码导致了速度下降,而是我们在实现clean_watthours_to_watts(...)的过程中间接调用了其他函数。现在,我们着眼于优化功能,而不是优化执行流程。因为优化这个函数需要优化调用我们无法控制的函数的模式,所以我们暂时忽略它。本章的后半部分将介绍通过改变控制流来提高性能的策略,我们将在那里返回来修正这个函数。

相反,让我们把注意力集中在那些 tottime 比 cumtime 高的项目上,这表示花费的时间是在执行我们编写的代码上,而不是在执行我们正在使用的代码上。这些数字明显低于我们之前观察的次数;它们是相对简单的功能,代表着较小的潜在利益,但情况可能并不总是如此。

       12   0.103   0.009   2.448   0.204 analysis.py:304(<listcomp>)
    33595   0.082   0.000   0.273   0.000 database.py:77(from_sql_result)
    33607   0.067   0.000   0.404   0.000 query.py:114(subiterator)

我们看到与数据库接口相关的两个函数是潜在的候选函数。这些都运行了 33,000 次以上,每次运行的总时间不到十分之一秒,因此它们不是特别诱人的优化目标。尽管如此,就我们代码的总时间而言,它们是最高的,因此它们代表了我们必须进行简单、自包含类型的优化的最佳机会。

要做的第一件事是尝试改变一些关于实现的东西,并测量任何差异。现有的实现非常短,只包含一行代码。我们根本不可能优化,但让我们试试。

@classmethod
def from_sql_result(cls, result) -> DataPoint:
    return cls(**result._asdict())

在前面的实现中,有一点可能会导致速度变慢,那就是生成了一个值字典,并动态地映射到关键字参数。 4 一个要测试的想法将是显式地传递论点,因为我们知道它们是一致的。

@classmethod
def from_sql_result(cls, result) -> DataPoint:
    if result.id is None:
        return cls(data=result.data, deployment_id=result.deployment_id,
                    sensor_name=result.sensor_name, collected_at=result.collected_at)
    else:
        return cls(id=result.id, data=result.data, deployment_id=result.deployment_id, sensor_name=result.sensor_name,
        collected_at=result.collected_at)

这个过程中最重要的部分是检验我们的假设。我们需要重新运行代码并比较结果。我们还需要意识到这样一个事实,即代码的执行时间可能会因外部因素(如计算机上的负载)而有所不同,因此尝试运行几次代码以查看结果是否稳定是一个好主意。我们在这里寻求显著的加速,因为我们的改变会引入可维护性问题,所以微不足道的速度提升是不值得的。

    33595   0.109   0.000   0.147   0.000 database.py:77(from_sql_result)

这里的结果显示,与之前的实现相比,在from_sql_result()函数上花费了更多的时间,但是累积时间减少了。这个结果告诉我们,我们对from_sql_result()所做的更改直接导致该函数花费更长的时间,但是这样做的时候,我们更改了控制流以消除对_asdict()的调用,并直接传递值,这大大弥补了我们引入的减速。

换句话说,除了通过改变控制流来避免_asdict()中的代码之外,这个函数的实现对性能没有明显的改善。它还要求我们列出在多个地方使用的字段,从而降低了代码的可维护性。因此,我们将坚持我们的原始实现,而不是“优化”版本。

Tip

对于类的创建还有另一个潜在的优化,在类上设置一个 slots 属性,比如__slots__ = {"sensor_name", "data", "deployment_id", "id", "collected_at"}。这使得开发人员可以保证在一个实例上只设置专门命名的属性,这使得解释器可以添加许多优化。在撰写本文时,数据类和__slots__之间存在一些不兼容,这使得它不太容易使用,但是如果您想优化对象的实例化,那么我建议您看一看。

其他两个也是如此:subiterator()和列表理解功能非常少;对它们的更改会降低可读性,并且不会带来实质性的性能改进。

一个小的、容易理解的函数成为显著性能改进的候选者的情况相对较少,因为糟糕的性能通常与复杂性相关。如果系统的复杂性是由简单函数的组合造成的,那么性能的提高来自于控制流的优化。如果你有很长的函数来做复杂的事情,那么更有可能的是通过孤立地优化函数来获得显著的改进。

其他分析器

Python 自带的分析器足以在大多数情况下获得有用的信息。尽管如此,由于代码性能是一个如此重要的主题,还有其他可用的分析器,它们都有独特的优点和缺点。

时间到了

要提到的最重要的可选分析器也来自 Python 标准库,名为 timeit。时间它对于分析快速、独立的函数很有用。它不是在正常运行中监控程序,而是重复运行给定的代码,并返回所用的累计时间。

>>> import timeit
>>> from apd.aggregation.utils import merc_y
>>> timeit.timeit("merc_y(52.2)", globals={"merc_y": merc_y})
1.8951617999996415

当使用默认参数调用时,如前所示,输出是执行第一个参数一百万次所需的秒数,使用最精确的时钟测量。只有第一个参数(stmt=)是必需的,它是每次要执行的代码的字符串表示。第二个字符串参数(setup=)表示测试开始前必须执行的设置代码,一个globals=字典允许将任意项传递到被分析代码的名称空间中。这对于在测试中传递函数特别有用,而不是在setup=代码中导入它。可选的number=参数允许我们指定代码应该运行多少次,因为一百万次执行对于执行时间超过 50 微秒的函数来说是不合适的。 5

表示要测试的代码的字符串和setup=字符串都可以是包含一系列 Python 语句的多行字符串。但是,请注意,第一个字符串中的任何定义或导入每次都要运行,所以所有设置代码都应该在第二个字符串中完成,或者直接作为全局变量传递。

线条轮廓图

一个普遍推荐的替代分析器是 Robert Kern 的 line_profiler。 6 它逐行记录信息,而不是逐函数记录,这对于精确定位函数性能问题的来源非常有用。

不幸的是,line_profiler 的权衡相当重要。它需要修改您的 Python 程序来注释您希望分析的每个函数,并且当这些注释就位时,代码不能运行,除非通过 line _ profilers 自定义环境。此外,在撰写本文时,大约有两年的时间无法安装带有 pip 的 line_profiler。虽然你会发现很多人在网上推荐这个分析器,但部分原因是它比其他工具更早上市。我建议避免使用这个分析器,除非对调试复杂的函数绝对必要;你可能会发现安装花费的时间比安装后节省的时间还多。

雅皮

另一个可选的分析器是 yappi, 7 ,它提供了跨多线程和异步事件循环运行的 Python 代码的透明分析。迭代器的调用计数之类的数字表示迭代器被调用的次数,而不是检索的项数,并且不需要修改代码来支持多线程分析。

yappi 的缺点是,它是一个相对较小的项目,正在大量开发中,因此您可能会发现它不如许多其他 Python 库那么完美。对于内置分析器不足的情况,我会推荐 yappi。在撰写本文时,我仍然推荐内置的分析工具作为我的首选,但是 yappi 紧随其后。

yappi 的接口与我们到目前为止使用的内置分析器有些不同,因为它没有提供与run(...)函数调用等价的接口。yappi 分析器必须在被分析的代码周围启用和禁用。默认分析器有一个等效的 API,如表 10-1 所示。

表 10-1

profile 和 yappi 分析的比较

| *使用启用/禁用 API 的 c profile*`import cProfile``profiler = cProfile.Profile()``profiler.enable()``method_to_profile()``profiler.disable()``profiler.print_stats()` | *基于 Yappi 的剖析*`import yappi``yappi.start()``method_to_profile()``yappi.stop()``yappi.get_func_stats().print_all()` |

在 Jupyter 单元中使用 yappi 使我们能够调用底层代码中的函数,而不需要解决线程和异步问题。我们本可以使用 yappi 来分析我们的代码,而不需要提前修改debug=参数。在前面的例子中,如果method_to_profile()调用interactable_plot_multiple_charts(...)widgets.interactive(...),产生的概要文件输出如下:

Clock type: CPU
Ordered by: totaltime, desc

name                                  ncall  tsub      ttot      tavg
..futures\thread.py:52 _WorkItem.run  17     0.000000  9.765625  0.574449
..rrent\futures\thread.py:66 _worker  5/1    0.000000  6.734375  1.346875
..38\Lib\threading.py:859 Thread.run  5/1    0.000000  6.734375  1.346875
..ndowsSelectorEventLoop.run_forever  1      0.000000  6.734375  6.734375
..b\asyncio\events.py:79 Handle._run  101    0.000000  6.734375  0.066677
..lectorEventLoop.run_until_complete  1      0.000000  6.734375  6.734375
..WindowsSelectorEventLoop._run_once  56     0.000000  6.734375  0.120257
..gation\analysis.py:282 plot_sensor  4      0.093750  6.500000  1.625000
..egation\analysis.py:304 <listcomp>  12     0.031250  5.515625  0.459635
...

在这个例子中,yappi 显示的总时间明显高于 cProfile 显示的总时间。您应该只比较性能分析器产生的时间和使用相同工具在相同硬件上产生的结果,因为当启用性能分析器时,性能会有很大差异 8

Yappi Helper Functions

Yappi 支持按函数和模块过滤统计数据。还有一个提供定制过滤功能的选项,以准确地确定应该在性能报告中显示哪些代码。还有一些其他的选择;您应该查看 yappi 的文档,找到推荐的方法来过滤输出,只显示您感兴趣的代码。

本章附带的代码有一些帮助器函数,可以让 yappi 在 Jupyter 上下文中更容易进行分析。这些是profile_with_yappi,一个上下文管理器,处理激活和停用分析器;jupyter_page_file,一个上下文管理器,帮助以与%%prun单元魔术相同的方式显示剖析数据,不与单元输出合并;和yappi_package_matches,一个助手,它使用filter_callback=选项来限制显示的统计信息,只显示给定 Python 包中的模块。清单 10-2 显示了使用这些辅助函数的一个例子。

img/481001_1_En_10_Figa_HTML.jpg

from apd.aggregation.analysis import (interactable_plot_multiple_charts, configs)
from apd.aggregation.utils import (jupyter_page_file, profile_with_yappi, yappi_package_matches)
import yappi

with profile_with_yappi():
    plot = interactable_plot_multiple_charts()
    plot()

with jupyter_page_file() as output:
    yappi.get_func_stats(filter_callback=lambda stat:
        yappi_package_matches(stat, ["apd.aggregation"])
    ).print_all(output)

Listing 10-2.Jupyter cell for yappi profiling, with part of the Jupyter output shown

这三个助手都不是必需的,但是它们提供了一个更加用户友好的界面。

Tracemalloc

到目前为止,我们看到的分析器都是测量运行一段代码所需的 CPU 资源。我们可用的另一个主要资源是内存。一个运行速度很快但需要大量内存的程序在可用内存较少的系统上运行速度会慢得多。

Python 有一个内置的 RAM 分配分析器,称为 tracemalloc。该模块提供tracemalloc.start()tracemalloc.stop()功能,分别启用和禁用 profiler。通过使用tracemalloc.take_snapshot()功能,可以随时请求轮廓结果。清单 10-3 给出了一个在我们的绘图代码中使用它的例子。

这样做的结果是一个Snapshot对象,它有一个statistics(...)方法来返回单个统计数据的列表。该函数的第一个参数是对结果进行分组的关键字。最有用的两个键是"lineno"(用于逐行分析)和"filename"(用于整个文件分析)。一个cumulative=标志允许用户选择是否包含间接调用函数的内存使用。也就是说,每个统计行应该直接表示一行做了什么,还是表示运行该行的所有结果?

import tracemalloc

from apd.aggregation.analysis import interactable_plot_multiple_charts

tracemalloc.start()
plot = interactable_plot_multiple_charts()()
snapshot = tracemalloc.take_snapshot()
tracemalloc.stop()
for line in snapshot.statistics("lineno", cumulative=True):
    print(line)

Listing 10-3Example script to debug memory usage after plotting the charts

标准库中的文档提供了一些帮助函数,以提供更好的输出数据格式,尤其是display_top(...)函数的代码示例。 9

Caution

tracemalloc 分配器只显示在生成快照时仍处于活动状态的内存分配。对我们的程序进行分析表明,SQL 解析使用了大量的 ram,但是不会显示我们的DataPoint对象,尽管它们占用了更多的 RAM。与 SQL 对象不同,我们的对象是短命的,所以在我们生成快照时它们已经被丢弃了。调试内存使用峰值时,必须在峰值时创建快照。

新遗迹

如果你正在运行一个基于网络的应用,那么商业服务 New Relic 可能会提供有用的剖析见解。它提供了一个紧密集成的分析系统,允许您监控来自 web 请求的控制流、服务这些请求所涉及的功能,以及作为呈现过程的一部分与数据库和第三方服务的交互。

New Relic 和它的竞争对手之间的权衡是巨大的。您可以访问一组优秀的概要分析数据,但是它并不适合所有的应用类型,并且要花费大量的金钱。此外,使用真实用户的动作来执行概要分析这一事实意味着,在向您的系统引入新的遗留系统之前,您应该考虑用户隐私。也就是说,新的遗迹剖析提供了一些我见过的最有用的性能分析。

优化控制流程

更常见的情况是,在 Python 系统中,不是单个函数导致性能问题。正如我们前面看到的,以一种天真的方式编写代码通常会导致一个函数除了改变它正在做的事情之外不能被优化。

以我的经验来看,低性能最常见的原因是函数的计算量超过了它所需要的。例如,在我们第一次实现获取整理数据的功能时,我们还没有数据库端过滤,所以我们添加了一个循环来从不相关的数据中过滤我们想要的数据。

稍后过滤输入数据不只是移动工作区;它可以增加正在完成的总工作量。在这种情况下,完成的工作是从数据库加载数据,建立数据点记录,并从这些记录中提取相关数据。通过将过滤从加载步骤转移到提取步骤,我们为那些我们不关心的对象建立了数据点记录。

Complexity

函数所花费的时间并不总是与输入的大小成正比,但是对于在数据上循环一次的函数来说,这是一个很好的近似值。排序和其他更复杂的操作表现不同。

函数需要多长时间(或需要多少内存)和它们的输入大小之间的关系称为计算复杂度。大多数程序员从来不需要担心函数的确切复杂性类别,但是在优化代码时,有必要了解它们的大致区别。

您可以使用具有不同输入的 timeit 函数来估计输入大小和时间之间的关系,但是根据经验,最好避免在循环中嵌套循环。迭代次数很少的嵌套循环是可以的,但是在用户输入上的另一个循环中循环用户输入会导致函数花费的时间随着用户输入量的增加而迅速增加。

对于给定的输入大小,函数花费的时间越长,就越需要最小化它处理的无关数据量。

在图 10-3 中,横轴表示花费的时间,纵轴表示流水线中一个阶段必须处理的输入量。一个步骤的宽度,也就是它处理的时间,与它处理的数据量成正比。

这两个流说明了处理单个传感器所需的工作量,上面的流具有数据库级别的过滤,下面的流具有 Python 中的过滤。在这两种情况下,输出总量是相同的,但是中间阶段要处理的数据量不同,因此花费的时间也不同。

img/481001_1_En_10_Fig3_HTML.jpg

图 10-3

数据库中过滤的代码数据集大小与清理期间过滤的关系图

我们在两个地方丢弃数据:当我们只找到有问题的传感器的数据时,以及当丢弃无效数据时。通过将传感器过滤器移到数据库中,我们减少了加载步骤中完成的工作量,从而减少了所需的时间。我们正在转移大部分过滤,用于移除无效数据的更复杂的过滤仍在清理步骤中进行。如果我们能够将这种过滤转移到数据库中,将会进一步减少加载步骤所花费的时间,尽管不是那么多。

我们已经假设我们在编写函数时需要过滤数据库,部分是为了提高 API 的可用性,但是我们可以通过使用 yappi profiler 和为我们的绘图系统提供显式配置的能力来测试这一假设。然后,我们可以直接比较使用数据库支持的过滤和 Python 过滤绘制图表所需的时间。数据库中过滤性能分析的实现如清单 10-4 所示。

import yappi

from apd.aggregation.analysis import (interactable_plot_multiple_charts, Config)
from apd.aggregation.analysis import (clean_temperature_fluctuations, get_one_sensor_by_deployment)
from apd.aggregation.utils import profile_with_yappi

yappi.set_clock_type("wall")

filter_in_db = Config(
    clean=clean_temperature_fluctuations,
    title="Ambient temperature",
    ylabel="Degrees C",
    get_data=get_one_sensor_by_deployment("Temperature"),
)

with profile_with_yappi():
    plot = interactable_plot_multiple_charts(configs=[filter_in_db])
    plot()

yappi.get_func_stats().print_all()

Listing 10-4Jupyter cell to profile a single chart, filtering in SQL

下面的统计数据是 cells 输出的一部分,显示了我们最感兴趣的一些条目。我们可以看到加载了 10828 个数据对象,get_data(...)函数花费了 2.7 秒,6 次数据库调用总共花费了 2.4 秒。analysis.py ( points = [dp async for dp in config.clean(query_results)])的第 304 行的 list comprehension 是调用 cleaner 函数的地方。清理数据需要 0.287 秒,但是清理函数本身的时间可以忽略不计。

name                                  ncall  tsub      ttot      tavg
..lectorEventLoop.run_until_complete  1      0.000240  3.001717  3.001717
..alysis.py:341 plot_multiple_charts  1      2.843012  2.999702  2.999702
..gation\analysis.py:282 plot_sensor  1      0.000000  2.720996  2.720996
..query.py:86 get_data_by_deployment  1      2.706142  2.706195  2.706195
..d\aggregation\query.py:39 get_data  1      2.569511  2.663460  2.663460
..lchemy\orm\query.py:3197 Query.all  6      0.008771  2.407840  0.401307
..lchemy\orm\loading.py:35 instances  10828  0.005485  1.588923  0.000147
..egation\analysis.py:304 <listcomp>  4      0.000044  0.286975  0.071744
..175 clean_temperature_fluctuations  4      0.000000  0.286888  0.071722

我们可以重新运行相同的测试,但是使用相同图表的新版本,其中所有的过滤都在 Python 中进行。清单 10-5 演示了这一点,通过添加一个新的更干净的函数来进行过滤,并使用现有的get_data_by_deployment(...)函数作为数据源。这代表了如果我们没有给get_data(...)添加一个sensor_name=参数,我们需要如何过滤数据。

import yappi

from apd.aggregation.analysis import (interactable_plot_multiple_charts, Config, clean_temperature_fluctuations, get_data_by_deployment)
from apd.aggregation.utils import (jupyter_page_file, profile_with_yappi, YappiPackageFilter)

async def filter_and_clean_temperature_fluctuations(datapoints):
    filtered = (item async for item in datapoints if item.sensor_name=="Temperature")
    cleaned = clean_temperature_fluctuations(filtered)
    async for item in cleaned:
        yield item

filter_in_python = Config(
    clean=filter_and_clean_temperature_fluctuations,
    title="Ambient temperature",
    ylabel="Degrees C",
    get_data=get_data_by_deployment,
)

with profile_with_yappi():
    plot = interactable_plot_multiple_charts(configs=[filter_in_python])
    plot()

yappi.get_func_stats().print_all()

Listing 10-5Jupyter cell to profile drawing the same chart but without any database filtering

在这个版本中,过滤发生在filter_and_clean_temperature_fluctuations(...)中,所以我们预计这需要很长时间。所花费的额外时间部分存在于该函数的生成器表达式中,但不是全部。plot_multiple_charts(...)花费的总时间从 3.0 秒增加到了 8.0 秒,其中 1.3 秒是过滤。这表明,通过在数据库中进行过滤,我们节省了 3.7 秒的开销,这代表了 21%的加速。

name                                  ncall  tsub      ttot      tavg
..lectorEventLoop.run_until_complete  1      0.000269  7.967136  7.967136
..alysis.py:341 plot_multiple_charts  1      7.637066  7.964143  7.964143
..gation\analysis.py:282 plot_sensor  1      0.000000  6.977470  6.977470
..query.py:86 get_data_by_deployment  1      6.958155  6.958210  6.958210
..d\aggregation\query.py:39 get_data  1      6.285337  6.881415  6.881415
..lchemy\orm\query.py:3197 Query.all  6      0.137161  6.112309  1.018718
..lchemy\orm\loading.py:35 instances  67305  0.065920  3.424629  0.000051
..egation\analysis.py:304 <listcomp>  4      0.000488  1.335928  0.333982
..and_clean_temperature_fluctuations  4      0.000042  1.335361  0.333840
..175 clean_temperature_fluctuations  4      0.000000  1.335306  0.333826
..-input-4-927271627100>:7 <genexpr>  4      0.000029  1.335199  0.333800

可视化分析数据

复杂的迭代器函数很难分析,正如clean_temperature_fluctuations(...)将它的tsub时间列为零。这是一个调用其他方法的复杂函数,但是对于它来说,花费的时间正好为零,这一定是一个舍入误差。剖析运行代码可以为你指出正确的方向,但你只能从这种方法中得到指示性的数字。从这个角度也很难看出 0.287 秒的总时间是如何按组成函数分解的。

内置的 profile 模块和 yappi 都支持以 pstats 格式导出数据,这是一种特定于 Python 的 profile 格式,可以传递给可视化工具。Yappi 还支持 Valgrind 分析工具中的 callgrind 格式。

我们可以使用yappi.get_func_stats().save("callgrind.filter_in_db", "callgrind")保存来自 yappi 的 callgrind 配置文件,然后将其加载到类似 KCachegrind 的 callgrind 可视化工具中。 12 图 10-4 显示了在 QCachegrind 中显示该代码的数据库过滤版本的示例,其中块的面积对应于在相应函数中花费的时间。

img/481001_1_En_10_Fig4_HTML.jpg

图 10-4

过滤数据库中的数据时,调用 clean _ temperature _ contractions 图表

你可能会惊讶地发现get_data(...)不仅出现在这张图表中,而且是迄今为止最大的单个区块。clean_temperature_fluctuations(...)函数似乎没有调用get_data(...)函数,所以为什么这个函数会占用大部分时间还不是很明显。

迭代器使得对调用流的推理变得困难,因为当你在一个循环中从一个 iterable 中取出一个项目时,它看起来不像一个函数调用。在引擎盖下,Python 正在调用youriterable.__next__()(或youriterable.__anext__()),它将执行传递回底层函数,完成前面的 yield。因此,一个 for 循环可以调用任意数量的函数,即使它的主体是空的。async for的结构让这一点更清楚,因为它明确表示底层代码可能在等待。底层代码不可能等待,除非控制传递给其他代码,而不仅仅是与静态数据结构交互。当分析使用 iterable 的代码时,您会发现使用 iterable 的函数调用的底层数据生成函数出现在输出中。

Consuming Iterables and Single Dispatch Functions

我们可以尽快编写一个使用迭代器的函数,这在一定程度上简化了调用堆栈。使用迭代器会由于阻止任务并行运行而降低性能,并且需要足够的内存来容纳整个 iterable,但是它确实大大简化了分析工具的输出。清单 10-6 显示了在保留相同接口的同时使用可迭代和异步可迭代的简单函数。

def consume(input_iterator):
    items = [item for item in input_iterator]
    def inner_iterator():
        for item in items:
            yield item
    return inner_iterator()

async def consume_async(input_iterator):
    items = [item async for item in input_iterator]
    async def inner_iterator():
        for item in items:
            yield item
    return inner_iterator()

Listing 10-6Pair of functions for consuming iterators in place

这对函数接受一个迭代器(或异步迭代器),一被调用(或等待)就使用它,返回一个新的迭代器,这个迭代器只从预先使用的源产生。这些功能的用法如下:

# Synchronous
nums = (a for a in range(10))
consumed = consume(nums)

# Async
async def async_range(num):
    for a in range(num):
        yield a
nums = async_range(10)
consumed = await consume_async(nums)

我们可以使用标准库中的 functools 模块,特别是@singledispatch装饰器来简化这个过程。回到第二章,我们看了 Python 的动态分派功能,它允许函数被它所连接的类查找。我们正在做类似的事情。我们有一对与底层数据类型相关联的函数,但是这些数据类型不是我们编写的类。我们无法控制附加到它们上面的函数,因为这两种类型是核心语言的特性,而不是我们已经创建并可以编辑的类。

@singledispatch decorator 根据第一个参数的类型将函数标记为有多个不同的实现。使用这种方法重写我们的函数(清单 10-7 )只需要向它们添加装饰器,将替代实现加入到基本实现中,并添加一个类型提示来区分变量。

import functools

@functools.singledispatch
def consume(input_iterator):
    items = [item for item in input_iterator]
    def inner_iterator():
        for item in items:
            yield item
    return inner_iterator()

@consume.register
async def consume_async(input_iterator: collections.abc.AsyncIterator):
    items = [item async for item in input_iterator]
    async def inner_iterator():
        for item in items:
            yield item
    return inner_iterator()

Listing 10-7Pair of functions for consuming iterators in place with single dipatch

这两个函数的行为与前面的实现完全相同,除了consume(...)函数可以用于任一类型的迭代器。它基于其输入类型透明地在同步和异步实现之间切换。如果第一个参数是 AsyncIterator,那么使用consume_async(...)变量;否则使用consume(...)变体。

nums = (a for a in range(10))
consumed = consume(nums)
nums = async_range(10)
consumed = await consume (nums)

传递给 register 的函数必须有类型定义或传递给 register 函数本身的类型。我们在这里使用了collections.abc.AsyncIterator而不是typing.AsyncIterator作为类型,因为类型必须是运行时可检查的。这意味着@singledispatch仅限于在具体类或抽象基类上调度。

typing.AsyncIterator类型是一个泛型类型:我们可以用typing.AsyncIterator[int]来表示int的迭代器。这是 mypy 用于静态分析的,但不在运行时使用。如果不消耗整个迭代器并检查其内容,一个正在运行的 Python 程序不可能知道一个任意的异步迭代器是否是一个typing.AsyncIterator[int]迭代器。

collections.abc.AsyncIterator不保证迭代器的内容,所以它类似于typing.AsyncIterator[typing.Any],但是因为它是一个抽象基类,所以可以在运行时用isinstance(...)进行检查。

贮藏

另一种提高性能的方法是缓存函数调用的结果。缓存的函数调用记录了过去的调用及其结果,以避免多次计算相同的值。到目前为止,我们一直使用摄氏温度系统来绘制温度,但有几个国家保留了古老的华氏测量系统。如果我们能够指定我们希望使用哪个温度系统来显示我们的图表,那就太好了,这样用户就可以选择他们最熟悉的系统。

转换温标的工作与现有clean_temperature_fluctuations(...)方法完成的任务正交;例如,我们可能想在不消除波动的情况下转换温度。为了实现这一点,我们创建一个新的函数,它接受一个 cleaner 和一个温度系统,并返回一个新的 cleaner 来调用底层的那个,然后进行温度转换。

def convert_temperature(magnitude: float, origin_unit: str, target_unit: str) -> float:
    temp = ureg.Quantity(magnitude, origin_unit)
    return temp.to(target_unit).magnitude
def convert_temperature_system(cleaner, temperature_unit):
    async def converter(datapoints):
        results = cleaner(datapoints)
        async for date, temp_c in results:
            yield date, convert_temperature(temp_c, "degC", temperature_unit)

    return converter

前面的函数没有任何类型提示,因为它们非常冗长。cleaner 参数和来自convert_temperature_system(...)的返回值都是类型t.Callable[[t.AsyncIterator[DataPoint]], t.AsyncIterator[t.Tuple[datetime.datetime, float]]],这是一个非常复杂的结构,在一行代码中包含两次。这些类型在我们的分析函数中被重复使用,虽然很难一眼识别,但是它们映射到容易理解的概念。这些都是分解成变量的很好的候选者,其结果如清单 10-8 所示。

CLEANED_DT_FLOAT = t.AsyncIterator[t.Tuple[datetime.datetime, float]]
CLEANED_COORD_FLOAT = t.AsyncIterator[t.Tuple[t.Tuple[float, float], float]]

DT_FLOAT_CLEANER = t.Callable[[t.AsyncIterator[DataPoint]], CLEANED_DT_FLOAT]
COORD_FLOAT_CLEANER = t.Callable[[t.AsyncIterator[DataPoint]], CLEANED_COORD_FLOAT]

def convert_temperature(magnitude: float, origin_unit: str, target_unit: str) -> float:
    temp = ureg.Quantity(magnitude, origin_unit)
    return temp.to(target_unit).magnitude

def convert_temperature_system(

    cleaner: DT_FLOAT_CLEANER, temperature_unit: str,
) -> DT_FLOAT_CLEANER:
    async def converter(datapoints: t.AsyncIterator[DataPoint],) -> CLEANED_DT_FLOAT:
        results = cleaner(datapoints)
        reveal_type(temperature_unit)
        reveal_type(convert_temperature)
        async for date, temp_c in results:
            yield date, convert_temperature(temp_c, "degC", temperature_unit)

    return converter

Listing 10-8Typed conversion functions

Typing Protocols, Typevars and Variance

我们以前使用过t.TypeVar(...)来表示泛型类型中的占位符,比如在 config 类中定义了draw(...)函数。我们必须使用T_keyT_value类型的变量,因为类中的一些函数使用了一组键和值,而另一些函数使用了一对键和值的可迭代变量。

也就是说,当clean=函数的类型为

t.Callable[t.AsyncIterator[DataPoint]], t.AsyncIterator[t.Tuple[datetime.datetime, float]]

相应的draw=功能属于以下类型

t.Callable[[t.Any, t.Iterable[datetime.datetime], t.Iterable[float], t.Optional[str]], None]

我们需要独立地访问datetimefloat组件类型来构建这两种类型声明。类型变量允许我们告诉 mypy,类型是一个占位符,以后会提供;这里我们需要一个T_key和一个T_value类型的变量。我们还可以使用它们来定义一个名为Cleaned的泛型类型的模式,以及该类型的两个具有特定值的实例。

Cleaned = t.AsyncIterator[t.Tuple[T_key, T_value]]
CLEANED_DT_FLOAT = Cleaned[datetime.datetime, float]
CLEANED_COORD_FLOAT = Cleaned[t.Tuple[float, float], float]

如果你期望有许多不同类型的 cleaned/cleaner 类型,那么这种方法比显式地给每个函数分配完整的类型要清晰一些。

返回这些数据的 cleaner 函数有点复杂,因为 mypy 推断可调用函数中使用泛型类型的能力是有限的。要为可调用类型和类类型创建复杂的别名(相对于数据变量),我们必须使用协议特性。协议是一个类,它定义了底层对象必须拥有的被认为是匹配的属性,非常类似于自定义抽象基类的 subclasshook,但采用声明式风格,用于静态类型而不是运行时类型检查。

我们想定义一个接受数据点的AsyncIterator和其他类型的可调用函数。这里的另一种类型由T_cleaned_co类型变量表示,如下所示:

T_cleaned_co = t.TypeVar("T_cleaned_co", covariant=True, bound=Cleaned)

class CleanerFunc(Protocol[T_cleaned_co]):
    def __call__(self, datapoints: t.AsyncIterator[DataPoint]) -> T_cleaned_co:
        ...

这个CleanerFunc类型可以用来生成与之前的*_CLEANED变量相匹配的*_CLEANER变量。方括号中用于CleanerFunc的类型是这个特定函数提供的Cleaned的变体。

DT_FLOAT_CLEANER = CleanerFunc[CLEANED_DT_FLOAT]
COORD_FLOAT_CLEANER = CleanerFunc[CLEANED_COORD_FLOAT]

TypeVar中的covariant=参数是新增加的,我们用于变量名的_co后缀也是新增加的。以前,我们的类型变量被用来定义函数参数和函数返回值。这些是不变的类型:类型定义必须完全匹配。如果我们声明一个函数期望一个Sensor[float]作为参数,我们不能传递一个Sensor[int]。正常情况下,如果我们要定义一个期望一个float作为参数的函数,传递一个int就可以了。

这是因为我们没有允许 mypy 在Sensor类的组成类型上使用它的兼容性检查逻辑。这个权限是通过可选的covariant=contravariant=参数赋予类型变量的。一个共变类型是正常子类型逻辑适用的类型,因此如果传感器的T_value是共变的,那么期望Sensor[float]的函数可以接受Sensor[int],以期望float的函数可以接受int的相同方式。这对于泛型类来说是有意义的,泛型类的函数向它们被传递的函数提供数据。

一个逆变类型(通常以_contra后缀命名)是一个反向逻辑成立的类型。如果传感器的T_value是逆变的,那么期望Sensor[float]的函数不能接受Sensor[int],但是它们必须接受比float更具体的东西,比如Sensor[complex]。这对于泛型类很有用,这些泛型类的函数从传递给它们的函数中消耗数据。

我们正在定义一个提供数据的协议, 13 ,所以协变类型是最合适的。传感器同时是数据的提供者(sensor.value())和消费者(sensor.format(...)),因此必须不变

Mypy 在检查协议时会检测到适当类型的差异,如果不匹配,就会引发错误。因为我们正在定义一个提供数据的函数,所以我们必须设置covariant=True来防止这个错误出现。

bound=参数指定了可以推断出该变量的最小规格。由于这被指定为CleanedT_cleaned_co只有在可以被推断为与Cleaned[Any, Any]匹配时才有效。CleanerFunc[int]无效,因为int不是Cleaned[Any, Any]的子类型。bound=参数也可用于创建对现有变量类型的引用,在这种情况下,它允许定义遵循某些外部提供的函数签名的类型。

协议和类型变量是强大的特性,可以简化类型,但是如果过度使用,也会使代码看起来混乱。将类型作为变量存储在一个模块中是一个很好的中间点,但是您应该确保所有的类型样板文件都经过了很好的注释,甚至可能放在一个实用程序文件中,以避免给代码带来过多的新贡献者。

有了新的转换代码,我们可以创建一个绘图配置,以华氏度为单位绘制温度图表。清单 10-9 展示了 apd.aggregation 包的最终用户如何创建一个新的Config对象,该对象的行为方式与现有对象相同,但以他们喜欢的温度范围呈现其值。

import yappi
from apd.aggregation.analysis import (interactable_plot_multiple_charts, Config)
from apd.aggregation.analysis import (convert_temperature_system, clean_temperature_fluctuations)
from apd.aggregation.analysis import get_one_sensor_by_deployment

filter_in_db = Config(
    clean=convert_temperature_system(clean_temperature_fluctuations, "degF"),
    title="Ambient temperature",
    ylabel="Degrees F",
    get_data=get_one_sensor_by_deployment("Temperature"),
)
display(interactable_plot_multiple_charts(configs=[filter_in_db])())

Listing 10-9Jupyter cell to generate a single chart showing temperature in degrees F

我们已经通过添加这个函数改变了控制流,所以我们应该再运行一次分析来发现它做了什么改变。我们不希望温度转换花费大量的时间。

..ation\analysis.py:191 datapoint_ok  10818  0.031250  0.031250  0.000003
..on\utils.py:41 convert_temperature  8455   0.078125  6.578125  0.000778

虽然datapoint_ok(...)被调用了 10818 次,但是convert_temperature(...)函数本身被调用了 8455 次。这告诉我们,通过在转换温度之前过滤掉datapoint_ok(...)和清理函数,我们避免了 2363 次调用convert_temperature(...)来获取绘制当前图表时不需要知道的数据。然而,我们所做的调用仍然花费了 6.58 秒,是绘制这个图表的总时间的三倍。这太过分了。

我们可以通过重新实现这个函数来优化它,消除对 pint 的依赖,从而减少相关的开销。如果convert_temperature(...)是一个简单的算术函数,花费的时间会减少到 0.02 秒,代价是牺牲了很多灵活性。这对于两个单位都需要的简单转换来说很好;pint 擅长于事先不知道确切转换的情况。

或者,我们可以缓存convert_temperature(...)函数的结果。简单的缓存可以通过创建一个字典来实现,该字典在以摄氏度输入的值和所选温度系统中的值之间进行映射。清单 10-10 中的实现为迭代器的每次调用建立了一个字典,防止相同的条目被计算多次。

def convert_temperature_system(
    cleaner: DT_FLOAT_CLEANER, temperature_unit: str,
) -> DT_FLOAT_CLEANER:
    async def converter(datapoints: t.AsyncIterator[DataPoint],) -> CLEANED_DT_FLOAT:
        temperatures = {}
        results = cleaner(datapoints)
        async for date, temp_c in results:
            if temp_c in temperatures:
                temp_f = temperatures[temp_c]
            else:
                temp_f = temperatures[temp_c] = convert_temperature(temp_c, "degC", temperature_unit)
            yield date, temp_f

    return converter

Listing 10-10A simple manual cache

一个缓存的效率 14 通常用命中率来衡量。如果我们的数据集是[21.0, 21.0, 21.0, 21.0],那么我们的命中率将是 75%(未命中、命中、命中、命中)。如果是[1, 2, 3, 4],那么命中率会降到零。前面的缓存实现假设了一个合理的命中率,因为它没有努力从缓存中清除未使用的值。缓存总是在额外使用的内存和节省的时间之间进行权衡。它变得有价值的确切临界点取决于存储的数据大小以及您对内存和时间的个人要求。

从缓存中逐出数据的常见策略是 LRU(最近最少使用)缓存。该策略定义了最大缓存大小。如果缓存已满,当要添加新项目时,它将替换最长时间未被访问的项目。

functools模块提供了一个 LRU 缓存作为装饰器的实现,这使得包装我们的函数变得很方便。我们还可以用它来创建现有函数的缓存版本,方法是在 LRU 缓存装饰器中手工包装一个函数。

Caution

如果函数只接受可哈希类型作为参数,可以使用 LRU 缓存。如果一个可变类型(比如没有frozen=True的字典、列表、集合或数据类)被传递给一个包装在 LRU 缓存中的函数,就会引发一个TypeError

如果我们使用原来的基于 pint 的convert_temperature(...)函数,并添加 LRU 缓存装饰器,我们现在就可以对使用缓存所花费的时间进行基准测试。这样做的结果是,对函数的调用次数大大减少了,但是每次调用花费的时间却保持不变。没有缓存的 8455 次调用变成了 67 次调用,对应于 99.2%的命中率,并将提供该特性的时间开销从 217%减少到 1%。

..on\utils.py:40 convert_temperature  67     0.000000  0.031250  0.000466

通过对修饰函数使用cache_info()方法,可以在不运行分析器的情况下检索关于 LRU 缓存效率的附加信息。这在调试复杂系统时很有用,因为您可以检查哪些缓存性能良好,哪些性能不佳。

>>> from apd.aggregation.utils import convert_temperature
>>> convert_temperature.cache_info()
CacheInfo(hits=8455, misses=219, maxsize=128, currsize=128)

图 10-5 以对数标度显示了所有三种方法所用的时间(水平线代表十倍增长,而不是线性增长)。这有助于证明缓存和优化方法有多接近;对于我们的特殊问题,缓存一个非常昂贵的函数会导致与另一个不太灵活的实现相同数量级的性能。

img/481001_1_En_10_Fig5_HTML.png

图 10-5

三种方法的性能总结

重写函数以避免使用 pint 仍然可以提高性能,但是缓存结果可以以小得多的变化提供大致相同的改进,无论是在代码行还是概念上。

和往常一样,这里需要平衡。很可能人们只想要摄氏度或华氏度的温度,所以只提供这两种温度的转换函数可能就足够了。转换本身是简单易懂的,所以引入错误的风险是最小的。更复杂的函数可能不那么容易优化,这使得缓存成为更有吸引力的方法。或者,他们可能处理命中率较低的数据,使得重构更有吸引力。

@lru_cache装饰器的好处不在于缓存的内在效率(它是一个相当简单的缓存实现),而在于它很容易为 Python 函数实现。需要使用缓存的每个人都可以理解用缓存修饰的函数的实现,因为他们可以忽略缓存,而专注于函数体。例如,如果您正在编写一个定制的缓存层,使用像 Redis 这样的系统作为存储而不是字典,那么您应该构建您的集成,这样它就不会用特定于缓存的指令污染修饰代码。

缓存属性

functools 模块中另一个可用的缓存装饰器是@functools.cached_property。这种类型的缓存比 LRU 缓存更受限制,但它适合一种非常常见的用例,这种用例足以保证包含在 Python 标准库中。用@cached_property修饰的函数与用@property修饰的函数行为相同,但是底层函数只被调用一次。

程序第一次读取属性时,它会被底层函数调用的结果透明地替换。 15 只要底层函数的行为是可预测的并且没有副作用, 16 a @cached_property与常规的@property没有区别。像@property一样,这只能作为一个类的属性,并且必须采用除了self之外不接受任何参数的函数形式。

这可以用在apd.sensors包中 DHT 传感器的实现中。这两个传感器的value()方法从 Adafruit 接口包大量委托给 DHT22 类。在下面的方法中,只有一小部分代码与提取值相关;剩下的是设置代码:

    def value(self) -> t.Optional[t.Any]:
        try:
            import adafruit_dht
            import board

            # Force using legacy interface
            adafruit_dht._USE_PULSEIO = False

            sensor_type = getattr(adafruit_dht, self.board)
            pin = getattr(board, self.pin)
        except (ImportError, NotImplementedError, AttributeError):
            # No DHT library results in an ImportError.
            # Running on an unknown platform results in a
            # NotImplementedError when getting the pin
            return None
        try:
            return ureg.Quantity(sensor_type(pin).temperature, ureg.celsius)
        except (RuntimeError, AttributeError):
            return None

我们可以对此进行更改,将创建传感器接口的公共代码分解到一个基类中,该基类包含一个传感器属性。温度和湿度传感器可以丢弃所有的接口代码,转而依赖于self.sensor的存在。

class DHTSensor:

    def __init__(self) -> None:
        self.board = os.environ.get("APD_SENSORS_TEMPERATURE_BOARD", "DHT22")
        self.pin = os.environ.get("APD_SENSORS_TEMPERATURE_PIN", "D20")

    @property
    def sensor(self) -> t.Any:
        try:
            import adafruit_dht
            import board

            # Force using legacy interface
            adafruit_dht._USE_PULSEIO = False

            sensor_type = getattr(adafruit_dht, self.board)
            pin = getattr(board, self.pin)
            return sensor_type(pin)
        except (ImportError, NotImplementedError, AttributeError):
            # No DHT library results in an ImportError.
            # Running on an unknown platform results in a
            # NotImplementedError when getting the pin
            return None

class Temperature(Sensor[t.Optional[t.Any]], DHTSensor):
    name = "Temperature"
    title = "Ambient Temperature"

    def value(self) -> t.Optional[t.Any]:
        try:
            return ureg.Quantity(self.sensor.temperature, ureg.celsius)
        except RuntimeError:
            return None

    ...

DHTSensor类中的@property行可以用@cached_property替换,以便在调用之间缓存传感器对象。在这里添加一个缓存不会影响我们现有代码的性能,因为我们不保存对传感器的长期引用并重复查询它们的值,但是传感器代码的任何第三方用户可能会发现这是一个优势。

Exercise 10-1: Optimizing Clean_Watthours_To_Watts

在本章的开始,我们确定了最需要优化的clean_watthours_to_watts(...)函数。在我的测试数据集上,它增加了几秒钟的执行时间。

在本章的代码中,有一些扩展的测试来测量这个函数的行为和它的性能。验证性能的测试很棘手,因为它们通常是最慢的测试,所以我不建议理所当然地添加它们。如果您添加了它们,请确保将它们标记出来,这样您就可以在正常的测试运行中跳过它们。

修改clean_watthours_to_watts(...)功能,使测试通过。您需要实现大约 16 倍的加速才能通过测试。本章讨论的策略足以实现大约 100 倍的加速。

摘要

从这一章学到的最重要的一课是,无论你对你的问题空间理解得多好,你都应该衡量你的性能改进,而不仅仅是假设它们是改进。通常有一系列选项可供您用来提高性能,其中一些选项比其他选项更具性能。想到一个聪明的方法可以让事情变得更快,却发现它实际上没有帮助,这可能会令人失望,但知道这一点还是更好。

最快的选择可能需要比合理假设可用的更多的 RAM,或者可能需要删除某些功能。你必须仔细考虑这些,因为不能满足用户需求的快速代码是没有用的。

functools 中的两个缓存函数是日常编程中需要注意的。将@functools.lru_cache用于带参数的函数,将@functools.cached_property用于多处需要的对象的计算属性。

如果你的输入提示开始看起来很麻烦,那么你应该整理它们。您可以为变量分配类型,并用类似于TypedDictProtocol的类来表示它们,尤其是当您需要定义更复杂的结构化类型时。请记住,这些不是用于运行时类型检查的,并考虑将它们移到类型实用程序模块中,以获得更清晰的代码。这种重组已经在本章的示例代码中得到应用。

额外资源

以下链接更深入地介绍了本节涵盖的主题:

十一、容错

从乐观的角度来看,开发人员编写代码是很自然的。我们编写不工作的代码,然后我们反复调整它,直到它给出我们想要的结果。希望我们也编写测试,允许我们验证代码在未来仍然工作,并且测试检查我们正在处理我们已经正确意识到的任何边缘情况。我们永远无法编写测试来涵盖我们还没有想到的问题,所以遵守代码如何划分和处理遇到的小问题的规则是我们编写行为符合我们预期的软件的最佳策略。

错误处理

从一开始,我们就在本书附带的代码中发现了异常。有些例外是我们知道可以由我们正在使用的代码引发的(例如,DHT 接口代码在无法连接到传感器时引发RuntimeError)。其他的是由于对象的不正确使用而导致的异常(例如,如果我们试图从逆变器获得一条输出中不存在的数据,则在太阳能发电传感器中使用KeyError)。

我们还提出了NotImplementedError作为我们Sensor基类的一部分,以表达方法必须被传感器开发人员和各种RuntimeErrorValueError覆盖,作为命令行接口错误处理的一部分。

编程语言通常遵循“三思而后行”或“请求原谅比请求允许更容易”的错误处理哲学。三思而后行的哲学意味着你应该使用条件句来确定某事是否可能,并留下例外来代表意料之外的情况。请求原谅的理念意味着你应该编写代码来预测最常见的情况,并为你所知道的边缘情况补充异常处理程序。

Python 很大程度上属于后一种阵营;在许多情况下,在编写 Python 代码时依靠异常处理程序进行控制流被认为是正确的风格。

从容器中获取项目

我们用 Python 编写的最常见的表达式之一是从容器类型中获取一个条目,比如从字典中获取一个值或者从列表中获取一个条目。这两个都使用variable[other]结构。如果other没有指向variable中的有效项,那么就会引发一个异常。否则,返回关联的值。

尽管这些操作使用相同的方括号结构,但是底层数据类型和变量的含义却大不相同。当我们编写一个使用这个特性的函数时,我们需要意识到可能的结果是多么的不同。

你有时会看到字典被称为映射,但是这些术语是不可互换的。字典是映射的一个例子,映射是将键映射到值并提供某些方法的任何对象的名称。如果variable是一个映射(比如字典),那么other应该是一个可散列的类型:一个定义了hash(other)的类型。

另一方面,如果variable是一个列表或元组,那么对于序列的条目访问是被使用的。在这种情况下,other应该是一个整数,表示我们正在寻找的容器中的索引。我们不能使用方括号语法从生成器中获取一个项目,但可以将其用于列表的原因是因为生成器不是一个序列。所有序列(事实上,所有映射)都是可迭代的,但不是所有可迭代的都是序列。

抽象基类

映射、序列和散列的定义是collections.abc模块中对应的MappingSequenceHashable类。MappingSequence都是Collection的子类。如果一个对象实现了__len__()__iter__()__contains__(...)魔法方法,那么它就是一个Collection。也就是说,如果一个对象有一个定义的长度,可以被迭代,并且可以被查询以查看一个值是否在对象的迭代结果中,那么它就是一个集合。

虽然collections.abc.Sizedcollections.abc.Iterablecollections.abc.Container、 1collections.abc.Collection对象都是提供子类钩子的抽象基类(意味着任何实现所需方法的对象都被认为是抽象基类的子类),MappingSequence实现不会被自动检测。映射或序列的实现必须根据适当的基类进行注册。

映射和序列都实现了一个__getitem__(...)方法,但是具有非常不同的含义。一个Sequence是一个对象,其中variable[0]返回底层集合中的第一项,而一个Mapping是一个对象,其中variable[0]返回附加的值,即键0

当出错时,__getitem__(...)方法的两种不同语义会引发不同的异常。当代码试图检索序列末尾之外的项目时,序列版本会引发一个IndexError(例如空序列上的variable[0])。相反,当代码在不包含与该键关联的值的映射上使用项目访问时,会引发KeyError

当相应的键不是适当的类型时,调用任一类型的__getitem__(...)的代码都会引发一个TypeError。例如,一个序列上的variable[1.2]或一个映射上的variable[{}]都会引发TypeError。当被索引的变量没有__getitem__(...)方法时,Python 解释器也会抛出一个TypeError,例如None[0]

您应该预料到行variable[other]可能会引发这三种不同异常中的任何一种。通过更多地了解变量的底层数据类型,我们可以排除一个TypeErrorIndexErrorKeyError,但是只有通过更多地了解实际数据,我们才能确保不会引发异常。

对于许多简单的任务(例如表 11-1 中的函数,该函数包装__getitem__(...)以在所请求的项目不可用的情况下返回默认值 2 ),“宽恕”风格明显更加直接。它本身并不简单。完全有可能通过嵌套许多try / except块来编写控制流混乱的代码,但是这通常会简化代码。也许更重要的是,这是人们期望从 Python 程序中得到的风格。

表 11-1

两种风格中带有默认函数的 get 的详细实现

| *三思而后行*`from collections.abc import Sequence, Mapping``from collections.abc import Hashable``def get_item(variable, key, default=None):``if isinstance(variable, Sequence):``if isinstance(key, int):``if (0 <= key <  len(variable)):``return variable[key]``else:``# key is too big``return default``else:``# Key isn't an int``return default``elif isinstance(variable, Mapping):``if isinstance(key, Hashable):``if key in variable:``return variable[key]``else:``# key is not known``return default``else:``# Key isn't hashable``return default``else:``# variable isn't a known type``return default` | *请求原谅*`def get_item(variable, key, default=None):``try:``return variable[key]``except TypeError:``# variable has no get item # method``# or key isn't a valid type``return default``except KeyError:``# Variable is a mapping but``# doesn't contain key``return default``except IndexError:``# Variable is a sequence``# shorter than key``return default` |

问题在于决定在哪里捕捉异常,以及在哪里让异常冒出来调用代码。前面提到的两个实现的关键区别在于,左侧有两条成功代码路径和四条失败代码路径,而右侧有一条成功代码路径和三条失败代码路径。如果我们想为特定的条件定制行为,左边比右边容易,但只是因为左边的控制流比右边的代码更复杂。

这种复杂性在这个函数的性能上也很明显,如图 11-1;虽然某些操作的性能与任一实现大致相同,但异常处理程序路线有时要快得多。根据我的经验,在使用请求原谅方法时,通常更容易避免过于聪明的代码。

img/481001_1_En_11_Fig1_HTML.jpg

图 11-1

每种不同情况下两种实现的性能图表

想象一下,如果variable=参数的值是一个不支持项目访问的对象,我们希望我们的get_item(...)函数引发一个TypeError,但是我们仍然希望未知的关键代码路径导致返回一个默认值。这对应的是左边自定义底部条件,而右边TypeError的两个来源只有一个。我们可以在TypeError异常处理程序中添加一个条件来确定哪个代码路径导致了问题。为了补偿复杂性的增加,我们还可以将KeyErrorIndexError异常处理程序合并到一个块中,因为它们代表相同的行为,如清单 11-1 所示。

def get_item(variable, key, default=None):
    try:
        return variable[key]
    except (KeyError, IndexError):
        # Key is invalid for variable, the error raised depends on the type # of variable
        return default
    except TypeError:
        if hasattr(variable, "__getitem__"):
            return default
        else:
            raise

Listing 11-1A get with default function that raises on noncontainer arguments

Tip

在异常处理程序中,您可以使用raise而不用显式的异常来重新引发当前正在处理的相同错误。

异常类型

例外是具有自己的类层次结构的类。所有的异常都继承自BaseException,但是只有那些继承自Exception的异常才是开发者想要使用的。 3 当我们捕捉异常时,我们需要指定我们想要捕捉哪种类型的异常。没有指定要捕获的异常类型的except块被称为 bare except 并捕获所有异常,甚至是内部异常。由于KeyboardInterrupt是这些内部异常之一,一个裸露的try / except会抑制用户使用<CTRL+c>来停止程序的能力。

Tip

捕捉多种异常类型总比捕捉一个过于宽泛的超类好。您可以在一个块中指定许多异常类型,或者使用多个except块来实现这一点。

异常的类层次相对较浅,但是一些超类值得记住。最有用的是LookupError,它是KeyErrorIndexError的超类。LookupError特指请求的键不存在的情况,所以这个词不太宽泛。这允许我们通过用except LookupError替换except (KeyError, IndexError)来稍微简化我们的get_item(...)功能。

类型错误和值错误

我们经常不得不提出自己的异常,而不仅仅是从调用堆栈的底层重新提出现有的异常。在这种情况下,我们需要确保选择适当的异常类型和有用的消息。如果不清楚哪个异常类最适合,那么TypeErrorValueError是默认的好的异常类型。

当传递给函数的值是错误的类型时,TypeError是合适的,当传递的值是正确的类型但在某些方面不合适时,ValueError是合适的,除了LookupError s 所涵盖的情况

四个异常TypeErrorValueErrorKeyErrorIndexError一起代表了您将遇到的大多数逻辑类型的异常。如果您需要在自己的代码中引发一个异常,那么很有可能其中一个非常合适。

RuntimeError 和 SystemExit

还有针对非特定问题行为的异常类,其中伴随的消息是对问题的描述。RuntimeError是一个万不得已的异常类,用来处理不匹配任何其他类别的错误,但是可能需要通过调用函数来捕获。SystemExitsys.exit(...)函数调用在内部引发,表示程序应该结束。 4 在这两种情况下,给出的论点都是至关重要的,因为这是问题所在的唯一信息。

一般来说,except SystemExit:块仅适用于定制如何向最终用户显示最终错误消息。代码捕获RuntimeError并继续正常操作可能是有意义的,但这很大程度上取决于底层代码的构造方式和RuntimeError的含义。创建一个新的异常类通常比依赖RuntimeError要好。

断言错误

当一条assert语句失败时,解释器自动引发AssertionError s。在编写测试时,你经常会遇到这些问题,因为大多数assert语句都是在测试中编写的。将assert语句添加到任意的 Python 代码中是完全可能的,但是开发人员很少这样做。

Python 并不保证它会为任何失败的assert语句引发一个AssertionError,所以你不能依靠一个 assert 语句来进行正常的错误处理。在非测试代码中使用assert语句的一个可能的用途是添加断言来覆盖你对必须总是为真的事情的假设。例如,您可以使用assert行来验证函数参数之间的某种关系,这种关系不能用静态类型声明来表达,或者参数列表排序正确。同样,这并不能代替函数中正确的错误处理,但是拥有assert可以帮助跟踪不明显的错误。

使用assert语句的好处在于它们并不总是引发错误。如果使用python -OPYTHONOPTIMIZE=1环境变量运行程序,那么 assert 语句将被忽略,除了在调试会话期间,可能会禁用代价高昂的健全性检查。

assert语句添加到代码中来实现对程序的正确运行必要的检查是不正确的,因为不能保证它们会被运行。这种检查应该用一个 if 语句保护一个raise来实现。你应该只对你认为应该总是正确的检查使用 assert,但是你想知道你是否错了。

自定义例外

每当您使用新的第三方库时,通常会遇到各种自定义异常。例如,Pint 为 pint 数据库中没有列出的单位提供了UndefinedUnitError,为不可能的转换提供了DimensionalityErrorUndefinedUnitErrorAttributeError的一种,与访问单元的ureg.watt方法相匹配。DimensionalityErrorTypeError的子类,意味着库的开发人员希望开发人员将不同单位的数量视为不同的类型。

Click 在处理与我们的代码无关的命令行选项解析时有一系列例外;请求在requests.exception模块中提供具体的异常(如ConnectTimeoutReadTimeoutInvalidSchemaInvalidURL等)。)来处理特定的错误情况,或者通过父类(如用于所有超时错误的requests.exception.Timeout,甚至是IOError,因为它是所有特定于请求的异常的基类。

并不总是清楚第三方代码会引发什么类型的异常;开发人员的意图以及他们如何看待自己的代码是一个重要的影响因素。知道应该从第三方代码中捕捉哪些异常的唯一方法是阅读文档 5 ,并相信它是准确的。

创建新的异常类型

当您编写定义新异常类型的库代码时,您应该站在未来用户的角度考虑问题。确保有足够的多样性来准确地传达发生了哪些错误,但是要以这样的方式安排它们,使它们形成一个内聚的整体,既包括默认的异常类型,也包括彼此。像所有的 API 设计一样,最重要的成功标准是你的最终用户觉得它很直观。

我们的apd.sensors包使用None作为无法确定传感器值时的信号值。传感器可能由于一系列原因而无法返回值:可能是检索值时出现暂时错误(如太阳能输出传感器中的连接错误)或永久性错误(如没有电池充电电路的机器上的交流状态传感器)。

未能返回数据点的传感器不是任何一种类型的LookupError:代码找到了传感器,它只是不能正常工作。它不是一个TypeErrorValueError,因为没有参数是错误的类型或不可接受的值。内置异常类型的最接近的匹配是RuntimeError,我们最后使用的异常类型。为了避免直接引发RuntimeError,我们可以定义一些异常子类并重新编写代码来引发这些异常,而不是将None作为一个标记对象返回。

清单 11-2 展示了我们可以添加到apd.sensors包中的新异常,包括一个用于所有apd.sensors异常的基类,一个更具体的用于数据收集问题的基类,以及两个用于数据收集问题类型的子类。这些类别允许代码的用户识别他们的传感器代码中的特定问题,或者寻找与传感器相关的故障的广泛类别。

class APDSensorsError(Exception):
    """An exception base class for all exceptions raised by the
    sensor data collection system."""

class DataCollectionError(APDSensorsError, RuntimeError):
    """An error that represents the inability of a Sensor instance
    to retrieve a value"""

class IntermittentSensorFailureError(DataCollectionError):
    """A DataCollectionError that is expected to resolve itself
    in short order"""

class PersistentSensorFailureError(DataCollectionError):
    """A DataCollectionError that is unlikely to resolve itself
    if retried."""

Listing 11-2New exceptions for apd.sensors, stored as exceptions.py

这四个异常允许最终用户直观地捕捉错误。用捕获RuntimeErrorAPDSensorsErrorDataCollectionError中任何一个的try / except来包装sensor.value()都将捕获失败。有一个IntermittentSensorFailureError的事实也允许下游代码识别该特定情况并重试读取,如清单 11-3 中的示例函数。

from apd.sensors.base import Sensor, T_value
from apd.sensors.exceptions import IntermittentSensorFailureError

def get_value_with_retries(sensor: Sensor[T_value], retries: int=3) -> T_value:
    for i in range(retries):
        try:
            return sensor.value()
        except IntermittentSensorFailureError as err:
            if i == (retries - 1):
                # This is the last retry, reraise the underlying error
                raise
            else:
                continue
    # It shouldn't be

possible to get here, but it's better to
    # fall through with an appropriate exception rather than a
    # None
    raise IntermittentSensorFailureError(f"Could not find a value " f"after {retries} retries")

Listing 11-3Example function to retry a sensor read if there’s an intermittent problem

然后,我们可以使用这些误差来代替各种传感器中的返回信号None。这允许我们去除各种传感器类型中的t.Optional[...]结构。改变这种类型是否意味着之前 JSON 编码的传感器值不再有效,因为None不再是该传感器的有效传感器值。任何调用sensor.from_json_compatible(...)sensor.format(...)的代码都可能引发异常。当编写存储传感器值并在以后恢复它们的代码时,确保捕获任何错误并丢弃数据点是很重要的。如果我们希望确保未来变化的兼容性,我们可以编写迁移函数,并将版本号与传感器数据一起存储。

附加元数据

我们已经在 CLI 界面中引发了RuntimeError来传达错误消息。此代码路径是自定义异常的另一个好用途;我们可以在清单 11-4 中创建一个异常,它不是一个通常被隐藏的类型 6 并存储额外的元数据,比如所需的退出状态代码。

@dataclasses.dataclass(frozen=True)
class UserFacingCLIError(APDSensorsError, SystemExit):
    """A fatal error for the CLI"""
    message: str
    return_code: int

    def __str__(self):
        return f"[{self.return_code}] {self.message}"

Listing 11-4A new exception type with additional metadata

通常用一个参数实例化一个异常:一个人类可读的异常解释。这种方法不是异常的唯一格式;例如,OSError异常类型有数字错误标识符的参数以及人类可读的字符串。

Note

虽然大多数内置异常接受任意数量的参数,但我建议不要用它来存储关于异常的元数据。对于如何解释元组的参数,具有良好定义的参数的自定义异常类型总是比约定更清晰。

异常类型是 Python 类,因此我们可以使用任何标准技术来存储额外的信息作为异常的一部分。我会推荐一个数据类,就像我们处理任何主要存储数据的 Python 类一样。然后,我们可以在异常处理期间提取这些元数据,从而允许我们将失败的返回代码和可读消息合并到一个对象中。在这里,我们显式地添加了两项元数据。需要自定义的UserFacingCLIError.__str__()方法,因为将Exception转换为字符串必须只返回面向用户的错误表示,而 dataclasses 的默认实现显示所有参数的元组。

然后,我们可以使用这个异常向用户显示一条消息,并向操作系统返回正确的退出代码。

if develop:
    try:
        sensors = [get_sensor_by_path(develop)]
    except UserFacingCLIError as error:
        click.secho(error.message, fg="red", bold=True)
        sys.exit(error.return_code)

涉及多个异常的回溯

当我们从 Python 代码中抛出一个我们随后没有捕捉到的异常时,解释器打印一个回溯。回溯为最终用户提供了关于引发了什么异常以及代码的哪个部分触发了异常的信息。以下是通过在 IP 地址传感器中故意引入一个错误而获得的回溯示例:

Traceback (most recent call last):
  File "...\Scripts\sensors-script.py", line 11, in <module>
    load_entry_point('apd.sensors', 'console_scripts', 'sensors')()
  File "...\site-packages\click\core.py", line 764, in __call__
    return self.main(*args, **kwargs)
  File "...\site-packages\click\core.py", line 717, in main
    rv = self.invoke(ctx)
  File "...\site-packages\click\core.py", line 956, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "...\site-packages\click\core.py", line 555, in invoke
    return callback(*args, **kwargs)
  File "...\src\apd\sensors\cli.py", line 72, in show_sensors
    click.echo(str(sensor))
  File "...\src\apd\sensors\base.py", line 31, in __str__
    return self.format(self.value())
  File "...\src\apd\sensors\sensors.py", line 41, in value
    addresses = socket.getaddrinfo("hostname", None)
  File "...\Lib\socket.py", line 748, in getaddrinfo
    for res in _socket.getaddrinfo(host, port, family, type, proto, flags):
socket.gaierror: [Errno 11001] getaddrinfo failed

回溯中的每一对File和代码行代表调用堆栈中的一个函数。最下面的一行是引发异常的行,上面的每一行都提供了错误发生在软件的哪个部分的上下文。在这种情况下,标准库的socket.py中出现了异常,尽管原因并不明显。如果我们回到上一层,我们会看到我们控制的代码对标准库的调用。如果您假设您正在使用的库没有错误(这通常是一个公平的假设),那么指向我们控制的代码的最低堆栈条目很可能是罪魁祸首。并不总是这一行,有时是堆栈的较高部分负责(例如,由于变量设置不正确),但这通常是开始调试的最佳位置。

在这种情况下,我们可以从堆栈跟踪中看到,我们传递了一个包含“hostname”的字符串文字,但是getaddrinfo(...)的第一个参数应该是一个实际的主机名。在这种情况下,错误是由于意外地将变量名用引号括起来而不是传递变量引起的,这可能是 linter 捕捉到的。

异常通常是 Python 开发人员首先看到的事情之一(无论是在他们的职业生涯中还是在解决某个特定问题时),所以回溯对于大多数开发人员来说是相当熟悉的;然而,在回溯上有一些不常见但非常有用的小变化。

except 或 finally 块中的异常

第一种替代形式表示在处理一个异常的同时引发另一个异常。通常,异常处理程序中唯一的 raise 语句是一个空的raise来重新引发被捕获的异常,通常是在自省系统状态以确定异常是否应该被抑制之后。但是,自检代码本身可能包含导致未处理异常的错误。还有一种可能是,finally:块中的代码可能会引发一个异常。

当我们通过"hostname"而不是hostname时导致的错误暴露了一个我们目前没有处理的错误案例。如果我们传递一个无法通过 DNS 系统解析的主机名,那么就会引发一个异常。如果我们想以不同于这里提出的其他潜在的OSError的方式处理这个案例,我们需要在处理程序中反思这个异常。

OSError s 提供了一个errno=属性来获得一个数字代码来识别特定的问题,而不是为每个可能的错误创建子类。在捕捉异常时,如果我们错误地检查了一个err_no=属性而不是errno=,那么就会引发一个AttributeError。原始的OSError异常和AttributeError异常都是传递给最终用户的有用信息,所以提供了两个回溯。

不正确的条件代码如下所示:

41\.        try:
42\.            addresses = socket.getaddrinfo("hostname", None)
43\.        except OSError as err:
44\.            if err.err_no == 11001:
45\.                raise

结果是显示了两个堆叠的异常,如下所示:

Traceback (most recent call last):
  File "...\src\apd\sensors\sensors.py", line 42, in value
    addresses = socket.getaddrinfo("hostname", None)
  File "...\Lib\socket.py", line 748, in getaddrinfo
    for res in _socket.getaddrinfo(host, port, family, type, proto, flags):
socket.gaierror: [Errno 11001] getaddrinfo failed

During handling of the preceding

exception, another exception occurred:

Traceback (most recent call last):
  File "...\Scripts\sensors-script.py", line 11, in <module>
    load_entry_point('apd.sensors', 'console_scripts', 'sensors')()
  File "...\site-packages\click\core.py", line 764, in __call__
    return self.main(*args, **kwargs)
  File "...\site-packages\click\core.py", line 717, in main
    rv = self.invoke(ctx)
  File "...\site-packages\click\core.py", line 956, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "...\site-packages\click\core.py", line 555, in invoke
    return callback(*args, **kwargs)
  File "...\src\apd\sensors\cli.py", line 72, in show_sensors
    click.echo(str(sensor))
  File "...\src\apd\sensors\base.py", line 31, in __str__
    return self.format(self.value())
  File "...\src\apd\sensors\sensors.py", line 44, in value
    if err.err_no == 11001:
AttributeError: 'gaierror' object has no attribute 'err_no'

要显示的第一个异常是第一个发生的异常:触发第二个异常时我们正在处理的较低级别的异常。回溯要短得多,因为省略了第二个异常常见的所有回溯行。第一个回溯中上下文行的最顶端(sensors.py,第 42 行)指向一个try / except结构的 try 块。第二个回溯中必须有一行指向与try对应的except块中的一行。这种情况下是sensors.py,44 线。该行以上的所有行也作为上下文应用于第一次回溯。

第一个和第二个回溯用一行“During handling of the above exception, another exception occurred:”隔开。这表明第二个异常发生在包含触发第一个异常的代码的try块中。解释器以与任何正常异常回溯相同的格式打印第二个异常的完整回溯。

作为这种格式的一部分,可以显示任意数量的回溯,尽管很少会超过两个。这仅仅是因为在 except 或 finally 块中最小化代码量被认为是一种好的风格,所以看到更多代码也不是什么新鲜事。

从...升起

有时我们希望用另一个异常替换我们已经捕获的异常,例如用一个PersistentSensorFailureError替换我们的温度传感器中的adafruit_dhtImportError,表明传感器不能提供一个值,并且它不希望在短时间内改变。当我们为一个库定义了新的异常类型时,这尤其有用,因为它让我们简化了函数可能引发的异常。

如果我们编写一个直接引发新的PersistentSensorFailureErrortry / except构造,那么任何回溯都会将两者分开,说明我们的异常是在处理导入错误时引发的,正如我们前面看到的。这不是对情况的准确描述,因为从用户的角度来看,我们并没有真正地处理异常。Python 在这里提供了一个raise ... from ...结构,将一个异常标记为另一个异常的替代。

我们应该从 DHT sensor 基类中更新 sensor 属性来使用这种方法,如清单 11-5 所示。

import os
import typing as t

from .exceptions import PersistentSensorFailureError

class DHTSensor:
    def __init__(self) -> None:
        self.board = os.environ.get("APD_SENSORS_TEMPERATURE_BOARD", "DHT22")
        self.pin = os.environ.get("APD_SENSORS_TEMPERATURE_PIN", "D20")

    @property
    def sensor(self) -> t.Any:
        try:
            import adafruit_dht
            import board
            sensor_type = getattr(adafruit_dht, self.board)
            pin = getattr(board, self.pin)
            return sensor_type(pin)
        except (ImportError, NotImplementedError, AttributeError) as err:
            # No DHT library results in an ImportError.
            # Running on an unknown platform results in a
            # NotImplementedError when getting the pin.
            # An unknown sensor type causes an AttributeError
            raise PersistentSensorFailureError("Unable to initialise sensor interface") from err

Listing 11-5New version of DHT base class

这导致了与没有from err子句时完全相同的输出格式,但是使用了不同的分隔线。不是说在处理第一个异常时发生了第二个异常,而是用“The above exception was the direct cause of the following exception:”分隔两个回溯。

作为前一个例子的特例,使用raise PersistentSensorFailureError("Unable to initialise sensor interface") from None会导致原来的ImportError被完全隐藏。在这种情况下,只有我们的异常会显示给最终用户,并且它会在回溯行中包含完整的上下文。

异常处理测试

我们有一些涉及 CLI 测试异常的测试。具体来说,我们尝试用各种无效的传感器路径调用get_sensor_by_path(...)函数,并断言RuntimeError被引发。Pytest 的raises(...)上下文管理器用于断言我们期望代码块引发某个异常。它有两个参数:异常的类型和一个可选的match=参数,用于在错误的字符串表示上定义一个正则表达式过滤器。

with pytest.raises(RuntimeError, match="Could not import module"):
    subject("apd.nonsense.sensor:FakeSensor")

上下文管理器捕获RuntimeError并检查字符串表示是否匹配match=参数。 7 如果引发了任何其他异常,包括与字符串不匹配的不同的RuntimeError,则上下文管理器会正常地重新引发它们。如果到with pytest.raises(...):块结束时还没有出现匹配的异常,那么上下文管理器将出现一个AssertionError,这意味着测试失败。

这种方法让我们可以测试代码是否会引发我们期望的异常,因此我们可以确信我们的函数在我们知道数据是坏的情况下会引发异常。这只是成功的一半;测试中异常的另一面是在可能引发异常的地方注入异常,并测试调用代码的行为是否正确。例如,我们可能想要测试引发IntermittentSensorFailureError(...)的传感器不会导致整个数据收集运行失败。

新行为

我们已经决定传感器的value()函数应该返回泛型Sensor[type]声明中指定类型的对象,或者应该引发DataCollectionError。我们还没有定义如果传感器出现故障,CLI 或 API 应该做什么。在我们知道我们想要什么行为之前,测试异常行为没有什么意义。

我们将从 CLI 开始。当出现错误时,我希望在命令行界面中显示错误字符串,并继续其余的传感器查找。如果有一个可选的标志来显示整个异常回溯,帮助开发人员准确地调试传感器不工作的原因,这也是非常有用的。实现这个的代码在清单 11-6 中。

@click.command(help="Displays the values of the sensors")
@click.option(
    "--develop", required=False, metavar="path", help="Load a sensor by Python path"
)
@click.option(
    "--verbose", is_flag=True, help="Show additional info"
)
def show_sensors(develop: str, verbose: bool) -> int:
    sensors: t.Iterable[Sensor[t.Any]]
    if develop:
        try:
            sensors = [get_sensor_by_path(develop)]
        except UserFacingCLIError as error:
            if verbose:
                tb = traceback.format_exception(type(error), error, error.__traceback__)
                click.echo("".join(tb))
            click.secho(error.message, fg="red", bold=True)
            return error.return_code
    else:
        sensors = get_sensors()
    for sensor in sensors:
        click.secho(sensor.title, bold=True)
        try:
            click.echo(str(sensor))
        except DataCollectionError as error

:
            if verbose:
                tb = traceback.format_exception(type(error), error, error.__traceback__)
                click.echo("".join(tb))
                continue
            click.echo(error)
        click.echo("")
    return 0

Listing 11-6Updated command-line entrypoint with exception handling

Note

我们编写的格式化整个异常的代码相当笨拙。从 Python 1 时代开始,traceback.format_exception(...)函数就一直保持着它的特征, 8 尽管增加了一些东西。三个参数是必需的,但是它们都可以从异常对象本身提取。可以用 None 替换 traceback 对象,以指示只应该格式化异常信息,而不是整个 traceback。

我们还应该修改 API 的行为。为了保持向后兼容性,我们应该让 API 用一个None代替现有 API 版本中的任何DataCollectionError。有可能(尽管不太可能)一些用户会编写代码,通过在 API 响应中寻找None值来监控错误发生的频率。展望未来,我们希望创建一个新版本的 API 来智能地处理错误,这样 API 用户就可以获得关于失败的有用信息。

为了测试这个新的行为,我们需要创建一个测试子类(清单 11-7 ),它引发特定的异常,这样我们就可以验证周围的代码行为是否正确。这让我们能够在测试中可靠地触发传感器错误。

from apd.sensors.base import JSONSensor
from apd.sensors.exceptions import IntermittentSensorFailureError

class FailingSensor(JSONSensor[bool]):

    title = "Sensor which fails"
    name = "FailingSensor"

    def __init__(self, n: int=3, exception_type: Exception=IntermittentSensorFailureError):
        self.n = n
        self.exception_type = exception_type

    def value(self) -> bool:
        self.n -= 1
        if self.n:
            raise self.exception_type(f"Failing {self.n} more times")
        else

:
            return True

    @classmethod
    def format(cls, value: bool) -> str:
        raise "Yes" if value else "No"

Listing 11-7Definition of FailingSensor test sensor

在清单 11-8 中,我们将测试 v1.0 API 服务器,但是模拟出get_sensors(...)方法来返回FailingSensorPythonVersion传感器。

    @pytest.mark.functional
    def test_erroring_sensor_shows_None(self, api_server, api_key):
        from .test_utils import FailingSensor

        with mock.patch("apd.sensors.cli.get_sensors") as get_sensors:
            # Ensure the failing sensor is first, to test that subsequent
            # sensors are still processed
            get_sensors.return_value = [FailingSensor(10), PythonVersion()]
            value = api_server.get("/sensors/", headers={"X-API-Key": api_key}).json
        assert value['Sensor which fails'] == None
        assert "Python Version" in value.keys()

Listing 11-8Test to verify the 1.0 API remains compatible

使用 unittest 进行高级模拟。模拟的

正如我们在第八章中看到的,创建模拟对象的另一种方法是使用标准库的单元测试包中的模拟支持。之前,我们创建了原始的Mock对象,但是它们也可以用可选的spec=参数来创建。这导致它们只模拟传递的对象的属性,而不是为任何任意的属性访问返回一个新的模拟。这种方法很有帮助,因为任何试图检测对象上属性存在的代码在传递模拟时的行为都与传递真实对象时一样。

这使得模拟对象更接近被测试的真实事物,并修复了一整类测试错误。如果您正在使用isinstance(...)条件,特别是当与实现子类钩子的抽象基类结合使用时,那么不使用spec=参数的Mock对象会导致采用错误的代码路径,如下面的控制台会话示例所示:

>>> import collections.abc
>>> import unittest.mock
>>> from apd.sensors.base import Sensor

>>> unspecced = unittest.mock.MagicMock()
>>> isinstance(unspecced, Sensor)
False
>>> isinstance(unspecced, collections.abc.Container)
True

>>> specced = unittest.mock.MagicMock(spec=Sensor)
>>> isinstance(specced, Sensor)
True
>>> isinstance(specced, collections.abc.Container)
False

我们可以使用这个模拟对象来创建模拟传感器,以触发异常或返回特定的值。这种方法的一个小问题是不涉及真正的Sensor基类代码,所以我们不能依赖我们的模拟对象有基类提供的帮助方法。我们需要定制整个面向用户的 API 的行为(比如__str__()方法),而不仅仅是实现我们需要定制的功能,就像我们对第一个FailingSensor实现所做的那样,如清单 11-9 所示。

from apd.sensors.base import Sensor
from apd.sensors.exceptions import IntermittentSensorFailureError

FailingSensor = mock.MagicMock(spec=Sensor)
FailingSensor.title = "Sensor which fails"
FailingSensor.name = "FailingSensor"
FailingSensor.value.side_effect = IntermittentSensorFailureError("Failing sensor")
FailingSensor.__str__.side_effect = IntermittentSensorFailureError("Failing sensor")

Listing 11-9An alternative way to create a FailingSensor object

需要设置titlename属性,因为传感器基类上没有titlename属性,只有类型声明暗示它们在子类上可用。如果我们没有在这里设置它们,那么任何访问它们的尝试都会导致一个AttributeError

我们之前已经在一个Mock对象上使用了return_value属性来定义如果一个对象被调用,应该返回什么值:FailingSensor.__str__.return_value = "Yes"将配置 mock 使得str(FailingSensor) == "Yes"。但是,我们不能使用这种方法来引发异常。

side_effect属性可以包含要引发的异常、要从多次调用中返回的 iterable 项或被调用来确定结果的函数。将副作用设置为 iterable 是指定变化行为的一种便捷方式。例如,在下面的副作用配置中,第一次使用str(FailingSensor)时,它会产生一个IntermittentSensorFailureError,告诉用户还会有两次失败。如果str(FailingSensor)被重复调用,它将通过链表提升下两个IntermittentSensorFailureErrors,然后在第四次尝试时返回"Yes"

FailingSensor.__str__.side_effect = [
    IntermittentSensorFailureError("Failing 2 more times"),
    IntermittentSensorFailureError("Failing 1 more times"),
    IntermittentSensorFailureError("Failing 0 more times"),
    "Yes"
]

不幸的是,任何进一步的调用都会导致StopIteration错误,因为指定返回值的side_effect方法具有列表项到调用结果的一对一映射。可以使用来自itertools模块 9 的函数来创建一个无限长的可迭代对象,允许任意多次调用str(FailingSensor)

FailingSensor.__str__.side_effect = itertools.chain(
    [
        IntermittentSensorFailureError("Failing 2 more times"),
        IntermittentSensorFailureError("Failing 1 more times"),
        IntermittentSensorFailureError("Failing 0 more times"),
    ],
    itertools.cycle(["Yes"])
)

这个例子使用了itertools.cycle(...)函数来创建一个无限长的 iterable,它重复 iterable 中作为参数给出的项,以及将任意 iterable 追加在一起的。其结果是一个 iterable,它可以作为副作用引发三次异常,然后一致返回"Yes"

警告信息

警告的实现方式与异常类似,但行为方式却非常不同。尽管开发人员有时会谈论发出警告,但警告并不与关键字raise10一起使用,而是由warnings.warn(...)函数触发。开发人员遇到的最常见的警告是DeprecationWarning。在运行本书的示例代码时,您可能已经看到了一些。这是不可避免的,因为底层库可能会随时弃用某些功能,或者它们自己可能会使用弃用的功能来维护对旧版本代码的支持。

例如,在本书写作期间的一个短时间内,aiohttp 模块在 Python 3.8 中运行时触发了一个弃用警告,警告它对asyncio.shield(...) 11 使用了一个较旧的签名。

   ...\lib\site-packages\aiohttp\connector.py:944: DeprecationWarning: The loop argument is deprecated since Python 3.8, and scheduled for removal in Python 3.10.
    hosts = await asyncio.shield(self._resolve_host(

DeprecationWarning意在告诉开发人员他们正在使用的模式不再被认为是最佳实践。应该清楚哪里出了问题(在这种情况下,不应该传递loop=参数),并且应该给出一个明确的时间表,说明何时必须修复问题(在升级到 Python 3.10 之前)。

在这种情况下,是 Python 标准库发出了反对警告,目标受众是 aiohttp 的开发人员。作为 aiohttp 的用户,我们不是预期的受众,我们不应该担心看到反对警告,只要它们指定的时间范围不会结束。在这种特殊情况下,aiohttp 开发人员在 Python 3.8 发布后的两周内修复了这个不推荐警告。

aiohttp 的connector.py中触发该问题的特定代码位于第 944 行,如警告消息中所述。如果我们查看代码,我们可以看到触发异常的代码。

944\.            hosts = await asyncio.shield(self._resolve_host(
945\.                host,
946\.                port,
947\.                traces=traces), loop=self._loop)

Python 标准库中实现该警告的代码如下:

    if loop is not None:
        warnings.warn("The loop argument is deprecated since Python 3.8, "
                      "and scheduled for removal in Python 3.10.",
                      DeprecationWarning, stacklevel=2)

warn(...)函数可以将一个字符串和一种警告作为前两个参数,或者将一个警告实例作为第一个参数。如果只传递了一个没有警告类型的字符串,则假定它是一个UserWarningstacklevel=参数对应于相关代码从回溯的底部算起有多少行。这一点非常重要,因为警告应该总是暗示用户的代码,而不是检测问题并发出警告的代码。

默认值是stacklevel=1,它将弃用警告的来源显示为warnings.warn(...)调用。这里,stacklevel=2使上下文显示为调用warnings.warn(...)所在函数的代码行。类似地,stacklevel=3将是进一步移除的一个功能。

当我们添加了对基于地图的图像的支持时,我们对apd.aggregation包中的Config对象进行了更改。我们有效地弃用了sensor_name=参数,取而代之的是另外指定的get_data=参数,但是我们没有向用户公开这一点。这是一个很好的DeprecationWarning候选,如清单 11-10 所示。

@dataclasses.dataclass
class Config(t.Generic[T_key, T_value]):
    title: str
    clean: CleanerFunc[Cleaned[T_key, T_value]]
    draw: t.Optional[
        t.Callable[
            [t.Any, t.Iterable[T_key], t.Iterable[T_value], t.Optional[str]], None
        ]
    ] = None
    get_data: t.Optional[
        t.Callable[..., t.AsyncIterator[t.Tuple[UUID, t.AsyncIterator[DataPoint]]]]
    ] = None
    ylabel: t.Optional[str] = None
    sensor_name: dataclasses.InitVar[str] = None

    def __post_init__(self, sensor_name: t.Optional[str] = None) -> None:
        if self.draw is None:
            self.draw = draw_date  # type: ignore
        if sensor_name is not None:
            warnings.warn(
                DeprecationWarning(
                    f"The sensor_name parameter is deprecated. Please pass "
                    f"get_data=get_one_sensor_by_deployment('{sensor_name}') "
                    f"to ensure the same behaviour. The sensor_name= f"parameter "
                    f"will be removed in apd.aggregation 3.0."
                ),
                stacklevel=3,
            )
            if self.get_data is None:
                self.get_data = get_one_sensor_by_deployment(sensor_name)
        if self.get_data is None:
            raise ValueError("You must specify a get_data function")

Listing 11-10Updated Config data class that issues a deprecation warning for sensor_name

Note

这里的stacklevel=参数是 3,不是 2。我们希望当用户实例化一个Config对象时显示这个警告。@dataclass装饰器生成一个调用__post_init__(...)__init__(...)函数。stacklevel 为 2 将显示弃用警告与生成的__init__(...)函数相关联,而不是与调用代码相关联。如果您不确定,请尝试引发一个异常并查看堆栈跟踪。

生成的警告显示了错误代码的位置(analysis.py,第 287 行),给出了修复的确切说明,并包含了修复的截止日期。它还显示了有问题的行,在本例中是多行Config(...)构造函数调用的第一行。

...\src\apd\aggregation\analysis.py:287: DeprecationWarning: The sensor_name parameter is deprecated. Please pass get_data=get_one_sensor_by_deployment('Temperature') to ensure the same behaviour. The sensor_name= parameter will be removed in apd.aggregation 3.0.
    Config(

警告过滤器

可以定义新的警告类型来补充内置的警告类型,但是这不如子类化异常有用。创建新警告类型的主要原因是允许最终用户更好地利用警告过滤器。警告过滤器改变警告的默认行为,使它们更突出或不突出。

更改过滤器可用于更准确地控制显示给最终用户的警告集。如果您正在维护一个依赖于库的工具,该库会导致多个弃用警告,那么为最终用户抑制这些警告会提高他们对该工具的信心。 12

warnings.simplefilter("ignore", DeprecationWarning)

相反,您可以提高警告的严重性,使其成为异常,以帮助您准确地调试它们的原因。警告过滤器的动作"error"导致任何警告被视为异常。也就是说,会显示完整的回溯,一旦代码遇到第一个警告,执行就会停止。 13 结合使用事后调试器和此选项是调查警告原因的有效方法。

warnings.simplefilter("error", DeprecationWarning)

Tip

当以python script.py的身份直接运行 Python 代码时,可以用-W命令行选项设置默认的警告行为,如python -Werror script.py。设置PYTHONWARNINGS环境变量具有相同的效果,但是它适用于不通过解释器直接调用的基于 Python 的可执行文件,比如我们的 sensors 命令行工具。

如果下游组件没有定义自定义警告(大多数都没有),您还可以按文件、行号、 14 消息或它们的任意组合来过滤警告。这种灵活性允许您隐藏您知道的特定警告,而不隐藏您可能不知道的任何其他警告。

import re, warnings

warnings.filterwarnings(
    "ignore",
    message=re.escape("The sensor_name parameter is deprecated"),
    category=DeprecationWarning,
    module=re.escape("apd.aggregation.analysis"),
    lineno=275
)

最后,您可以临时修改警告过滤器,并自动恢复旧的过滤器。如果单个函数引发了许多您想要抑制的不同警告,但在通过不同的代码路径触发时又不隐藏它们,这可能会很有用。

import warnings

with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    function_that_warns_a_lot()

如果您想断言代码中出现了警告,那么相同的上下文管理器在测试中非常有用。如果您希望确信在某些复杂的情况下会显示警告,这是很有用的,但通常没有必要。catch_warnings(...)函数接受一个可选的record=True参数,该参数允许访问上下文管理器主体中出现的所有警告的记录。您应该确保警告过滤器没有忽略任何警告,因为只记录向最终用户显示的警告。清单 11-11 显示了一个利用该功能的示例测试。

def test_deprecation_warning_raised_by_config_with_no_getdata():
    with warnings.catch_warnings(record=True) as captured_warnings:
        warnings.simplefilter("always", DeprecationWarning)
        config = analysis.Config(
            sensor_name="Temperature",
            clean=analysis.clean_passthrough,
            title="Temperaure",
            ylabel="Deg C"
        )
        assert len(captured_warnings) == 1
        deprecation_warning = captured_warnings[0]
        assert deprecation_warning.filename == __file__
        assert deprecation_warning.category == DeprecationWarning
        assert str(deprecation_warning.message) == (
            "The sensor_name parameter is deprecated. Please pass "
            "get_data=get_one_sensor_by_deployment('Temperature') "
            "to ensure the same behaviour. The sensor_name= parameter "
            "will be removed in apd.aggregation 3.0."
        )

Listing 11-11A test to ensure a warning is raised

记录

所有类型的应用都广泛使用日志记录。它帮助最终用户调试问题,并允许更详细的错误报告,这反过来又节省了试图重现问题的时间。日志的使用方式与print(...)调试的方式非常相似,但是对于大型应用和库来说,它有一些显著的优势。

print(...)调试相比,日志记录的最大优势是日志记录框架将每个日志条目与严重性相关联。用户可以选择一个日志级别来控制记录多少日志信息,因此他们可以选择仅在需要时生成调试日志(例如)。

Tip

如果您正在编写有助于调试的日志语句,请为最终用户提供一种获取日志的简单方式。Pipenv 用一个--support标志很好地做到了这一点,它以 markdown 格式打印所有相关数据,以便粘贴到 GitHub 问题中。考虑添加一个类似的选项,设置一个较低的日志级别,并将格式化的版本和配置数据与日志文件一起输出,作为界面设计的一部分。但是,不要在没有得到用户明确许可的情况下自动整理用户系统的日志,因为这可能会侵犯他们的隐私。

日志记录的默认级别是调试、信息、警告、错误和严重。 15 我们可以使用日志模块中的匹配函数来记录消息,比如logging.warning(...),将警告级别的消息记录到根日志记录器

>>> logging.warning("This is a warning")
WARNING:root:This is a warning

默认情况下,Python 会丢弃调试和信息日志消息,只有警告级别及以上的消息才会以格式LEVEL:logger:message记录到终端。记录器从丢弃消息变为显示消息的阈值就是该记录器的级别。用于显示的格式是首次使用 root logger 时设置的,可以通过使用新的格式化程序调用logging.basicConfig(...)函数进行调整。 16 这也允许您更改根日志记录器的过滤器阈值级别,例如在以下示例中将其设置为 debug:

logging.basicConfig(format="{asctime}: {levelname} - {message}", style="{", level=logging.DEBUG)

多年来,Python 已经有了许多字符串格式语法;要使用现代风格,请将style="{"作为另一个参数传递。您可能会看到旧程序中的日志配置使用不同的格式,但可用的键仍然是相同的。这些键列在标准库的文档中的LogRecord属性下,但是最有用的是

  1. asctime–格式化的日期/时间

  2. levelname–日志级别的名称

  3. pathname–引发日志消息的文件的路径

  4. funcName–产生日志消息的函数的名称

  5. message–记录的字符串

嵌套记录器

在程序中使用嵌套层次的记录器是很常见的。可以通过调用logging.getLogger(name)函数来检索记录器,其中 name 是要检索的记录器的名称。

当一个记录器被检索时,其名称将与由.字符分割的现有记录器进行比较。如果现有的日志记录器的名称是新日志记录器的前缀,那么它将成为父日志记录器。那就是:

>>> import logging
>>> root_logger = logging.getLogger()
>>> apd_logger = logging.getLogger("apd")
>>> apd_aggregation_logger = logging.getLogger("apd.aggregation")

>>> print(apd_aggregation_logger)
<Logger apd.aggregation (WARNING)>

>>> print(apd_aggregation_logger.parent)
<Logger apd (WARNING)>

>>> print(apd_logger.parent)
<RootLogger root (WARNING)>

Caution

如果apd_aggregation_logger是在apd_logger之前创建的,那么两者都将 root logger 作为它们的父级。确保这种行为正确的最简单方法是在所有模块中添加logger = logging.getLogger(__name__)行。这确保了你的日志结构和你的代码结构是一样的,更容易推理。如果您想确保所有的父记录器都设置正确,请确保将它包含在任何__init__.py中。

这些记录器均可用于记录消息,所使用的记录器显示为日志消息的一部分(如果记录器名称包含在格式化程序中)。记录器收到的任何消息也会传递给其父级。 17 正是这种行为允许我们通过配置根记录器来配置所有记录器的格式。

>>> apd_aggregation_logger.warning("a warning")
WARNING:apd.aggregation:a warning

>>> apd_logger.warning("a warning")
WARNING:apd:a warning

>>> root_logger.warning("a warning")
WARNING:root:a warning

单个记录器可以有一个新的级别集,该级别集会传播到它们的所有子级(除非它们有自己的级别集)。这允许通过设置命名记录器的级别来配置每个包的日志记录。

>>> apd_logger.setLevel(logging.DEBUG)

>>> apd_aggregation_logger.debug("debugging")
DEBUG:apd.aggregation:debugging

>>> apd_logger.debug("debugging")
DEBUG:apd:debugging

>>> root_logger.debug("debugging")
(no output)

自定义操作

到目前为止,我们一直将 loggers 视为一种美化的打印声明,但是它们比这灵活得多。当我们记录一个字符串时,日志框架在内部创建一个LogRecord对象,然后将它传递给一个处理程序,该处理程序对它进行格式化并输出到标准错误流上。

记录器也可以有自定义的处理程序,以其他方式记录信息。最常用的处理程序是StreamHandler,它格式化日志消息(可能使用自定义格式化程序)并在终端中显示。我们可以用它来定义在apd.aggregation包中使用自定义日志格式进行日志记录,但是默认格式用于所有其他日志记录,例如。

额外元数据

我们可以使用日志方法的额外字典向格式化程序添加特定于应用的方面。这样做的缺点是,所有遵循该格式的日志消息都必须为额外的关键字提供一个值,如果它们是日志格式的一部分。如果您在根日志记录器上设置了一个定制格式,需要一个特定的额外数据,这将导致所有不受您直接控制的日志调用引发一个KeyError。这是只将自定义格式化程序应用于您自己的记录器而不是根记录器的一个很好的理由。

为了做到这一点,我们需要用一个新的格式化程序定制一个单独的记录器。我们不能使用logging.basicConfig(...)函数,因为它只能操纵根日志记录器;我们需要提供一个新的函数,按照我们希望的那样设置处理程序。清单 11-12 有一个这个函数的例子。

import logging

def set_logger_format(logger, format_str):
    """Set up a new stderr handler for the given logger
    and configure the formatter with the provided string
    """
    logger.propagate = False
    formatter = logging.Formatter(format_str, None, "{")

    std_err_handler = logging.StreamHandler(None)
    std_err_handler.setFormatter(formatter)

    logger.handlers.clear()
    logger.addHandler(std_err_handler)
    return logger

logger = set_logger_format(
    logging.getLogger(__name__),
    format_str="{asctime}: {levelname} - {message}",
)

Listing 11-12Helper function to configure a logger with a specific formatter

我们在set_logger_format(...)调用中添加的任何附加字段也必须在每个日志记录调用中提供,作为一个extra=字典,如下所示:

>>> logger = set_logger_format(
...     logging.getLogger(__name__),
...     format_str="[{sensorname}/{levelname}] - {message}",
... )
>>> logger.warn("hi", extra={"sensorname": "Temperature"})
[Temperature/WARNING] – hi

我们可以通过在格式化日志记录之前对其进行操作来解决这一限制。有几种不同的方法可以将变量注入日志记录:定制工厂、添加适配器或添加过滤器。当从我们自己的代码进行日志记录时,自动注入数据还允许更方便的接口,因为我们不再需要显式地传递格式化程序可能希望作为关键字参数的所有数据。

测井适配器

日志适配器是一段代码,它包装了一个日志记录器,允许定制它的任何行为。它提供了一个流程函数,可用于将消息和参数转换为底层日志功能,并可如清单 11-13 所示进行创建。

import copy
import logging

class ExtraDefaultAdapter(logging.LoggerAdapter):
    def process(self, msg, kwargs):
        extra = copy.copy(self.extra)
        extra.update(kwargs.pop("extra", {}))
        kwargs["extra"] = extra
        return msg, kwargs

def set_logger_format(logger, format_str):
    """Set up a new stderr handler for the given logger
    and configure the formatter with the provided string
    """
    logger.propagate = False
    formatter = logging.Formatter(format_str, None, "{")

    std_err_handler = logging.StreamHandler(None)
    std_err_handler.setFormatter(formatter)

    logger.handlers.clear()
    logger.addHandler(std_err_handler)
    return logger

Listing 11-13A log adapter that provides defaults for some additional keywords

使用这个适配器允许我们省略额外的字典,除非我们有希望添加到这个日志消息中的数据,允许我们在不相关的时候省略它。这也使得向格式字符串添加新项变得更加容易,因为我们不需要改变每个日志记录函数调用来匹配。

>>> logger = set_logger_format(
...     logging.getLogger(__name__),
...     format_str=" [{sensorname}/{levelname}] - {message}",
... )
>>> logger = ExtraDefaultAdapter(logger, {"sensorname": "none"})
>>> logger.warn("hi")
[none/WARNING] - hi
>>> logger.warn("hi", extra={"sensorname": "Temperature"})
[Temperature/WARNING] - hi

这种方法的缺点是我们需要用适配器包装每个日志记录器。它非常适合在单个模块中自动填充额外的数据,但是它不能帮助我们提供跨多个记录器的缺省值,因为不能保证使用记录器的所有代码也将使用适配器(事实上,对于根记录器,几乎可以保证会有使用日志记录的代码不知道我们的定制适配器)。

我们可以向适配器本身添加任何我们想要的逻辑。例如,我们可以从上下文变量中提取这个值,而不是为sensorname提供一个显式的默认值。适配器最适合于只有一个记录器需要定制元数据的情况。如果您已经为一个只有您登录的记录器定义了一个自定义格式化程序,那么很有可能确保所有的日志调用都通过适配器。

日志记录工厂

另一种方法是定制内部日志记录对象本身的创建。定制工厂允许任意数据存储在所有的LogRecords上,而记录日志的代码不会意识到任何差异。这允许自定义元数据以第三方代码使用的记录器格式使用,例如根记录器。使这种格式对所有日志记录器通用意味着没有不同日志格式的混合,这对于用户来说可能是一个显著的优势。缺点是这里设置的属性不能在额外的字典中传递。 18

在前面的例子中,我们可以灵活地将额外的数据传递给日志记录系统。当覆盖LogRecord工厂时,我们别无选择,只能使用一个上下文变量来传递额外的数据。这限制了该方法的使用方式,因为我们不能简单地将我们想要的值作为参数传递。

清单 11-14 显示了定制记录工厂的示例代码,以便在所有记录中包含来自 sensorname_var 上下文变量的值。

from contextvars import ContextVar
import functools
import logging

sensorname_var = ContextVar("sensorname", default="none")

def add_sensorname_record_factory(existing_factory, *args, **kwargs):
    record = existing_factory(*args, **kwargs)
    record.sensorname = sensorname_var.get()
    return record

def add_record_factory_wrapper(fn):
    old_factory = logging.getLogRecordFactory()
    wrapped = functools.partial(fn, old_factory)
    logging.setLogRecordFactory(wrapped)

add_record_factory_wrapper(add_sensorname_record_factory)
logging.basicConfig(
    format="[{sensorname}/{levelname}] - {message}", style="{", level=logging.INFO
)

Listing 11-14Customizing a LogRecord factory to add contextual information and include in all logs

这种方法与以前的方法有很大不同,因为它在全局级别上更改日志记录配置。适配器示例涉及到对每个模块的更改,以将记录器包装在适当的适配器中,并且每个模块都可以有自己的适配记录器。一次只能有一个唱片工厂处于活动状态。虽然我们可以多次覆盖它以提供额外的数据,但是所有的覆盖都必须以这样一种方式编写,以便不会相互冲突。这种方法可以如下使用:

>>> logger = logging.getLogger(__name__)
>>> logger.warning("hi")
[none/WARNING] – hi
>>> token = sensorname_var.set("Temperature")
>>> logging.warning("hi")
[Temperature/WARNING] - hi
>>> sensorname_var.reset(token)

日志过滤器

在我看来,日志过滤器在这两种方法之间提供了一个很好的中间地带。名称过滤器可能使这种方法有点违反直觉,因为过滤器旨在用于动态地丢弃日志记录,但这也是改变日志记录的最灵活的方法。

您可以将日志过滤器与记录器相关联,这将导致记录器处理的每个日志消息都调用它,但是您也可以根据处理程序注册它。控制格式的是处理程序,因此将筛选器与处理程序相关联可以确保自定义格式和默认值筛选器紧密关联。无论何时使用该处理程序,您都知道过滤器也是活动的。

这种方法意味着默认传感器名称仅作为格式化过程的一部分来填充。附加信息仍然可以作为额外字典的一部分传递,这是正常的,当显式传递时,所有日志处理程序都可以使用它。清单 11-15 显示了一个更新的设置函数,它可以选择关联一个过滤器和处理器。

import logging

class AddSensorNameDefault(logging.Filter):
    def filter(self, record):
        if not hasattr(record, "sensorname"):
            record.sensorname = "none"
        return True

def set_logger_format(logger, format_str, filters=None):
    """Set up a new stderr handler for the given logger
    and configure the formatter with the provided string
    """
    logger.propagate = False
    formatter = logging.Formatter(format_str, None, "{")

    std_err_handler = logging.StreamHandler(None)
    std_err_handler.setFormatter(formatter)

    logger.handlers.clear()
    logger.addHandler(std_err_handler)
    if filters is not None:
        for filter in filters:
            std_err_handler.addFilter(filter)
    return logger

Listing 11-15Using a handler filter to add a default sensorname

设置这个记录器与适配器模式非常相似,但是有一个重要的区别。set_logger_format(...)呼叫只需要进行一次。对logging.getLogger(...)的任何后续调用都会返回一个正确配置的记录器,而不需要记录器的每个用户都配置过滤器。初始使用如下进行:

logger = set_logger_format(
    logging.getLogger(),
    "[{sensorname}/{levelname}] - {message}",
    filters=[AddSensorNameDefault(), ]
)
>>> logger.warning("hi")
[none/WARNING] - hi
>>> logger.warning("hi", extra={"sensorname": "Temperature"})
[Temperature/WARNING] - hi

日志记录配置

上述代码的缺点是,为了更改格式化程序或添加过滤器,我们必须为日志系统做大量的设置工作。对于除了简单的自包含工具之外的所有应用,最终用户可能希望配置他们自己的处理程序或日志格式化程序。对于大型应用中使用的库来说尤其如此。

因此,在现实应用中很少使用 Python 代码配置日志记录。通常,日志配置是通过某种配置系统提供的,比如配置迁移系统的alembic.ini文件的[logging]配置部分。logging.config.fileConfig(...)助手函数可用于从文件中加载日志配置,少量粘合代码(清单 11-16 )可用于使我们添加的任何过滤器可供最终用户在 ini 风格的日志配置中利用(清单 11-17 )。

[loggers]
keys=root

[handlers]
keys=stderr_with_sensorname

[formatters]
keys=sensorname

[logger_root]
level=INFO
handlers=stderr_with_sensorname

[handler_stderr_with_sensorname]
class=apd.aggregation.utils.SensorNameStreamHandler
formatter = sensorname

[formatter_sensorname]
format = {asctime}: [{sensorname}/{levelname}] - {message}
style = {

Listing 11-17A sample logging configuration file that uses a filter to provide default values for the formatter

import logging

class AddSensorNameDefault(logging.Filter):
    def filter(self, record):
        if not hasattr(record, "sensorname"):
            record.sensorname = "none"
        return True

class SensorNameStreamHandler(logging.StreamHandler):
    def __init__(self, *args, **kwargs):
        super().__init__()
        self.addFilter(AddSensorNameDefault())

Listing 11-16Glue code to provide a handler that has a filter added by default

Caution

日志配置文件格式允许嵌入一些逻辑,以简化复杂配置的设置。这使得从配置文件中运行任意代码成为可能。这很少成为问题,但是如果您有由系统管理员运行的工具,那么应该只有管理员能够编辑日志配置。

其他处理者

除了我们目前使用的StreamHandler之外,还有其他有用的处理程序。最常见的是FileHandler,它将日志信息输出到一个命名文件中。将此设置为根日志记录器上的处理程序用于构建持久日志文件。

更复杂的处理程序,如TimedRotatingFileHandlerSysLogHandlerHTTPHandler,不太常用,但非常强大。这些允许将日志集成到任何方式的现有日志管理解决方案中。甚至有商业日志管理系统也以同样的方式集成,比如 Sentry 及其定制的EventHandler类。

审计日志

拥有定制的记录器和处理程序允许编写在复杂系统中记录用户操作的审计日志记录系统。审核日志是一种日志,旨在提供有关用户已执行的某些重要操作的信息。它不是用于调试,而是用于验证系统没有被滥用。

为了实现这一点,您通常会获得一个带有logging.getLogger("audit")名称的新日志记录器,并将其配置为审计日志记录器。与大多数日志记录器不同,大多数审计日志的命名并不与 Python 模块相匹配。通常,审计记录器使用特殊的日志处理程序,例如将审计日志事件附加到系统日志或通过电子邮件发送的处理程序。我建议将审计日志条目输出到与其他日志条目相同的输出流位置。将审计日志条目与调试信息混合在一起可以增加高级上下文,这在调试问题时非常有用。

日志处理程序可以与多个记录器相关联,因此可以通过为每个文件定义一个处理程序并将其与应该输入到该文件中的每个单独的记录器相关联,来配置自定义日志文件以包含多个记录器的输出。您还可以使用记录器的嵌套结构为应用的逻辑组件创建日志文件。

日志处理程序是用提供emit(record)函数的 Python 类实现的,因此可以编写定制的处理程序来执行任何适当的特定于应用的审计日志记录操作。实际上,大多数常见需求都有可用的处理程序实现。

围绕问题进行设计

前面的策略允许我们交流在程序组件中遇到的问题(使用异常)和最终用户(使用警告和日志)。它们让我们更容易理解用户遇到了什么问题(当他们被报告时)。然而,大多数问题都没有被报告,我们永远不可能提前想到每一个可能的边缘情况。

编写可靠软件的一个关键部分是设计能够自动补偿正常运行中遇到的问题的过程。对我们来说,与传感器通信的任何问题都会导致我们正在收集的传感器历史数据出现缺口。

这种失败有两种可能的原因。传感器服务器工作正常,但聚合进程(或网络)出现故障;或者聚合进程(或网络)工作正常,但传感器出现故障。

调度传感器查找

聚合器或网络故障的问题是最容易解决的。我们可以修改传感器来定期收集和存储数据,而不是通过聚合过程从传感器获取实时数据。然后,它可以通过 API 提供这些收集的数据。这允许聚合过程检测何时收集了数据但未下载,并通过下载自上次成功同步以来的所有数据来纠正问题。

要实现这一点,需要对聚合过程和传感器本身进行重大更改。所涉及的服务器不仅需要在特定时间触发传感器数据收集,还需要能够存储数据并通过 API 公开存储的数据集。

我们需要像创建聚合流程一样创建一个数据库集成。我们还需要一个新的命令行选项来存储数据,并为 alembic 和 sqlalchemy 添加一组依赖项,以确保我们可以将数据存储到数据库中。这些需要是可选的依赖项:并不是所有的apd.sensors包的用户都必须使用聚合器,如果用户只需要命令行工具来检查当前状态,那么要求他们安装完整的数据库系统就太过分了。一旦添加了这个新特性,setup.cfg的可选依赖部分将如下所示。

Note

有些要求只有在我们同时安装了 webapp 和 scheduled extras 的情况下才是相关的,因为我们稍后将使用它们来实现数据库查找。我们可以为这些内容创建另一个额外的内容,但这确实会让用户更难理解。您可能更喜欢将这些依赖项添加到其他额外的定义中。当我们使用第三个额外的时候,我们必须记住,在编写代码的时候,并不是所有的依赖项都是可用的。没有任何东西可以阻止用户为这些额外的依赖项安装额外的组件,而没有这两个组件。

[options.extras_require]
webapp = flask
scheduled =
  sqlalchemy
  alembic
storedapi =
  flask-sqlalchemy
  python-dateutil

然后,我们需要使用pipenv install确保我们的本地开发环境被标记为需要这组新的可选依赖项。就像聚合过程一样,我们需要创建一个数据库表定义(清单 11-18 ,将元数据对象连接到 alembic 配置,并生成一个初始的 alembic 迁移。

from __future__ import annotations

import datetime
import typing as t

import sqlalchemy
from sqlalchemy.schema import Table
from sqlalchemy.orm.session import Session

from apd.sensors.base import Sensor

metadata = sqlalchemy.MetaData()

sensor_values = Table(
    "recorded_values",
    metadata,
    sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True),
    sqlalchemy.Column("sensor_name", sqlalchemy.String, index=True),
    sqlalchemy.Column("collected_at", sqlalchemy.TIMESTAMP, index=True),
    sqlalchemy.Column("data", sqlalchemy.JSON),
)

def store_sensor_data(sensor: Sensor[t.Any], data: t.Any, db_session: Session) -> None:
    now = datetime.datetime.now()
    record = sensor_values.insert().values(
        sensor_name=sensor.name, data=sensor.to_json_compatible(data), collected_at=now
    )
    db_session.execute(record)

Listing 11-18Database table for caching sensor values locally

清单 11-19 中的变化是添加了一个命令行选项,用于指定应该连接到哪个数据库,以及一个标志,用于标记应该将数据保存到本地数据库,而不仅仅是输出给用户的信息。有了这个,我们的用户可以设置一个调度任务来调用我们的脚本,并根据调度保存数据。

@click.command(help="Displays the values of the sensors")
@click.option(
    "--develop", required=False, metavar="path", help="Load a sensor by Python path"
)
@click.option("--verbose", is_flag=True, help="Show additional info")
@click.option("--save", is_flag=True, help="Store collected data to a database")
@click.option(
    "--db",
    metavar="<CONNECTION_STRING>",
    default="sqlite:///sensor_data.sqlite",
    help="The connection string to a database",
    envvar="APD_SENSORS_DB_URI",
)
def show_sensors(develop: str, verbose: bool, save: bool, db: str) -> None:
    sensors: t.Iterable[Sensor[t.Any]]
    if develop:
        try:
            sensors = [get_sensor_by_path(develop)]
        except UserFacingCLIError as error:
            if verbose:
                tb = traceback.format_exception(type(error), error, error.__traceback__)
                click.echo("".join(tb))
            click.secho(error.message, fg="red", bold=True)
            sys.exit(error.return_code)
    else:
        sensors = get_sensors()

    db_session = None
    if save:
        from sqlalchemy import create_engine
        from sqlalchemy.orm import sessionmaker

        engine = create_engine(db)
        sm = sessionmaker(engine)
        db_session = sm()

    for sensor in sensors:
        click.secho(sensor.title, bold=True)
        try:
            value = sensor.value()
        except DataCollectionError as error:
            if verbose:
                tb = traceback.format_exception(type(error), error, error.__traceback__)
                click.echo("".join(tb))
                continue
            click.echo(error)
        else:
            click.echo(sensor.format(value))
            if save and db_session is not None:
                store_sensor_data(sensor, value, db_session)
                db_session.commit()

        click.echo("")
    sys.exit(ReturnCodes.OK)

Listing 11-19Updated command-line script to add saving of data

这足以确保在出现网络或聚合故障时不会丢失数据;然而,一旦错误条件结束,集成丢失的数据是不够的。

API 和过滤

我们需要更新我们的 API,以便提取过去记录的任何数据。同时,我们可以更新 API,将失败的传感器分离到一个独立的错误列表中,补充我们在本章前面添加的异常处理。

复杂的 API 通常为用户提供指定他们需要哪些数据的能力,通过只计算最终用户需要的信息,允许 API 实现更高效。更常见的是,API 提供某种形式的过滤选项来减少传递的数据量。

我们需要一个新的 API 端点来公开已经收集的数据,以便聚合过程可以将其与数据库同步。这个端点的实现如清单 11-20 所示。

@version.route("/historical")
@version.route("/historical/<start>")
@version.route("/historical/<start>/<end>")
@require_api_key
def historical_values(
    start: str = None, end: str = None
) -> t.Tuple[t.Dict[str, t.Any], int, t.Dict[str, str]]:
    try:
        import dateutil.parser
        from sqlalchemy import create_engine
        from sqlalchemy.orm import sessionmaker
        from apd.sensors.database import sensor_values
        from apd.sensors.wsgi import db
    except ImportError:
        return {"error": "Historical data support is not installed"}, 501, {}

    db_session = db.session
    headers = {"Content-Security-Policy": "default-src 'none'"}

    query = db_session.query(sensor_values)
    if start:
        query = query.filter(
            sensor_values.c.collected_at >= dateutil.parser.parse(start)
        )
    if end:
        query = query.filter(
            sensor_values.c.collected_at <= dateutil.parser.parse(end)
        )

    known_sensors = {sensor.name: sensor for sensor in cli.get_sensors()}
    sensors = []
    for data in query:
        if data.sensor_name not in known_sensors:
            continue
        sensor = known_sensors[data.sensor_name]
        sensor_data = {
            "id": sensor.name,
            "title": sensor.title,
            "value": data.data,
            "human_readable": sensor.format(sensor.from_json_compatible(data.data)),
            "collected_at": data.collected_at.isoformat(),
        }
        sensors.append(sensor_data)
    data = {"sensors": sensors}
    return data, 200, headers

Listing 11-20New historical values endpoint for the v3.0 API

将此信息导入聚合过程的处理程序与普通传感器收集非常相似,因为数据采用相同的格式。可以通过添加一个新的命令行工具来同步一个时间范围内的任何丢失数据,或者通过检测自上次成功数据收集以来的长时间,并使用/historical端点而不是正常端点来实现该过程。

EXERCISE 11-1: Supporting Historical Data Collection

在运行传感器的服务器出现故障的情况下,这一更改不会直接帮助我们。对于我们现有的传感器类型,这是不可能恢复的,但这是我们特定传感器的属性,而不是不可改变的事实。其他传感器可能能够找到某个时间点的值。例如,报告服务器状态的传感器可以从现有的系统日志中提取过去的状态。

考虑需要对代码库进行哪些更改,以支持可以报告它们在过去某个时间点的值的传感器。考虑如何修改现有的类来提供这种额外的功能,使其向后兼容现有的传感器。

和往常一样,本章附带的代码中有一个如何实现这一点的例子。然而,这不会被合并到代码的主分支中,因为它离整理我们当前存储的数据的要求太远了。

摘要

当编写您希望其他开发人员使用的库时,包括自定义异常并在相关时发出警告;这是一种比README.txt文件更有效的与观众交流的方式。特别是,计划任何不推荐使用的功能,并确保在使用旧功能时显示警告。

自定义异常类型允许下游开发人员为特定的错误条件编写处理程序,就像您使用的库中的自定义异常允许您捕捉依赖项中的错误一样。

即使您没有编写一个供其他人使用的库,日志框架也允许您的用户配置他们想要存储的调试信息以及他们想要如何处理它。如果你不提供日志记录语句或者只使用print(...)来记录,它们更有可能被丢弃,而不是作为错误报告返回给你。

虽然这些功能有助于调试和编写代码来处理故障,但编写对错误情况具有鲁棒性的代码的最重要方面是将故障转移设计到流程本身中。

无论您决定使用哪种策略组合,都要确保测试您的代码行为正确。自动化测试能够并且应该验证当事情出错时,而不仅仅是当事情按预期运行时,您的代码以可接受的方式运行。

额外资源

以下链接提供了本章所涵盖主题的额外背景信息:

十二、回调和数据分析

在过去的 11 章中,我们已经编写了两个实用程序来从各种来源收集数据并将它们聚合在一起。我们设计了用于显示汇总数据、从错误状况中恢复以及使最终用户能够根据自己的需求定制流程的每一步的系统。然而,与这些数据交互的唯一方式是在屏幕上查看。没有功能可以在数据到来时主动分析数据并做出相应的反应。

在最后一章中,我们将向聚合过程添加一个新概念,允许我们构建检测输入数据中特定条件的触发器和检测到这些条件时运行的动作。一些潜在有用的是数据的阈值点(如温度高于 18°C,太阳能电池板输出高于 0.5 kW,或 RAM 低于 500MB)。可选地,在两个传感器之间存在相关性,例如一个传感器上的温度与另一个传感器的温度相差超过阈值,或者跨时间的相关性,例如太阳能功率明显多于或少于前一天。

生成器数据流

到目前为止,我们编写的所有分析代码都是被动的;它介于数据源和消费者之间,并在消费者获取数据时修改数据。这些功能都是一个for循环的变体;它们迭代源数据,并可能产生输出。生成器是重构输入和输出都是可迭代的循环的一种很好的方式。

同样的代码可以用几种不同的方式来表达,如理解、修改共享变量的循环或生成函数。例如,我们从 DataPoint 对象获取值的clean_passthrough(...)函数是一个生成器函数,如清单 12-1 所示。

async def clean_passthrough(
    datapoints: t.AsyncIterator[DataPoint],
) -> CLEANED_DT_FLOAT:
    async for datapoint in datapoints:
        if datapoint.data is None:
            continue
        else:
            yield datapoint.collected_at, datapoint.data

Listing 12-1The passthrough cleaner generator function

我们可以通过使用values = [value async for value in clean_passthrough(datapoints)]将数据点的异步迭代器转换成日期和值对的列表。

同样的逻辑可以直接表达为列表理解或操作列表对象的循环。这些在表 12-1 中显示为两种实现。

表 12-1

同一逻辑的理解和循环实现

| `cleaned = [``(datapoint.collected_at, datapoint.data)``async for datapoint in datapoints``if datapoint.data``]` | `results = []``async for datapoint in datapoints:``if datapoint.data is None:``continue``else:``results.append(``datapoint.collected_at, datapoint.data``)` |

关键的区别在于,通过使用生成器函数,我们可以通过函数的名称来引用循环的逻辑。对于理解和标准循环,我们总是根据我们正在处理的数据来定义逻辑。正是这个属性使得 generator 函数成为我们的最佳选择,因为我们需要将对逻辑的引用传递给Config对象的构造函数,而无需提取任何数据。

在任何情况下,我们写的更复杂的函数都不能被理解。它们需要变量来跟踪状态并有条件地执行不同的操作。任何理解都可以重写为生成函数, 1 但并不是所有的生成函数都可以重写为理解。如果你的理解变得过于复杂,你应该考虑将它重构为一个 for 循环或者一个生成器函数。

消耗自身输出的发电机

到目前为止,我们看到的生成器函数模拟了一个for循环。它们有一个数据源作为参数,可以被迭代。生成器函数实现循环的逻辑,函数用它想要处理的源数据调用它。这看起来像清单 12-2 ,它显示了一个简单的生成器函数,用于对一些数字求和。

import typing as t

def sum_ints(source: t.Iterable[int]) -> t.Iterator[int]:
    """Yields a running total from the underlying iterator"""
    total = 0
    for num in source:
        total += num
        yield total

def numbers() -> t.Iterator[int]:
    yield 1
    yield 1
    yield 1

def test():
    sums = sum_ints(numbers())
    assert [a for a in sums] == [1, 2, 3]

Listing 12-2Generator to sum numbers

在这个例子中,numbers()函数用于提供整数的迭代器,而sum_ints(...)函数接受任何整数的迭代器并将它们相加。尽管test()函数负责调用这两个函数并将它们连接在一起,但它只迭代sum_ints(...)的输出。是sum_ints(...)迭代numbers()的输出,而不是test()。这样,数据从numbers()函数流向sum_ints(...)函数再流向test()函数,如图 12-1 所示。

img/481001_1_En_12_Fig1_HTML.jpg

图 12-1

迭代器链的数据流行为

尽管我们可以将任意的 iterable 传递给一个函数进行迭代,但有时我们希望对下一个要处理的数据有更明确的控制。用这种消费生成器的模式最难表达的事情之一是用一个初始值启动一个生成器,然后将它自己的输出作为输入反馈回来(图 12-2 )。

img/481001_1_En_12_Fig2_HTML.jpg

图 12-2

一个迭代器,用初始值处理自己的输出

任何时候我们想要一个生成器来处理自己的输出,我们都必须对它进行编码,而不是使用输入迭代器作为数据源,如清单 12-3 所示。这防止了它被用于除了它自己的输出之外的任何方式。

import itertools
import typing as t

def sum_ints(start: int) -> t.Iterator[int]:
    """Yields a running total with a given start value"""
    total = start
    while True:
        yield total
        total += total

def test():
    sums = sum_ints(1)
    # Limit an infinite iterator to the first 3 items
    # itertools.islice(iterable, [start,] stop, [step])
    sums = itertools.islice(sums, 3)
    assert [a for a in sums] == [1, 2, 4]

Listing 12-3A variant that has only a single start value, then processes its output

有一些真实的用例希望编写既可以在输入流上工作也可以在自己的输出上工作的函数。任何以与输入相同的输出格式返回数据的函数都可以这样写,但是迭代地改进它们的输入的函数是一个很好的选择。

例如,如果我们有一个函数,通过将图像的大小重新调整为其输入大小的 50%来减小图像的大小,我们可以编写一个生成器函数,在给定一个可迭代的图像的情况下,返回一个已调整大小的图像的迭代器。或者,如果我们可以在它自己的输出上使用相同的生成器,我们可以提供一个输入图像,并得到一个相同的初始图像的逐渐变小版本的生成器。

我们定义的新函数不能再像我们最初想要的那样用来添加任意的整数。让sum_ints(...)函数既能处理自己的输出又能处理任意可迭代对象的一种方法是定义一个新的迭代器,它使用闭包在使用生成器的代码和它的函数之间共享状态。

我们可以创建一个返回两个迭代器的函数,一个委托给sum_ints(...)迭代器并保存最新值的副本,另一个迭代器用作sum_ints(...)的输入,它使用第一个函数的共享值。 2 该包装函数的数据流如图 12-3 所示。

img/481001_1_En_12_Fig3_HTML.jpg

图 12-3

使用包装函数生成迭代器的数据流,迭代器在自己的输出上工作

清单 12-4 展示了编写这个包装函数的一种方法。get_wrap_feedback_pair(...)函数提供了两个生成器,它们在test()方法中被用来创建一个版本的sum_ints(...),它有一个已知的初始值,将自己的输出作为输入返回。

import itertools
import typing as t

def sum_ints(source: t.Iterable[int]) -> t.Iterator[int]:
    """Yields a running total from the underlying iterator"""
    total = 0
    for num in source:
        total += num
        yield total

def get_wrap_feedback_pair(initial=None):  # get_w_f_p(...) above
    """Return a pair of external and internal wrap functions"""
    shared_state = initial
    # Note, feedback() and wrap(...) functions assume that
    # they are always in sync
    def feedback():
        while True:
            """Yield the last value of the wrapped iterator"""
            yield shared_state
    def wrap(wrapped):
        """Iterate over an iterable and stash each value"""
        nonlocal shared_state
        for item in wrapped:
            shared_state = item
            yield item
    return feedback, wrap

def test():
    feedback, wrap = get_wrap_feedback_pair(1)
    # Sum the iterable (1, ...) where ... is the results
    # of that iterable, stored with the wrap method
    sums = wrap(sum_ints(feedback()))
    # Limit to 3 items
    sums = itertools.islice(sums, 3)
    assert [a for a in sums] == [1, 2, 4]

Listing 12-4Helper function to feed a generator’s output back as input

现在,sum_ints(...)函数表示应用于循环每一步的逻辑,而get_wrap_feedback_pair(...)编码了生成器的输出和它应该处理的下一个值之间的关系。例如,如果我们想基于输出结果进行数据库查询,并使用它来提供下一个值,我们就需要设计一个新的get_wrap_feedback_pair(...)变体,对输入和输出之间的新关系进行编码。

这种方法让我们更接近于能够从调用函数动态地控制迭代器中的数据流,但是它仍然是有限的。如果我们只想要一个关系,它工作得非常好,但是由于代码是自包含的,调用函数(test(),在我们的例子中)不能影响行为。它依赖包装函数来实现适当的逻辑。

增强型发电机

另一种方法是改变生成器的行为,使用“增强的生成器”语法。 3 这允许数据在每次生成一个项目时被发送到正在运行的生成器中。它仍然相当有限,因为您不能发送比产生的更多的数据,但它确实允许一种更具表现力的定制行为的方式。

到目前为止,我们一直将yield视为 return 语句的替代,但是yield表达式解析为一个可以存储在变量中的值,如received = yield to_send。在正常操作下,received值始终为None,但是可以通过使用send(...)方法推进发生器来改变。这种模式允许生成器函数在每次前进时遍历调用者显式提供的数据。

Enhanced Asynchronous Generators

使用异步生成器对象上的asend(...)协程,相同的执行模型可用于在本地协程中实现的迭代器。除了必须等待之外,这与send(...)方法的行为方式相同。这是需要的,因为异步迭代器在产生新对象时会阻塞,而asend(...)send(...)调用都是请求新对象的特例。

除非底层生成器处于收益状态,否则可能不会等待asend(...)结果。这个调用不涉及同步,所以不能安全地并行调度多个调用。在对同一个生成器发出另一个调用之前,您必须一直等待一个asend(...)调用的结果。因此,很少将此作为一项任务来安排。

没有下一个(...)使发电机前进一位的方法。虽然您可以手动使用await gen.__anext__(),但是我建议您使用 await gen.asend(None)在循环之外推进异步迭代器。

清单 12-5 显示了一个整数求和函数的例子,它从 yield 语句返回值而不是输入 iterable 中接收数据。

import typing as t

def sum_ints() -> t.Generator[int, int, None]:
    """Yields a running total from the underlying iterator"""
    total = 0
    num = yield total
    while True:
        total += num
        num = yield total

def test():
    # Sum the iterable (1, ...) where ... is the results
    # of that iterable, stored with the wrap method
    sums = sum_ints()
    next(sums)  # We can only send to yield lines, so advance to the first
    last = 1
    result = []
    for n in range(3):
        last = sums.send(last)
        result.append(last)
    assert result == [1, 2, 4]

test()

Listing 12-5Sending data to an in-progress generator

Note

发电机的类型定义已从t.Iterable[int]更改为t.Generator[int, int, None]。前者相当于 t.Generator[int,None,None],意思是它产生int s,但是它期望被发送None并返回None作为它的最终值。

在这种情况下,如图 12-4 所示的控制流程要简单得多。这两个函数之间自由传递数据,而不是数据只在一个方向流动,或者通过中间函数循环流动。

img/481001_1_En_12_Fig4_HTML.jpg

图 12-4

使用增强的生成器方法控制流

增强的生成器函数对循环体进行编码,就像标准生成器一样,但是它们更接近于while循环而不是for循环的行为。它不是对一些输入数据进行循环,而是对一个条件进行循环,并在循环过程中接收中间值。

这种方法非常适合有状态函数需要外部指令的情况,比如图像操作。图像编辑增强生成器可以将初始图像作为其输入,然后执行诸如“调整大小”、“旋转”、“裁剪”等命令。命令可以是硬编码的;它们可能来自用户输入或者来自对它输出的最后一个版本的分析。

使用类

增强的生成器可以使用从 yield 语句中获得的值作为下一个要处理的数据,或者作为一个指令来改变它们正在做的事情,或者它们可以混合使用这两者。

用各种指令多次调用并且在调用之间共享状态的代码通常作为一个类来实现。在这种情况下,实例负责存储状态,该类的用户调用不同的方法来通知需要什么代码路径。

任何使用这种方法的代码看起来都比增强的生成器语法更自然。例如,清单 12-6 显示了表示为一个类的相同平均值计算行为。

class MeanFinder:
    def __init__(self):
        self.running_total = 0
        self.num_items = 0

    def add_item(self, num: float):
        self.running_total += num
        self.num_items += 1

    @property
    def mean(self):
        return self.running_total / self.num_items

def test():
    # Recursive mean from initial data
    mean = MeanFinder()
    to_add = 1
    for n in range(3):

        mean.add_item(to_add)
        to_add = mean.mean
    assert mean.mean == 1.0

    # Mean of a concrete data list
    mean = MeanFinder()
    for to_add in [1, 2, 3]:
        mean.add_item(to_add)
    assert mean.mean == 2.0

Listing 12-6Class-based approach for long-running sets of asynchronous code

这种方法特别适合于希望在多个相似的函数之间共享代码的情况,因为类可以被子类化,每个实现可以覆盖单独的方法。然而,开发人员期望类比增强的生成器更少有状态。在事先知道需要多少参数和什么类型的情况下调用对象上的方法是正常的。增强的生成器允许开发人员编写程序,其中接收函数决定从调用函数请求什么数据。这非常适合生成器表示用于整理多段数据并保留中间结果的算法的情况。 4

使用增强的生成器包装可迭代对象

由于yield的结果,我们的增强生成器改变了控制流以期望新的项目,所以我们不能使用增强生成器来代替标准生成器。该方法可用于创建与其调用函数协同工作来处理数据的函数,但它不再可用作另一个 iterable 的简单包装。

为了解决这个问题,我们可以编写一个包装函数,将增强生成器的签名转换为标准生成器函数的签名。然后,我们可以在需要交互控制行为的情况下使用增强的生成器,当我们有一个可迭代输入时使用包装的生成器,如清单 12-7 所示。

import typing as t

input_type = t.TypeVar("input_type")
output_type = t.TypeVar("output_type")

def wrap_enhanced_generator(
    input_generator: t.Callable[[], t.Generator[output_type, input_type, None]]
) -> t.Callable[[t.Iterable[input_type]], t.Iterator[output_type]]:
    underlying = input_generator()
    next(underlying)  # Advance the underlying generator to the first yield

    def inner(data: t.Iterable[input_type]) -> t.Iterator[output_type]:
        for item in data:
            yield underlying.send(item)

    return inner

def sum_ints() -> t.Generator[int, int, None]:
    """Yields a running total from the underlying iterator"""
    total = 0
    num = yield total
    while True:
        total += num
        num = yield total

def numbers() -> t.Iterator[int]:
    yield 1
    yield 1
    yield 1

def test() -> None:
    # Start with 1, feed output back in, limit to 3 items
    recursive_sum = sum_ints()
    next(recursive_sum)
    result = []
    last = 1
    for i in range(3):
        last = recursive_sum.send(last)
        result.append(last)
    assert result == [1, 2, 4]

    # Add 3 items from a standard iterable
    simple_sum = wrap_enhanced_generator(sum_ints)
    result_iter = simple_sum(numbers())
    assert [a for a in result_iter] == [1, 2, 3]

Listing 12-7An enhanced generator that can be used as a standard generator

这种方法允许我们定义一个增强的生成器函数来定义流程中单个步骤的逻辑,然后使用该逻辑作为迭代器的包装器或者处理它自己的输出。图 12-5 显示了循环输入迭代时使用的数据流。

img/481001_1_En_12_Fig5_HTML.jpg

图 12-5

包裹增强生成器的控制流

重构返回值过多的函数

任何增强的生成器也可以写成一系列函数,只要每次调用都传递所有必需的中间值。所有需要参数的函数都在有效地共享状态,只是以一种比通常更明确的方式。

复杂的程序结构不太适合这种习惯用法,所以我不建议重写一个增强的生成器来使用协程。如果您在一个循环中看到一组函数,其中一个函数的返回值被立即传递给另一个函数调用,而没有被使用,那么这可能是重构的一个很好的候选。

清单 12-8 演示了一对函数来计算一系列数字的平均值。mean_ints_split_initial()函数提供了一些初始值,调用函数将这些初始值和要添加的新数字一起传递给mean_ints_split(...)mean_ints_split(...)函数接受三个参数并返回两个值,但是调用函数只关心一个参数和一个值。

import typing as t

def mean_ints_split_initial() -> t.Tuple[float, int]:
    return 0.0, 0

def mean_ints_split(
    to_add: float, current_mean: float, num_items: int
) -> t.Tuple[float, int]:
    running_total = current_mean * num_items
    running_total += to_add
    num_items += 1
    current_mean = running_total / num_items
    return current_mean, num_items

def test():
    # Recursive mean from initial data
    to_add, current_mean, num_items = mean_ints_split_initial()
    for n in range(3):
        current_mean, num_items = mean_ints_split(to_add, current_mean, num_items)
        to_add = current_mean
    assert current_mean == 1.0
    assert num_items == 3

    # Mean of concrete data list
    current_mean = num_items = 0
    for to_add in [1, 2, 3]:
        current_mean, num_items = mean_ints_split(to_add, current_mean, num_items)
    assert current_mean == 2.0
    assert num_items == 3

Listing 12-8Code to find the average of some numbers expressed as bare functions

这里传递的num_items值只与mean_ints_split(...)的实现相关;对调用函数没用。如果开发人员可以实例化一个新的均值计算,然后传入数字并访问修改后的均值,而不需要每次都传递额外的上下文数据,那么 API 会更加简单。这是增强生成器的另一个好用途,代码如清单 12-9 所示。

import typing as t

def mean_ints() -> t.Generator[t.Optional[float], float, None]:
    running_total = 0.0
    num_items = 0
    to_add = yield None
    while True:
        running_total += to_add
        num_items += 1
        to_add = yield running_total / num_items

def test():
    # Recursive mean from initial data
    mean = mean_ints()
    next(mean)
    to_add = 1
    for n in range(3):
        current_mean = mean.send(to_add)
        to_add = current_mean
    assert current_mean == 1.0

    # Mean of a concrete data list
    # wrap_enhanced_generator would also work here
    mean = mean_ints()
    next(mean)
    for to_add in [1, 2, 3]:
        current_mean = mean.send(to_add)
    assert current_mean == 2.0

Listing 12-9Simplified mean calculation using an enhanced generator

如果您发现自己有一个被多次调用的协程,并且每次都被传递上一次调用的结果,那么它就非常适合于增强的生成器。

行列

到目前为止,我们看到的所有方法都假设不需要将数据从多个来源推送到迭代器。如前所述,如果另一个线程或任务试图在数据准备好之前发送数据,生成器会引发异常,这需要使用复杂的锁来防止。同样,我们不能向生成器发送数据,除非我们也提取一段数据。如果多个函数试图发送数据,那么它们必然也是提取数据,并且需要协调以确保正确的函数获得任何预期供其使用的数据。

更好的方法是使用一个Queue对象。在关于线程化的章节中,我们将这些作为将工作传递给线程的解决方案,但是 asyncio 模块提供了一个Queue实现,它以类似的方式为异步 Python 工作。具体来说,任何可以阻塞标准队列中线程的方法都适用于 asyncio 队列。清单 12-10 展示了使用队列的sum_ints(...)函数的实现。

import asyncio
import itertools
import typing as t

async def sum_ints(data: asyncio.Queue) -> t.AsyncIterator[int]:
    """Yields a running total a queue, until a None is found"""
    total = 0
    while True:
        num = await data.get()
        if num is None:
            data.task_done()
            break
        total += num
        data.task_done()
        yield total

def numbers() -> t.Iterator[int]:
    yield 1
    yield 1
    yield 1

async def test():
    # Start with 1, feed output back in, limit to 3 items
    data = asyncio.Queue()
    sums = sum_ints(data)

    # Send the initial value
    await data.put(1)
    result = []
    async for last in sums:
        if len(result) == 3:
            # Stop the summer at 3 items
            await data.put(None)
        else:
            # Send the last value retrieved back
            await data.put(last)
            result.append(last)
    assert result == [1, 2, 4]

    # Add 3 items from a standard iterable
    data = asyncio.Queue()
    sums = sum_ints(data)

    for number in numbers():
        await data.put(number)
    await data.put(None)
    result = [value async for value in sums]
    assert result == [1, 2, 3]

Listing 12-10Sending work to a coroutine with a queue

这种队列方法非常类似于使用一对包装函数的方法,如果我们比较图 12-3 和 12-6 就可以看出这一点。主要区别在于添加到队列中的值完全由包含的test()函数决定。

img/481001_1_En_12_Fig6_HTML.jpg

图 12-6

使用队列时的执行流

队列纯粹是数据的管道;对于数据应该来自哪里,它没有特定于应用的逻辑。对于基于线程的队列使用,我推荐使用一个 sentinel 值 5 来告诉协程何时结束,因为这样更容易清理迭代器。

选择控制流

我很少使用增强的生成器方法,因为通常有一些方法可以用更常用的 Python 控制结构来解决这个问题,比如类和队列。我发现这一点更清楚,但增强的生成器非常值得了解,以防您遇到特别适合它们的问题。

图 12-7 中的决策树图示了我决定使用什么结构的过程。与本书中的其他决策树不同,这种选择很大程度上取决于美学和可读性。该图表将帮助您找到自然的匹配,但是您很可能会做出不同的决定,因为您认为这将提高可维护性。

img/481001_1_En_12_Fig7_HTML.jpg

图 12-7

不同控制流的决策树

我们行动的结构

我们需要为触发器和动作选择一种传递数据的方法。动作事先没有可用的数据,而是通过单个调用函数传递数据。我们将把它们实现为具有处理特定点的方法的类。

触发器更难设计。他们可能需要在数据点检查之间存储状态。我们希望从数据库中加载数据,所以我们可以创建一个异步迭代器,它执行数据库查询并产生结果,每当到达迭代器的末尾时进行更多的数据库查询,直到有更多的数据可用。在这种情况下,我们将提前获得数据,因为我们将有一个迭代器对象,我们相信它包含所有必要的数据。因此,我们选择将触发器实现为一个包装另一个的迭代器。

然而,还有另一个潜在的有用数据源:动作。例如,我们可能有一个触发器对象,它比较“产生的能量”和“使用的能量”来产生一个“购买的能量”值。我们不希望将这个值添加到数据库中,因为它只是另外两个数据点的差,而不是测量值,但是如果它太高或异常高,我们可能希望创建警报。

我们可以编写PowerUsedTooHighPowerUsedHigherThanUsual触发器,但是这些会非常具体,并且共享许多相同的代码。如果能写一个DifferenceBetweenSensors触发器以及ValueTooHighValueHigherThanUsual助手就更好了。这将允许用户用任何一对传感器组成逻辑,但我们需要一种方法将DifferenceBetweenSensors的输出发送到ValueTooHighValueHigherThanUsual堆栈。

如果数据点可以来自数据库或动作的行为,那么我们不能认为数据源是预先可用的,必须在决策树的第一个问题上采取右边的路径。数据源是将整理后的数据传递给触发器的函数,这意味着我们应该遵循左边的路径。因此,触发器将作为类来实现。

最后,我们希望允许用户将触发器和动作组合到管道中。像触发器一样,这些对象没有预先可用的数据,但与触发器不同,它们从多个地方接收数据。这个功能负责接收来自数据库的数据和来自操作的数据,所以它是基于队列的。

总之,我们的分析代码有ActionTriggerDataProcessorActionTrigger都是从单个位置传递的数据,所以它们都被实现为类。可以从多个来源接收数据,并负责将其传递给触发器和动作,因此它们使用队列来接收数据。

分析协程

为了允许用户动态地编写动作和触发器,我们提供了一个代表已配置管道的DataProcessor类(清单 12-11 )。该类负责为该流程的所有数据设置输入队列,并为启动各种所需任务提供一个更简单的 API。

@dataclasses.dataclass
class DataProcessor:
    name: str
    action: Action
    trigger: Trigger[t.Any]

    def __post_init__(self):
        self._input: t.Optional[asyncio.Queue[DataPoint]] = None
        self._sub_tasks: t.Set = set()

    async def start(self) -> None:
        self._input = asyncio.Queue()
        self._task = asyncio.create_task(self.process(), name=f"{self.name}_process")
        await asyncio.gather(self.action.start(), self.trigger.start())

    @property
    def input(self) -> asyncio.Queue[DataPoint]:
        if self._input is None:
            raise RuntimeError(f"{self}.start() was not awaited")
        if self._task.done():
            raise RuntimeError("Processing has stopped") from (self._task.exception())
        return self._input

    async def idle(self) -> None:
        await self.input.join()

    async def end(self) -> None:
        self._task.cancel()

    async def push(self, obj: DataPoint) -> None:
        return await self.input.put(obj)

    async def process(self) -> None:
        while True:
            data = await self.input.get()
            try:
                processed = await self.trigger.handle(data)
            except ValueError:
                continue
            else:
                action_taken = await self.action.handle(processed)
            finally:
                self.input.task_done()

Listing 12-11A class to represent a configured trigger and action pair

idle()方法委托给队列的join()方法,该方法一直阻塞,直到task_done()被调用的次数与等待get()的次数相同。因此,await processor.idle()会阻塞,直到没有项目等待处理。这种方法对于编写测试代码特别有用,因为它允许我们在开始断言预期的动作被执行之前,确保处理器已经完成处理。

在原始数据源与触发器和操作之间添加一个队列,使我们能够保证数据总是按顺序处理,并且故障不会阻碍其他任务接收数据的能力。我们只能以最慢的触发器处理数据的速度将数据输入到一组触发器中,除非我们允许它们积累大量数据进行处理。

允许积压工作建立的问题是,我们会发现自己使用越来越多的内存来存储较慢任务的任务。idle()方法在这里可能很有用,因为它允许我们定期阻塞接收协程,所以积压只能暂时累积,必须在接收更多数据之前清除。或者,我们可以为输入队列定义一个最大长度,当单个传感器的积压太长时,它会暂时停止接收。

有了数据处理器,我们还可以定义触发器和动作组件的基类来匹配它的行为,如清单 12-12 所示。

import typing as t

from ..typing import T_value
from ..database import DataPoint
from ..exceptions import NoDataForTrigger

class Trigger(t.Generic[T_value]):
    name: str

    async def start(self) -> None:
        """ Coroutine to do any initial setup """
        return

    async def match(self, datapoint: DataPoint) -> bool:
        """ Return True if the datapoint is of interest to this
        trigger.
        This is an optional method, called by the default implementation
        of handle(...)."""
        raise NotImplementedError

    async def extract(self, datapoint: DataPoint) -> T_value:
        """ Return the value that this datapoint implies for this trigger,
        or raise NoDataForTrigger if no value is appropriate.
        Can also raise IncompatibleTriggerError if the value is not readable.

        This is an optional method, called by the default implementation
        of handle(...).
        """
        raise NotImplementedError

    async def handle(self, datapoint: DataPoint) -> t.Optional[DataPoint]:
        """Given a data point, optionally return a datapoint that
        represents the value of this trigger. Will delegate to the
        match(...) and extract(...) functions."""
        if not await self.match(datapoint):
            # This data point isn't relevant
            return None

        try:
            value = await self.extract

(datapoint)
        except NoDataForTrigger:
            # There was no value for this point
            return None

        return DataPoint(
            sensor_name=self.name,
            data=value,
            deployment_id=datapoint.deployment_id,
            collected_at=datapoint.collected_at,
        )

class Action:
    async def start(self) -> None:
        return

    async def handle(self, datapoint: DataPoint):
        raise NotImplementedError

Listing 12-12Base classes for the Trigger and Action components

这两个对象有一个允许初始启动动作的start()协程和一个接受并处理DataPoint对象的handle(...)方法。在Trigger的情况下,handle(…)方法检查传递的数据点是否与触发器相关,如果是,它返回一个新的数据点,数据由extract(...)方法指定。对于一个Actionhandle(...)协程返回一个布尔值,表示是否采取了一个动作。它还具有特定于处理程序的副作用,例如数据库访问。

要创建的一个好的第一个触发器是将一个DataPoint的值与一个阈值进行比较,如清单 12-13 所示。例如,这可以用来发现过高的温度。由于ValueThresholdTrigger类是一个相当复杂的类,接受许多参数,所以数据类功能对于确保它有适当的标准方法是有用的,比如__init__(...)

import dataclasses
import typing as t
import uuid

from ..database import DataPoint
from ..exceptions import IncompatibleTriggerError
from .base import Trigger

@dataclasses.dataclass(frozen=True)
class ValueThresholdTrigger(Trigger[bool]):
    name: str
    threshold: float
    comparator: t.Callable[[float, float], bool]
    sensor_name: str
    deployment_id: t.Optional[uuid.UUID] = dataclasses.field(default=None)

    async def match(self, datapoint: DataPoint) -> bool:
        if datapoint.sensor_name != self.sensor_name:
            return False
        elif (self.deployment_id and datapoint.deployment_id != self.deployment_id):
            return False
        return True

    async def extract(self, datapoint: DataPoint) -> bool:
        if datapoint.data is None:
            raise IncompatibleTriggerError("Datapoint does not contain data")
        elif isinstance(datapoint.data, float):
            value = datapoint.data
        elif (isinstance(datapoint.data, dict) and "magnitude" in datapoint.data):
            value = datapoint.data["magnitude"]
        else:
            raise IncompatibleTriggerError("Unrecognised data format")
        return self.comparator(value, self.threshold)  # type: ignore

Listing 12-13A trigger to check for a value having a certain relationship to a prespecified value

控制阈值检查的两个参数是comparator=threshold=参数。threshold是一个浮点数,comparator=是一个接受两个浮点数并返回一个布尔值的函数。

有效比较器的一个例子是lambda x, y: x > y,但是在operator模块中有一些内置的标准比较版本。 6 设定comparator=operator.gt可能更露骨一点,我更喜欢。你应该使用任何你觉得更自然的风格。

我们还需要至少一个基本的Action实现,最简单有用的一个是调用 webhook 通知外部服务温度过高的动作。清单 12-14 显示了这方面的实现。

@dataclasses.dataclass
class WebhookAction(Action):
    """An action that runs a webhook"""
    uri: str

    async def start(self) -> None:
        return

    async def handle(self, datapoint: DataPoint) -> bool:
        async with aiohttp.ClientSession() as http:
            async with http.post(
                self.uri,
                json={
                    "value1": datapoint.sensor_name,
                    "value2": str(datapoint.data),
                    "value3": datapoint.deployment_id.hex,
                },
            ) as request:
                logger.info(
                    f"Made webhook request for {datapoint} with status " f"{request.status}"
                )
                return request.status == 200

Listing 12-14An action that calls a webhook, using the format expected by the IFTTT service

另一个有用的操作是记录发送的任何数据点。虽然这对于生产没有太大的帮助,但是作为调试管道的一种方式,它是无价的。这让我们可以看到工具在终端中正在做什么;实现它的代码在清单 12-15 中。

class LoggingAction(Action):
    """An action that stores any generated data points back to the DB"""

    async def start(self) -> None:
        return

    async def handle(self, datapoint: DataPoint) -> bool:
        logger.warn(datapoint)
        return True

Listing 12-15Action handler that logs to the standard error stream

本章附带的代码包括一些额外的触发器和动作,在您阅读本文时,apd.aggregation的发布版本可能会包括更多。

摄取数据

我们希望运行许多并发的触发器和动作集,所以我们将使用一个长时间运行的协程作为多个子任务的控制器。这个协程管理触发器和动作的设置,并将数据交给每个子任务。

长时间运行的协程的行为与长时间运行的线程有很大不同,尤其是在它们如何终止方面。当我们查看长时间运行的线程时,我们需要创建一种方法来指示线程,不再有数据需要它处理,它应该结束了。增强型迭代器也是如此,我们对基于队列的协程和函数使用了相同的模式,其中发送一个 sentinel 值是停止处理任务的唯一方式。

作为任务调度的协程使这变得更容易,因为它们有一个cancel()方法。cancel()方法允许开发人员停止一个任务,而不需要添加一个方法来要求它自己停止。这对于协程长时间运行的系统设计特别有用,因为它允许我们干净地关闭程序中不再需要的部分。协程已经启动的任何任务也会被取消,除非它们在第一次创建时用asyncio.shield(...)包装。还可以使用 try/finally 块编写一个协程,它可以干净地从请求的取消中关闭。取消是通过在协程代码中引发一个CancelledError异常来实现的,这个异常可以被捕获,终结代码在结束前运行。

现在有了初始行为集的处理程序,但是我们需要一种方法将数据推入这个过程。我们已经有了一个从数据库加载数据并异步迭代的函数;我们可以通过将它放在一个无限循环中来补充这一点,一旦第一次迭代结束,这个无限循环就会搜索任何额外的数据,如清单 12-16 所示。

import asyncio

from apd.aggregation.query import db_session_var, get_data

async def get_data_ongoing(*args, **kwargs):
    last_id = 0
    db_session = db_session_var.get()
    while True:
        # Run a timer for 300 seconds concurrently with our work
        minimum_loop_timer = asyncio.create_task(asyncio.sleep(300))
        async for datapoint in get_data(*args, **kwargs):
            if datapoint.id > last_id:
                # This is the newest datapoint we have handled so far
                last_id = datapoint.id
            yield datapoint
            # Next time, find only data points later than the latest we've # seen
            kwargs["inserted_after_record_id"] = last_id
        # Commit the DB to store any work that was done in this loop and
        # ensure that any isolation level issues do not prevent loading more
        # data
        db_session.commit()
        # Wait for that timer to complete. If our loop took over 5 minutes
        # this will complete immediately, otherwise it will block
        await minimum_loop_timer

Listing 12-16A version of get_data(...) that may block for new data while iterating

Tip

这使用了 asyncio.sleep(...)以确保循环迭代之间的最小时间。如果我们在循环结束时直接等待 asyncio.sleep(300 ),那么迭代之间至少会有 300 秒,但也可能更多。在循环开始时将此任务委托给一个任务,然后等待任务完成,这意味着我们的 300 秒等待与循环体中执行的生产性工作并行运行。通过对当前时间进行算术运算来计算每次循环迭代所需的延迟,可以获得相同的效果,但这要清楚得多。

这里的实现在每个数据库查询之间有一个静态延迟。这不是最有效的方法,因为它在数据检查之间引入了一个固定的周期,因此可能需要 5 分钟才能获得新数据。我们可以减少迭代之间的时间,但这意味着相应地增加了数据库服务器的负载。这种方法称为短轮询,因为它定期发出一个短请求来检查更多数据。长轮询更有效,因为它涉及到直到有数据可用时才完成的请求,但是它要求后端和接口库支持它。短轮询是最兼容的方法,因此在没有证据表明它太低效的情况下,它是一个很好的默认方法。

Postgres Pubsub

如果我们使用一个提供 pubsub, 7 的数据库,我们可以完全避免轮询,并重新编写它来监听数据聚合过程发送的通知主题。

PostgreSQL pubsub 功能是通过 LISTEN 和 NOTIFY 命令启用的。SQLAlchemy 并没有紧密集成这个功能,但是底层的连接库支持它,所以如果它对我们有用,我们可以利用它。

如果连接的数据库是 PostgreSQL,我们将首先修改 CLI 以在添加新数据后发送通知:

    if "postgresql" in db_uri:
        # On Postgres sent a pubsub notification, in case other processes are
        # waiting for this data
        Session.execute("NOTIFY apd_aggregation;")

接下来,我们将创建一个替代的get_data_ongoing(...)实现来寻找通知。该函数必须调用Session.execute("LISTEN apd_aggregation;")来确保连接正在接收相关主题的通知。

因为我们没有使用完全异步的 PostgreSQL 库,所以我们不能只await一个通知,所以我们必须创建一个合适的 shim 函数,处理从数据库连接读取通知。

async def wait_for_notify(loop, raw_connection):
    waiting = True
    while waiting:
        # The database connection isn't asynchronous, poll in a new thread
        # to make sure we've received any notifications
        await loop.run_in_executor(None, raw_connection.poll)
        while raw_connection.notifies:
            # End the loop after clearing out all pending
            # notifications
            waiting = False
            raw_connection.notifies.pop()
        if waiting:
            # If we had no notifications wait 15 seconds then
            # re-check
            await asyncio.sleep(15)

这仍然需要主动检查数据库状态,但是poll()函数不进行数据库查询,所以它是一个轻量级得多的解决方案。数据库负载的减少使得将检查间隔时间从几分钟减少到几秒钟变得更加高效。

运行分析流程

完成这个特性的最后一个部分是编写一个新的命令行实用程序来运行处理。这个实用程序负责建立数据库连接,加载用户的配置,将用户定义的处理程序连接到数据库的信息提要,然后启动长期运行的协程。

清单 12-17 显示了一个新的 click 命令,它获取一个基于 python 的配置文件和一个数据库连接字符串的路径,并执行该文件中的所有数据处理器。

import asyncio
import importlib.util
import logging
import typing as t

import click

from .actions.runner import DataProcessor
from .actions.source import get_data_ongoing
from .query import with_database

logger = logging.getLogger(__name__)

def load_handler_config(path: str) -> t.List[DataProcessor]:
    # Create a module called user_config backed by the file specified, and # load it
    # This uses Python's import internals to fake a module in a known # location
    # Based on an StackOverflow answer by Sebastian Rittau and sample code
    # from Brett Cannon
    module_spec = importlib.util.spec_from_file_location("user_config", path)
    module = importlib.util.module_from_spec(module_spec)
    module_spec.loader.exec_module(module)
    return module.handlers

@click.command()
@click.argument("config", nargs=1)
@click.option(
    "--db",
    metavar="<CONNECTION_STRING>",
    default="postgresql+psycopg2://localhost/apd",
    help="The connection string to a PostgreSQL database",
    envvar="APD_DB_URI",

)
@click.option("-v", "--verbose", is_flag=True, help="Enables verbose mode")
def run_actions(config: str, db: str, verbose: bool) -> t.Optional[int]:
    """This runs the long-running action processors defined in a config file.

    The configuration file specified should be a Python file that defines a
    list of DataProcessor objects called processors.n
    """
    logging.basicConfig(level=logging.DEBUG if verbose else logging.WARN)

    async def main_loop():
        with with_database(db):
            logger.info("Loading configuration")
            handlers = load_handler_config(config)

            logger.info(f"Configured {len(handlers)} handlers")
            starters = [handler.start() for handler in handlers]
            await asyncio.gather(*starters)

            logger.info(f"Ingesting data")
            data = get_data_ongoing()
            async for datapoint in data:
                for handler in handlers:
                    await handler.push(datapoint)

    asyncio.run(main_loop())
    return True

Listing 12-17Command-line tool to run the management pipeline

我们这里使用的配置文件是一个 Python 文件,由load_handler_config(...)函数显式加载。该工具的配置包括组合不同的 Python 类、lambda 函数和其他可调用程序,因此它不适合非技术最终用户直接编辑。我们本来可以创建一个提供这些选项的配置文件格式,但是至少现在,基于 Python 的配置已经足够了。清单 12-18 显示了这个配置文件的一个例子。

import operator

from apd.aggregation.actions.action import (
    OnlyOnChangeActionWrapper,
    LoggingAction,
)
from apd.aggregation.actions.runner import DataProcessor
from apd.aggregation.actions.trigger import ValueThresholdTrigger

handlers = [
    DataProcessor(
        name="TemperatureBelow18",
        action=OnlyOnChangeActionWrapper(LoggingAction()),
        trigger=ValueThresholdTrigger(
            name="TemperatureBelow18",
            threshold=18,
            comparator=operator.lt,
            sensor_name="Temperature",
        ),
    )
] 

Listing 12-18A config file that uses a variety of actions and handlers from the accompanying code

流程状态

长时间运行的流程可能很难监控。向用户显示此类进程状态的最常见方式是显示进度条,但这只有在我们事先知道要处理的数据量的情况下才有效。我们的系统被特别设计成无限期运行,等待新的数据。即使没有数据等待处理,我们也不是 100%完成,因为我们有理由期待更多的数据即将到来。

更合适的方法是收集正在进行的工作的统计数据,并将它们显示给用户。我们可以跟踪每个数据处理器读取的数据点的总数和它的操作成功处理的总数,以及所用时间的滚动平均值。这三项允许我们生成有用的统计数据(清单 12-19 ),让最终用户很好地了解每个处理程序的效率。

@dataclasses.dataclass
class DataProcessor:
    name: str
    action: Action
    trigger: Trigger[t.Any]

    def __post_init__(self):
        self._input: t.Optional[asyncio.Queue[DataPoint]] = None
        self._sub_tasks: t.Set = set()
        self.last_times = collections.deque(maxlen=10)
        self.total_in = 0
        self.total_out = 0

    async def process(self) -> None:
        while True:
            data = await self.input.get()
            start = time.time()
            self.total_in += 1
            try:
                processed = await self.trigger.handle(data)
            except ValueError:

                continue
            else:
                action_taken = await self.action.handle(processed)
                if action_taken:
                    elapsed = time.time() - start
                    self.total_out += 1
                    self.last_times.append(elapsed)
            finally:
                self.input.task_done()

    def stats(self) -> str:
        if self.last_times:
            avr_time = sum(self.last_times) / len(self.last_times)
        elif self.total_in:
            avr_time = 0
        else:
            return "Not yet started"
        return (
            f"{avr_time:0.3f} seconds per item. {self.total_in} in, "
            f"{self.total_out} out, {self.input.qsize()} waiting."
        )

Listing 12-19A data processor that generates statistics as it’s used

确定何时在类 UNIX 系统上显示统计信息的标准方法是注册一个返回信息的信号处理程序。信号是进程被告知各种操作系统事件的方式,例如,当用户按下<CTRL+c>时。不是所有的平台都支持相同的信号集,所以通常不同的信号会在不同的操作系统上使用。

对于提供请求统计数据信号的操作系统(称为SIGINFO),我们应该确保程序做出适当的反应。为了实现这一点,我们用一个函数来更新 CLI 工具,以迭代数据处理器并将它们的统计数据输出给用户,如清单 12-20 所示。

import signal
def stats_signal_handler(sig, frame, data_processors=None):
    for data_processor in data_processors:
        click.echo(
            click.style(data_processor.name, bold=True, fg="red") + " " + data_processor.stats()
        )
    return

signal_handler = functools.partial(stats_signal_handler, data_processors=handlers)
signal.signal(signal.SIGINFO, signal_handler)

Listing 12-20Example of a statistics signal handler

使用signal.signal(...)函数注册一个信号处理程序,该函数接受一个信号号和一个处理程序。处理程序必须是一个接受两个参数的函数:正在处理的信号和接收信号时正在执行的帧。

Note

信号值是一个整数,但是如果你运行print(signal.SIGINT)(例如),你会看到Signals.SIGINT。这是因为它是用一个Enum对象实现的。我们在第四章中用IntEnum创建了返回代码结构,所以这是相当熟悉的。有几个Enum的变种可用;最有趣的是Flag。这进一步扩展了Enum,允许项目的按位组合,比如Constants.ONE | Constants.TWO

SIGINFO信号只在基于 BSD Unix 操作系统的操作系统上可用,比如 FreeBSD 和 macOS。 8 查看程序输出时,按下<CTRL+t>键,该键升起。该处理程序拦截兼容操作系统上对<CTRL+t>的任何使用,并触发统计数据的显示。在 Linux 系统上,SIGINFO不可用,通常使用SIGUSR1,可以使用kill命令发送:

kill -SIGUSR1 pid

这个信号用处不大,因为不可能用组合键生成,但它是一个标准,所以我们也应该支持它。Windows 没有提供请求状态更新的信号,所以我们选择了<CTRL+c>处理程序 9<CTRL+c>的新行为是在第一次按下它时打印统计数据,然后第二次快速连续按下会导致程序结束。我们将通过创建一个信号处理程序来实现这一点,这个信号处理程序会自动复位,并安排一个任务稍后重新连接这个处理程序(清单 12-21 )。

def stats_signal_handler(sig, frame, original_sigint_handler=None, data_processors=None):
    for data_processor in data_processors:
        click.echo(
            click.style(data_processor.name, bold=True, fg="red") + " " + data_processor.stats()
        )
    if sig == signal.SIGINT:
        click.secho("Press Ctrl+C again to end the process", bold=True)
        handler = signal.getsignal(signal.SIGINT)
        signal.signal(signal.SIGINT, original_sigint_handler)
        asyncio.get_running_loop().call_later(5, install_ctrl_c_signal_handler, handler)
    return

def install_ctrl_c_signal_handler(signal_handler):
    click.secho("Press Ctrl+C to view statistics", bold=True)
    signal.signal(signal.SIGINT, signal_handler)

def install_signal_handlers(running_data_processors):
    original_sigint_handler = signal.getsignal(signal.SIGINT)
    signal_handler = functools.partial(
        stats_signal_handler,
        data_processors=running_data_processors,
        original_sigint_handler=original_sigint_handler,
    )

    for signal_name in "SIGINFO", "SIGUSR1", "SIGINT":
        try:
            signal.signal(signal.Signals[signal_name], signal_handler)
        except KeyError:
            pass

Listing 12-21Signal handler functions to show statistics

这使用当前事件循环的loop.call_later(...)方法来恢复信号处理程序。这个方法调度一个等待给定时间的新任务,然后调用一个函数。被调用的函数不是一个等待的协程,而是一个标准函数,所以它不能用于任何可能阻塞的东西。

这个方法和loop.call_soon(...)的目的是允许异步代码调度回调,而不必先将它们包装在一个协程中,然后作为一个任务来调度。

Caution

signal.signal(...)注册的信号处理器在收到信号后立即运行,中断任何并发的 asyncio 进程。重要的是,任何处理程序都要尽量减少与程序其他部分的交互,因为这可能会导致不确定的行为。有一个loop.add_signal_handler(...)函数和signal.signal(...)有相同的签名,但是保证信号处理器在安全的情况下被调用一次。并非所有的事件循环实现都支持这一点:例如,这个方法在 Microsoft Windows 上不工作。如果您需要 Windows 兼容性,您必须确保您的信号处理程序不会干扰您的异步任务。

回收

这种定义函数并将它们传递给其他函数的方法已经被我们用作图表配置对象的一部分。对于分析程序,我们使用HandlerAction对象,它们维护状态并有多个可调用的方法。另一方面,我们定义了clean(...)get_data(...)draw(...)函数,而不是这三个函数的自定义类。

例如,我们可以创建一个只有一个clean(...)方法的Cleaner对象,而不是传递一个函数。只要只需要一个可调用函数,使用函数而不是类就没有什么特别的好处。

传递函数的一个非常常见的用例是实现回调。回调是一个函数,用于挂钩到中间函数中的事件。我们传递给图表配置的三个函数是图表功能的核心,不是回调函数。

真正的回调函数对正在运行的函数没有影响,只有外部的副作用。例如,plot_sensor(...)方法检查特定部署对于给定传感器没有点的情况,如果图例为空,则跳过将该传感器添加到图例。我们可以想象,当这种情况发生时,我们希望挂钩到这一点来告诉用户,因为在过滤视图时,看到不同数量的部署可能会令人困惑。发生这种情况时调用的函数就是回调函数的一个例子。

我们可以通过在这个方法的签名中添加一个 log_skipped 回调函数来实现这一点,这个函数会传递一条消息给用户。该消息将被添加如下:

if log_skipped:
    log_skipped(f"No points for {name} in {config.title} chart")

然后,该函数可以将任意数量的不同调用作为log_skipped=传递,以定制如何通知用户。例如,它可以被打印到屏幕上,它可以被制作成日志消息,或者它可以被附加到列表中以在其他地方显示。

plot_sensor(config, plot, location_names, *args, log_skipped=print, **kwargs)
plot_sensor(config, plot, location_names, *args, log_skipped=logger.info, **kwargs)

messages = []
plot_sensor(config, plot, location_names, *args, log_skipped=messages.append, **kwargs)

这并不是说回调实现了不重要的功能,但是它们从来都不是触发它们的函数的核心功能。延迟后重置我们的信号处理程序是应用的核心功能,但它是事件循环工作的附带内容,因此也被视为回调。

回调是核心功能一部分的另一个例子是我们的process(...)方法。我们没有并行调度动作,这样我们可以确保它们按顺序发生,但是如果我们将动作调度为任务,那么我们就会在任务完成之前进入下一个循环迭代。这使得记录完成每个动作所花费的时间变得不可能。

清单 12-22 展示了一种处理这种情况的方法,即向完成时运行的任务添加一个回调。等待任务的时间并不重要;任务完成后,回调会很快运行。

    def action_complete(self, start, task):
        action_taken = task.result()
        if action_taken:
            elapsed = time.time() - start
            self.total_out += 1
            self.last_times.append(elapsed)
        self.input.task_done()

    async def process(self) -> None:
        while True:
            data = await self.input.get()
            start = time.time()
            self.total_in += 1
            try:
                processed = await self.trigger.handle(data)
            except ValueError:
                self.input.task_done()
                continue
            else:
                result = asyncio.create_task(self.action.handle(processed))
                result.add_done_callback(functools.partial(self.action_complete, start))

Listing 12-22Example of using a callback to record the time taken for a task

通过将handle(...)协程包装在另一个收集相关统计数据的协程中,也可以在没有add_done_callback(...)的情况下实现这一点,但这在很大程度上是一个风格问题。通过包装协程,可以更清楚地重写用 asyncio 回调可以实现的大多数事情。除了阻塞代码与 asyncio 框架的低级集成之外,任务回调很少是最好的方法,但有时它会很有用。

我们不会应用这两个更改:我们不想失去任何按日期顺序处理操作的保证,因为它可能会使最终用户对无序通知感到困惑。

扩展可用的操作

我们可用的动作和触发器是演示的合理基础,但它们不足以满足现实世界的用户需求。尽管我们可以按原样发布软件,但通过进一步构建一些我们期望真实用户需要的东西,我们更容易找到实现中的痛点。

Exercise 12-1: A Trigger That Subtracts Two Sensor Values

在本章前面,我们说过比较同一传感器的两种部署会很有用。例如,如果房屋楼上的湿度明显高于楼下的湿度,则表明最近使用了淋浴。这不是仅仅通过设定楼上传感器的阈值就可以检测到的,并且不太可能出现误报。

编写一个新的处理程序,比较同一传感器的两个部署,并返回两个值之间的差异。本章的代码中有一个分支点,它提供了一个很好的起点,一个更新的get_data(...)方法不会不适当地对数据进行排序。

一旦我们有了一个计算两个传感器之间差异的触发器,我们就可以创建功能来允许Action将触发器的输出传递回所有DataProcessor的集合以进行重新分析。通过这种方式,我们从本章开始就合并了两种数据处理方法,我们处理从数据库查询的可迭代数据,但偶尔也会处理过程本身的输出。我们可以使用另一个Queue对象来表示我们想要传递回处理程序的短暂数据点。get_data_ongoing(...)函数(清单 12-23 )也将从这个队列中提取数据,而不仅仅是数据库。

import asyncio
from contextvars import ContextVar

from apd.aggregation.query import db_session_var, get_data

refeed_queue_var = ContextVar("refeed_queue")

async def queue_as_iterator(queue):
    while not queue.empty():
        yield queue.get_nowait()

async def get_data_ongoing(*args, historical=False, **kwargs):
    last_id = 0
    if not historical:
        kwargs["inserted_after_record_id"] = last_id = (await get_newest_ record_id())
    db_session = db_session_var.get()
    refeed_queue = refeed_queue_var.get()

    while True:
        # Run a timer for 300 seconds concurrently with our work
        minimum_loop_timer = asyncio.create_task(asyncio.sleep(300))
        import datetime
        async for datapoint in get_data(*args, inserted_after_record_id=last_id, order=False, **kwargs):
            if datapoint.id > last_id:
                # This is the newest datapoint we have handled so far
                last_id = datapoint.id
            yield datapoint

        while not refeed_queue.empty():
            # Process any datapoints gathered through the refeed queue
            async for datapoint in queue_as_iterator(refeed_queue):
                yield datapoint

        # Commit the DB to store any work that was done in this loop and
        # ensure that any isolation level issues do not prevent loading more
        # data
        db_session.commit()
        # Wait for that timer to complete. If our loop took over 5 minutes
        # this will complete immediately, otherwise it will block
        await minimum_loop_timer

Listing 12-23Updated version of get_data that includes data points from a context variable

清单 12-23 中的代码假设在上下文变量中有一个队列,只要有一些可用,就从该队列中取出项目。这将处理来自数据库查询的所有DataPoint,然后在进行下一个查询之前处理所有生成的点。清单 12-24 显示了向该队列添加项目所需的操作。

from .source import refeed_queue_var

class RefeedAction(Action):
    """An action that puts data points into a special queue to be consumed
    by the analysis programme"""

    async def start(self) -> None:
        return

    async def handle(self, datapoint: DataPoint) -> bool:
        refeed_queue = refeed_queue_var.get()
        if refeed_queue is None:
            logger.error("Refeed queue has not been initialised")
            return False
        else:
            await refeed_queue.put(datapoint)
            return True

Listing 12-24The relevant refeed action

在这两个代码路径中都没有设置refeed_queue_var变量。这是因为单独的处理程序和get_data_ongoing(...)函数运行在不同的上下文中,所以它们不能全局设置上下文变量。迭代器在命令行工具的main_loop()的上下文中运行,但是每个处理程序都有自己单独的上下文,因为它们是作为并行运行的任务启动的。

我们需要在之前设置上下文变量,处理程序被分支为新任务,以便它们维护对同一任务的引用。我们将把它添加到main_loop()函数本身。虽然可以使用全局变量而不是上下文变量来编写这段代码,但这会使将来的测试和潜在的多线程变得更加困难。

摘要

在这一章中,我们应用了许多我们在前面章节中提到的技术来极大地扩展聚合程序的功能。Python 的强大之处在于能够使用相对较少的功能来实现不同的结果。

在我看来,实现这一点最重要的特性是能够编写将逻辑实现作为参数的代码,无论是作为类、函数还是生成器函数。这非常适合我们在本书的分析部分所做的工作,因为它允许我们创建数据管道,并在需要的地方提供特定于应用的逻辑。

额外资源

我还想分享几个链接,包括本章主题的额外阅读材料,如下所示:

此外,我还想分享几个链接,不是专门针对本章的:

  • Python 软件基金会关于 www.python.org/events/ 即将举办的活动列表。

  • Code project ( https://adventofcode.com/ )的问世,每年 12 月发布 25 个拟通过编码解决的谜题。我发现这些写得非常好,是尝试新技术或语言的好方法。我鼓励你尝试一下本书中涉及这些难题的一些技巧,尤其是如果你在日常编程工作中没有机会的话。

收场白

这个长时间运行的过程是本书示例代码的最终特征。有了它,我们就有了一个系统,它有一个可以部署到多个服务器的轻量级组件,可以有选择地记录随时间变化的数据,并通过 HTTP 接口提供这些数据,但它本身就是一个有用的调试工具。我们有一个中央聚合流程,用于维护要查询的已知 HTTP 端点的列表,一个 Jupyter 笔记本,用于绘制聚合数据的图表,还有一个分析流程,用于处理传入数据,以便将合成数据添加到共享数据库或触发外部操作。

在本书的开始,我列举了一些这种类型的应用有用的真实世界的例子。最明显的一个例子是我关注的智能家居,我们的工作允许我们绘制能源使用和温度随时间变化的图表。触发系统可以用来检测一个房间的温度和湿度何时比其他房间更接近外部温度,这表明一扇窗户一直开着,我们可以使用 webhook 将通知推送到移动设备。

一个城市传感器网络,如阿姆斯特丹用于监测飞机噪音的网络,可以在任何给定时间将声音水平绘制在地图上,并且可以编写一个自定义触发器来检测移动的噪音源,以便与已知的飞行数据相关联。

对于服务器监控,我们可以绘制 RAM 和磁盘使用情况的图表,并在服务器的任何监控项目低于阈值时向 Slack 发送通知。通知操作对于像 arcade 这样的部署特别有用,在这种部署中,非技术人员可以得到关于特定机器上的警报状况的警告,并且维护人员会在事后生成报告。

这个项目的代码将随着时间的推移继续发展。网站( https://advancedpython.dev )和这本书在 Apress 网站上的部分都逐章提供了这本书的源代码。欢迎对该软件的当前版本做出任何贡献。

除了构建一个合理有用的软件之外,我们还探索了 Python 标准库的很大一部分,同时关注示例软件中不常用的工具和技术。我们使用了 cookiecutter 和 Pipenv 来创建项目和设置构建环境,使用 Jupyter 来构建软件原型和构建一次性仪表板和分析脚本,并且我们还构建了一个 web 服务。

我们为卫星进程编写了一段同步代码,为聚合软件编写了一个异步工具。两者都使用 SQLAlchemy 和 Alembic 进行数据库连接,使用 pytest 进行测试,涵盖了在同步和异步上下文中的使用。

示例代码广泛使用了相对较新的语言特性,如上下文变量、数据类和类型,以使我们的代码更具表现力,并且我们已经探索了使用 asyncio、迭代器和并发性等特性的适当位置。其中一些技术你可能非常熟悉;其他人可能完全是外国人。Python 的生态系统很广阔,有许多较小的社区致力于创造令人兴奋的新工具。只有通过参与所有这些社区,你才能意识到他们在开发什么。加入当地的 Python 社区可以更容易地了解最新动态。世界各国都有 Python 大会,很多城市都有用户群。还有聊天室、论坛和问答板,社区的所有部分都可以在那里互动。

我曾经听到有人吹嘘他们可能在 24 小时内学会 Python。我完全不同意。我现在已经学习 Python 16 年了,觉得自己还有很多东西要学。Python 是一种设计良好的语言,因此非常直观;一个初学者当然可以在 24 小时内写出一个简单的程序,而一个有经验的程序员可以在很短的时间内写出相应的更复杂的程序。然而,学会足够多的知识并不等同于什么都学会了。

成千上万的人致力于 Python 的生态系统,通过贡献错误报告、文档、库和核心代码来不断改进它。日常的 Python 编程略有不同;虽然这不太可能影响你的日常工作,但今天有可能是某人发布了一个让你的工作更轻松的工具。你不看是不会知道的。

向同行学习是开源软件最有回报的部分之一;我希望这本书对你有所帮助,我也希望不久能在 Python 活动中见到你并向你学习。

posted @ 2024-08-09 17:41  绝不原创的飞龙  阅读(2)  评论(0编辑  收藏  举报