Twisted-专家级编程-全-

Twisted 专家级编程(全)

原文:Expert Twisted

协议:CC BY-NC-SA 4.0

一、Twisted 事件驱动编程简介

Twisted 是一个强大的、经过充分测试的、成熟的并发网络库和框架。正如我们将在本书中看到的,十多年来,许多项目和个人都使用了它,并取得了巨大的成效。

同时,Twisted 是大型的、复杂的、古老的。它的词典里充斥着奇怪的名字,比如“反应堆”、“协议”、“终点”和“延迟”。这些描述了一种哲学和架构,这种哲学和架构让具有多年 Python 经验的新手和老手都感到困惑。

两个基本的编程范例通知了 Twisted 的 API 万神殿:事件驱动编程异步编程。JavaScript 的兴起和asyncio在 Python 标准库中的引入使这两者进一步成为主流,但是这两种范式都没有完全主导 Python 编程,以至于仅仅了解这种语言就使它们变得熟悉。它们仍然是为中级或高级程序员保留的专门主题。

本章和下一章将介绍事件驱动和异步编程背后的动机,然后展示 Twisted 如何使用这些范例。它们为后面探索真实世界 Twisted 程序的章节奠定了基础。

我们将从探索 Twisted 环境之外的事件驱动编程的本质开始。一旦我们了解了事件驱动编程的定义,我们将会看到 Twisted 如何提供软件抽象来帮助开发人员编写清晰有效的事件驱动程序。我们还将沿途停下来了解这些抽象的一些独特部分,如接口,并探索它们是如何在 Twisted 的网站上记录的。

在这一章结束时,你将知道 Twisted 的术语:协议、传输、反应器、消费者和生产者。这些概念构成了 Twisted 事件驱动编程方法的基础,了解这些概念对于用 Twisted 编写有用的软件是必不可少的。

关于 Python 版本的说明

Twisted 本身支持 Python 2 和 3,所以本章中的所有代码示例都可以在 Python 2 和 3 上运行。Python 3 是未来,但 Twisted 的部分优势在于其丰富的协议实现历史;出于这个原因,即使您从未编写过代码,也要熟悉在 Python 2 上运行的代码,这一点很重要。

什么是事件驱动编程?

一个事件是导致一个事件驱动程序执行一个动作的东西。这个宽泛的定义允许许多程序被理解为事件驱动的;例如,考虑一个根据用户输入打印HelloWorld!的简单程序:

import sys
line = sys.stdin.readline().strip()
if line == "h":
     print("Hello")
else:
     print("World")

超过标准输入的输入行的可用性是一个事件。我们的程序在sys.stdin.readline()暂停,它要求操作系统允许用户输入一个完整的行。直到收到一个,我们的计划才能取得进展。当操作系统接收到输入,Python 的内部机制确定它是一行时,sys.stdin.readline()通过将数据返回给它来恢复我们的程序。这次恢复是推动我们计划向前发展的事件。那么,即使这个简单的程序也可以理解为一个事件驱动的程序。

多个事件

接收单个事件然后退出的程序不会从事件驱动的方法中受益。然而,一次可以发生多件事情的程序更自然地围绕事件来组织。图形用户界面就意味着这样一个程序:在任何时候,用户都可能点击一个按钮,从菜单中选择一个项目,滚动一个文本小部件,等等。

这是我们之前的程序的一个版本,带有 Tkinter GUI:

from six.moves import tkinter
from six.moves.tkinter import scrolledtext

class Application(tkinter.Frame):
    def __init__ (self, root):
        super(Application,self). __init__ (root)
        self.pack()
        self.helloButton = tkinter.Button(self,
                                      text="Say Hello",
                                      command=self.sayHello)
        self.worldButton = tkinter.Button(self,
                                        text="Say World",
                                        command=self.sayWorld)
         self.output = scrolledtext.ScrolledText(master=self)
         self.helloButton.pack(side="top")
        self.worldButton.pack(side="top")
         self.output.pack(side="top")
    def outputLine(self, text):
        self.output.insert(tkinter.INSERT, text+ '\n')
    def sayHello(self):
        self.outputLine("Hello")
    def sayWorld(self):
        self.outputLine("World")

应用(tkinter。Tk())。主循环()

这个版本的程序为用户提供了两个按钮,每个按钮都可以生成一个独立的点击事件。这与我们之前的程序不同,在我们之前的程序中,只有sys.stdin.readline可以生成单个“生产线就绪”事件。

我们通过将事件处理程序与每一个相关联来处理每个按钮事件可能发生的情况。Tkinter 按钮接受一个可调用的command以在被点击时调用。当标有“Say Hello”的按钮生成一个点击事件时,该事件驱动我们的程序调用Application.sayHello,如图 1-1 所示。这反过来将由Hello组成的一行输出到一个可滚动的文本小部件。同样的过程也适用于标有“Say Hello”和Application.sayWorld的按钮。

img/455189_1_En_1_Fig1_HTML.jpg

图 1-1

我们的 Tkinter GUI 应用在一系列点击“说你好”和“说世界”之后

我们的Application类继承的tkinter.Framemainloop方法,等待绑定到它的按钮生成一个事件,然后运行相关的事件处理程序。在每个事件处理程序运行之后,tkinter.Frame.mainloop再次开始等待新的事件。一个监视事件源并分派其相关处理程序的循环是典型的事件驱动程序,被称为事件循环

这些概念是事件驱动编程的核心:

  1. 事件表示某件事情已经发生,程序应该对此做出反应。在我们的两个例子中,事件自然地对应于程序输入,但是正如我们将看到的,它们可以表示导致我们的程序执行一些动作的任何东西。

  2. 事件处理程序构成了程序对事件的反应。有时一个事件的处理程序仅仅由一系列代码组成,就像我们的sys.stdin.readline例子一样,但是更多的时候它被一个函数或方法封装,就像我们的tkinter例子一样。

  3. 一个事件循环等待事件并调用与每个事件相关的事件处理程序。不是所有的事件驱动程序都有事件循环;我们的例子没有,因为它只响应单个事件。然而,大多数类似于我们的tkinter例子,它们在最终退出之前处理许多事件。这类程序使用事件循环。

多路复用和解复用

事件循环等待事件的方式影响了我们编写事件驱动程序的方式,所以我们必须仔细研究一下。考虑我们的tkinter例子及其两个按钮;mainloop中的事件循环必须等到用户至少点击了一个按钮。一个简单的实现可能如下所示:

def mainloop(self):
    while self.running:
         ready = [button for button in self.buttons if button.hasEvent()]
         if ready:
            self.dispatchButtonEventHandlers(ready)

mainloop不断地为新事件轮询每个按钮,只为那些准备好事件的按钮分派事件处理程序。当没有事件准备好时,程序没有进展,因为没有采取需要响应的动作。事件驱动程序必须在这些不活动期间暂停执行。

在我们的mainloop例子中,while 循环暂停它的程序,直到其中一个按钮被点击,并且sayHellosayWorld应该运行。除非用户使用鼠标的速度超乎寻常的快,否则这个循环大部分时间都花在检查没有被点击的按钮上。这被称为忙等待,因为程序正在积极地忙等待。

像这样的繁忙等待会暂停程序的整体执行,直到它的一个事件源报告一个事件,因此它足以作为一种暂停事件循环的机制。

驱动我们的实现忙碌等待的内部列表理解提出了一个关键问题:发生了什么事情吗?答案来自于ready变量,这个变量包含了在一个地方被点击过的所有按钮。ready的真决定了事件循环问题的答案:当ready为空因而为假时,没有按钮被点击,所以什么也没发生。然而,当它是真的时,至少有一个被点击了,所以一些事情已经发生了。

构建ready的列表理解将许多独立的输入合并成一个。这被称为多路复用,而从单个合并的输入中分离出不同输入的逆过程被称为解复用。列表理解将我们的按钮复用到ready中,而dispatchButtonEventHandlers方法通过调用每个事件的处理程序将它们解复用出来。

现在,我们可以通过精确描述事件循环等待事件的方式来完善我们对事件循环的理解:

  • 一个事件循环通过将事件源复用到一个输入中来等待事件。当该输入指示事件已经发生时,事件循环将其解复用为组成输入,并调用与每个输入相关联的事件处理程序。

我们的mainloop复用器浪费了大部分时间来轮询没有被点击的按钮。并非所有多路复用器都如此低效。tkinter.Frame.mainloop的实际实现使用了一个类似的多路复用器来轮询所有的小部件,除非操作系统提供了更有效的原语。为了提高效率,mainloop的多路复用器利用了计算机检查 GUI 部件的速度比人与它们互动的速度更快的洞察力,并插入了一个sleep调用,使整个程序暂停几毫秒。这允许程序被动地花费部分忙等待循环,而不是主动地什么都不做,以可忽略的延迟为代价节省 CPU 时间和能量。

虽然 Twisted 可以与图形用户界面集成,并且事实上对tkinter有特殊的支持,但它本质上是一个网络引擎。套接字,而不是按钮,是网络中的基本对象,操作系统公开了用于复用套接字事件的有效原语。Twisted 的事件循环使用这些原语来等待事件。要理解 Twisted 的事件驱动编程方法,我们必须理解这些套接字和这些多路复用网络原语之间的交互。

select复用器

它的历史,它的兄弟姐妹,和它的目的

几乎所有现代操作系统都支持select多路复用器。select之所以得名,是因为它能够获取一个套接字列表,并且只“选择”那些具有准备好处理的事件的套接字。

诞生于 1983 年,那时计算机的能力远不及现在。因此,它的接口使它无法以最高效率运行,尤其是在复用大量套接字时。每个操作系统家族都提供了自己的、更高效的多路复用器,比如 BSD 的kqueue和 Linux 的epoll,但是没有两个能够互操作。幸运的是,它们的原理与select非常相似,我们可以从select的原理中归纳出它们的行为。我们将使用select来探究这些插座多路复用器的行为。

select和插座

下面的代码省略了错误处理,并将在实践中出现的许多边缘情况下中断。它只是作为一种教学工具。不要在实际应用中使用它。用 Twisted 代替。 Twisted 力求正确处理错误和边缘情况;这也是它的实现如此复杂的部分原因。

排除了免责声明,让我们开始一个交互式 Python 会话,并为select创建套接字以进行多路传输:

>>> import socket
>>> listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
>>> listener.bind(('127.0.0.1', 0))
>>> listener.listen(1)
>>> client = socket.create_connection(listener.getsockname())
>>> server, _ = listener.accept()

对套接字 API 的完整解释超出了本书的范围。事实上,我们期望我们讨论的部分将引导您选择 Twisted!然而,前面的代码包含了比无关细节更基本的概念:

  1. listener -该插座可以接受输入连接。它是一个互联网(socket.AF_INET)和 TCP ( socket.SOCK_STREAM)套接字,客户端可以通过内部的、仅限本地的网络接口(通常有一个127.0.0.1地址)和操作系统(0)随机分配的端口进行访问。这个监听器可以为一个传入的连接执行必要的设置,并对其进行排队,直到我们读取它为止(listen(1))。

  2. 这个插座是一个输出连接。Python 的socket.create_connection函数接受一个代表要连接的监听套接字的(host, port)元组,并返回一个与之连接的套接字。因为我们的监听套接字在同一个进程中,并被命名为listener,所以我们可以用listener.getsockname()检索它的主机和端口。

  3. server -服务器的传入连接。一旦client连接到我们的主机和端口,我们必须接受来自listener的长度为 1 的队列的连接。listener.accept返回一个(socket, address)元组;我们只需要套接字,所以我们丢弃了地址。一个真正的程序可能会记录地址或使用它来跟踪连接度量。我们通过套接字的listen方法将监听队列设置为 1,在我们调用accept并允许create_connection返回之前,监听队列为我们保存这个套接字。

clientserver是同一个 TCP 连接的两端。已建立的 TCP 连接没有“客户端”和“服务器”的概念;我们的client套接字与我们的server套接字具有相同的读、写或关闭连接的特权:

>>> data = b"xyz"
>>> client.sendall(data)
>>> server.recv(1024) == data
True
>>> server.sendall(data)
>>> client.recv(1024) == data

True

套接字事件的方式和原因

在幕后,操作系统为每个 TCP 套接字维护读写缓冲区,以考虑网络的不可靠性以及以不同速度读写的客户端和服务器。如果server暂时无法接收数据,我们通过的b"xyz"``client.sendall将保留在其写缓冲区中,直到server再次变为活动状态。类似地,如果我们太忙而没有时间调用client.recv来接收发送的b"xyz" server.sendall,那么client'的读缓冲区会一直保存它,直到我们有时间接收它。我们传递的数字recv表示我们愿意从读缓冲区中移除的最大数据量。如果读缓冲区的数据小于最大值,如我们的例子所示,recv将从缓冲区中移除所有的数据并返回。

我们的套接字的双向性意味着两种可能的事件:

  1. 一个可读事件,这意味着套接字有一些可用的东西。当数据到达套接字的接收缓冲区时,连接的服务器套接字会生成该事件,因此在可读事件后调用recv将立即返回该数据。断开由无数据的recv表示。按照惯例,当我们可以accept一个新的连接时,一个监听套接字会生成这个事件。

  2. 一个可写事件,这意味着套接字的写缓冲区中有可用空间。这是一个微妙的问题:只要套接字从服务器接收到对数据的确认,它在网络上传输的速度比我们将数据添加到发送缓冲区的速度快,它就保持可写。

select的界面反映了这些可能的事件。它最多接受四个参数:

  1. 监控可读事件的套接字序列;

  2. 监控可写事件的套接字序列;

  3. 监控“异常事件”的套接字序列在我们的例子中,不会发生异常事件,所以我们总是在这里传递一个空列表;

  4. 一个可选的超时。这是select等待其中一个监视器套接字生成事件的秒数。省略这个参数将导致select永远等待。

我们可以询问select我们的套接字刚刚生成的事件:

>>> import select
>>> maybeReadable = [listener, client, server]
>>> maybeWritable = [client, server]
>>> readable, writable, _ = select.select(maybeReadable, maybeWritable, [], 0)
>>> readable
[]
>>> writable == maybeWritable and writable == [client, server]
True

我们通过提供超时 0 来指示select不要等待任何新事件。如上所述,我们的clientserver套接字可能是可读或可写的,而我们的listener只能接受传入的连接,只能是可读的。

如果我们忽略了超时,select会暂停我们的程序,直到它所监控的一个套接字变得可读或可写。这种执行的暂停类似于多路复用的 busy-wait,它在我们上面的天真的mainloop实现中轮询所有的按钮。

调用select 多路复用套接字比繁忙等待更有效,因为操作系统只有在至少一个事件已经生成时才会恢复我们的程序;在内核内部,一个事件循环,与我们的select相似,等待来自网络硬件的事件,并将它们分派给我们的应用。

处理事件

select返回一个包含三个列表的元组,顺序与其参数相同。迭代每个返回的列表解复用 select的返回值。我们的套接字都没有生成可读的事件,尽管我们已经将数据写入了clientserver;我们之前对recv的调用清空了它们的读取缓冲区,自从我们接受server以来,没有新的连接到达listener。然而,clientserver都生成了一个可写事件,因为它们的发送缓冲区中有可用空间。

clientserver发送数据导致server生成一个可读事件,因此select将其放入readables列表:

>>> client.sendall(b'xyz')
>>> readable, writable, _ = select.select(maybeReadable, maybeWritable, [], 0)
>>> readable == [server]
True

有趣的是,writable列表再次包含了我们的clientserver插座:

>>> writable == maybeWritable and writable == [client, server]
True

如果我们再次调用select,我们的server插座将再次位于readable,我们的clientserver插座将再次位于writable。原因很简单:只要数据保留在套接字的读缓冲区中,它就会连续生成一个可读事件,只要套接字的写缓冲区中还有空间,它就会生成一个可写事件。我们可以通过recv调用发送到server的数据client并再次调用select来确认新事件:

>>> server.recv(1024) == b'xyz'
True
>>> readable, writable, _ = select.select(maybeReadable, maybeWritable, [], 0)
>>> readable
[]
>>> writable == maybeWritable and writable == [client, server]
True

清空server的读缓冲区导致它停止生成可读事件,而clientserver继续生成可写事件,因为它们的写缓冲区还有空间。

带有select的事件循环

我们现在知道了select如何复用套接字:

  1. 不同的套接字生成可读或可写的事件,以指示事件驱动的程序应该接受传入的数据或连接,或者写入传出的数据。

  2. select通过监视套接字的可读或可写事件来复用套接字,暂停程序,直到至少生成一个事件或可选的超时时间已过。

  3. 套接字继续生成可读和可写事件,直到导致这些事件的环境发生变化:具有可读数据的套接字发出可读事件,直到其读缓冲区被清空;侦听套接字发出可读事件,直到所有传入连接都被接受;并且可写套接字发出可写事件,直到其写缓冲区被填满。

有了这些知识,我们可以围绕select勾画一个事件循环:

import select

class Reactor(object):
    def __init__ (self):
        self._readers = {}
        self._writers = {}
    def addReader(self, readable, handler):
        self._readers[readable] = handler
    def addWriter(self, writable, handler):
        self._writers[writable] = handler
    def removeReader(self, readable):
        self._readers.pop(readable,None)
    def removeWriter(self, writable):
        self._writers.pop(writable,None)
    def run(self):
        while self._readers or self._writers:
            r, w, _ = select.select(list(self._readers), list(self._writers), [])
            for readable in r:
                self._readersreadable
            for writable in w:
                if writable in self._writers:
                   self._writerswritable

我们称我们的事件循环为反应器,因为它对套接字事件做出反应。我们可以请求我们的ReactoraddReader调用套接字上的可读事件处理程序,用addWriter调用可写事件处理程序。事件处理程序接受两个参数:反应器本身和生成事件的套接字。

run方法中的循环将我们的套接字与select进行多路复用,然后在产生读事件的套接字和产生写事件的套接字之间解复用结果。每个可读套接字的事件处理程序首先运行。然后,事件循环在运行其事件处理程序之前,检查每个可写套接字是否仍注册为编写器。这种检查是必要的,因为关闭的连接表示为读取事件,所以之前立即运行的读取处理程序可能会从读取器和写入器中移除关闭的套接字。当它的可写事件处理程序运行时,关闭的套接字将从_writers字典中移除。

事件驱动的客户端和服务器

这个简单的事件循环足以实现一个不断向服务器写入数据的客户端。我们将从事件处理程序开始:

def accept(reactor, listener):
    server, _ = listener.accept()
    reactor.addReader(server, read)

def read(reactor, sock):
    data = sock.recv(1024)
    if data:
        print("Server received", len(data),"bytes.")
    else:
        sock.close()
        print("Server closed.")
        reactor.removeReader(sock)

DATA=[b"*",  b"*"]
def write(reactor, sock):
    sock.sendall(b"".join(DATA))
    print("Client wrote", len(DATA)," bytes.")
    DATA.extend(DATA)

accept函数通过接受传入连接并请求反应器监控可读事件来处理侦听套接字上的可读事件。这些由read函数处理。

read函数通过尝试从套接字的接收缓冲区接收固定数量的数据来处理套接字上的可读事件。任何接收到的数据的长度都会被打印出来——记住,传递给recv的数据量代表返回的字节数的上限。如果在已经生成可读事件的套接字上没有接收到数据,那么连接的另一端已经关闭了它的套接字,并且read函数通过关闭它的套接字端并将其从由反应器监视可读事件的套接字集中删除来做出响应。关闭套接字释放其操作系统资源,同时将其从反应器中移除确保了select多路复用器不会试图监控永远不会再次活动的套接字。

write函数将一系列星号(*)写入生成写事件的套接字。每次成功写入后,数据量都会翻倍。这模拟了真实网络应用的行为,这些应用不会始终如一地向一个连接写入相同数量的数据。考虑一个 web 浏览器:一些传出的请求包含用户键入的少量表单数据,而另一些请求将一个大文件上传到远程服务器。

注意,这些是模块级函数,而不是我们的Reactor类中的方法。相反,通过将它们注册为读取器或写入器,它们与反应器相关联,因为 TCP 套接字只是套接字的一种,我们必须处理它们的事件的方式不同于我们处理其他套接字事件的方式。然而,select的工作方式是一样的,不管它被赋予什么样的套接字,所以在它返回的套接字列表上运行事件处理程序的逻辑应该被Reactor类封装。稍后我们将会看到封装和它所隐含的接口对事件驱动程序有多重要。

我们现在可以建立一个listener和一个client,并允许事件循环驱动连接的接受和从client到服务器套接字的数据传输。

import socket
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.bind(('127.0.0.1',0))
listener.listen(1)
client = socket.create_connection(listener.getsockname())

loop = Reactor()
loop.addWriter(client, write)
loop.addReader(listener, accept)
loop.run()

运行该程序会显示成功和失败:

Client wrote 2 bytes.
Server received 2 bytes.
Client wrote 4 bytes.
Server received 4 bytes.
Client wrote 8 bytes.
Server received 8 bytes.
...
Client wrote 524288 bytes.
Server received 1024 bytes.
Client wrote 1048576 bytes.
Server received 1024 bytes.
^CTraceback (most recent call last):
  File "example.py", line 53, in <module>
    loop.run()
  File "example.py", line 25, in run
    writeHandler(self, writable)
  File "example.py", line 33, in write
    sock.sendall(b"".join(DATA))
KeyboardInterrupt

成功是显而易见的:数据从客户机套接字传递到服务器。这个行为遵循由acceptreadwrite事件处理程序设计的路径。正如所料,客户机首先向服务器发送两个字节的b'*',服务器依次接收这两个字节。

客户机和服务器的同时性展示了事件驱动编程的威力。我们的 GUI 应用可以响应来自两个不同按钮的事件,而这个小型网络服务器现在可以响应来自客户端或服务器的事件,允许我们在一个进程中同时处理两者。select的多路复用能力在我们的程序事件循环中提供了一个单点,在这里它可以响应任何一个事件。

失败也是显而易见的:在一定次数的重复之后,我们的程序会冻结,直到它被键盘中断。这个失败的线索存在于我们程序的输出中;过了一会儿,客户机发送的数据量是服务器接收的数据量的许多倍,KeyboardInterrupt的回溯直接导致我们的write处理程序的sock.sendall调用。

我们的客户机使我们的服务器不堪重负,结果是客户机试图发送的大部分数据都留在它的套接字的发送缓冲区中。当在发送缓冲区中没有剩余空间的套接字上调用时,sendall的默认行为是暂停或阻塞程序。现在,如果sendall阻塞了没有阻塞,并且我们的事件循环被允许运行,那么套接字就不会以可写的形式出现,阻塞的sendall调用也不会运行;然而,我们不能保证一个给定的发送调用会写足够的内容来填满一个套接字的发送缓冲区,这样sendall就不会阻塞,写处理程序会运行到完成,而select会阻止进一步的写操作,直到缓冲区耗尽。网络的本质是我们只有在这样的问题发生后才知道它。

到目前为止,我们报道的所有事件都促使我们的程序做一些事情。它们都不能促使它停止做某事。我们需要一种新的活动。

非阻塞输入输出

知道何时停止

默认情况下,套接字会阻止一个程序开始一项操作,直到远端执行某项操作,该操作才能完成。在这种情况下,我们可以通过请求操作系统使其非阻塞来使套接字发出一个事件。

让我们回到交互式 Python 会话,再次构建一个clientserver套接字之间的连接。这一次,我们将使客户端成为非阻塞的,并尝试向其写入无限的数据流。

>>> import socket
>>> listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
>>> listener.bind(( '127.0.0.1',0))
>>> listener.listen(1)
>>> client=socket.create_connection(listener.getsockname())
>>> server, _ = listener.accept()
>>> client.setblocking(False)
>>> while True: client.sendall(b"*"*1024)
...
Traceback (most recent call last):
  File"<stdin>", line1, in <module>
BlockingIOError: [Errno11] Resource temporarily unavailable

我们再次填充了client的发送缓冲区,但是sendall没有暂停进程,而是引发了一个异常。Python 2 和 3 中的异常类型有所不同;在这里,我们展示了 Python 3 的BlockingIOError,而在 Python 2 中,它将是更一般的socket.error。在 Python 的两个版本中,异常的errno属性将被设置为errno.EAGAIN:

>>> import errno, socket
>>> try:
...    while True: client.sendall(b"*"*1024)
... except socket.error as e:
...    print(e.errno == errno.EAGAIN)
True

该异常表示操作系统生成的事件,指示我们应该停止写入。这几乎足以修复我们的客户端和服务器。

跟踪状态

然而,处理这个异常需要我们回答一个新问题:我们试图写入套接字发送缓冲区的数据有多少?不回答这个问题,我们就无法知道我们实际上发送了什么数据,也不知道我们无法用非阻塞套接字编写正确的程序。例如,网络浏览器必须跟踪它上传了多少文件,否则就有可能在传输过程中破坏文件内容。

在生成成为我们的异常的EAGAIN事件之前,client.sendall可以在它的写缓冲区中放置任意数量的字节。我们必须从套接字对象的sendall方法切换到send方法,该方法返回写入套接字发送缓冲区的数据量。我们可以用我们的server插座来证明这一点:

>>> server.setblocking(False)
>>> try:
...    while True: print(server.send(b"*" * 1024))
... except socket.error as e:
...     print("Terminated with EAGAIN:", e.errno == errno.EAGAIN)
1024
1024
...
1024
952
Terminated with EAGAIN:True

我们将server标记为非阻塞,这样当它的发送缓冲区已满时,它会生成一个EAGAIN事件。然后while循环调用server.send。返回 1024 的调用已经将所有提供的字节写入套接字的发送缓冲区。最终套接字的写缓冲区被填满,一个代表EAGAIN事件的异常终止了循环。然而,循环终止前最后一次成功的send调用返回 952,这里 send 简单地丢弃了剩余的 72 个字节。这就是所谓的短写。阻塞套接字也会发生这种情况!因为当发送缓冲区中没有可用空间时它们会阻塞,而不是引发异常,sendall可以并且确实包含一个循环,该循环检查底层send调用的返回值并重新调用它,直到所有数据都被发送完。

在这种情况下,套接字的发送缓冲区不是 1024 的倍数,因此我们无法在到达EAGAIN之前将偶数个send调用的数据放入。然而,在现实世界中,套接字的发送缓冲区会根据网络中的条件改变大小,应用会通过连接发送不同数量的数据。使用非阻塞 I/O 的程序,比如我们假设的 web 浏览器,必须定期处理这样的短写。

我们可以使用send的返回值来确保我们将所有数据写入连接。我们维护自己的缓冲区,其中包含我们想要写入的数据。每次select为该套接字发出一个可写事件时,我们试图send当前在缓冲区中的数据;如果send调用在没有引发EAGAIN的情况下完成,我们会记录返回的数量,并从我们的缓冲区的开头删除该数量的字节,因为send从它传递的字节序列的开头将数据写入发送缓冲区。另一方面,如果send引发了一个EAGAIN异常,表明发送缓冲区已满,无法接受更多数据,我们就让缓冲区保持原样。我们以这种方式进行,直到我们自己的缓冲区为空,此时我们知道所有的数据都已经放在了套接字的发送缓冲区中。之后,由操作系统将它发送到连接的接收端。

我们现在可以通过将它的write函数分成一个初始化数据写入的函数和一个在send之上管理缓冲区的对象来修复我们简单的客户机-服务器示例:

import errno
import socket

class BuffersWrites(object):
    def __init__ (self, dataToWrite, onCompletion):
        self._buffer = dataToWrite
        self._onCompletion = onCompletion
    def bufferingWrite(self, reactor, sock):
        if self._buffer:
            try:
                written = sock.send(self._buffer)
            except socket.error as e:
                if e.errno != errno.EAGAIN:
                    raise
                return
            else:
                print("Wrote", written,"bytes")
                self._buffer = self._buffer[written:]
        if not self._buffer:
                reactor.removeWriter(sock)
                self._onCompletion(reactor, sock)

DATA=[b"*", b"*"]
def write(reactor, sock):
    writer = BuffersWrites(b"".join(DATA), onCompletion=write)
    reactor.addWriter(sock, writer.bufferingWrite)
    print("Client buffering", len(DATA),"bytes to write.")
    DATA.extend(DATA)

BuffersWrites的初始化器的第一个参数是它将写入的字节,它将其用作缓冲区的初始值,而它的第二个参数onCompletion是一个可调用的对象。顾名思义,当提供的数据被完全写入时,onCompletion将被调用。

bufferingWrite方法的签名是我们对适合传递Reactor.addWriter的可写事件处理程序的期望。如上所述,它试图将任何缓冲的数据send到它传递的套接字,保存返回的指示写入量的数字。如果send引发一个EAGAIN异常,bufferingWrite抑制它并返回;否则,它会传播异常。在这两种情况下。self._buffer保持不变。

如果send成功,从self._buffer的开始处切掉与写入量相等的字节数,然后bufferingWrite返回。例如,如果send调用只写了 1024 个字节中的 952 个,self_buffer将包含最后的 73 个字节。

最后,如果缓冲区是空的,那么所有请求的数据都已经被写入,没有工作留给BuffersWrites实例去做。它请求反应器停止监视其套接字的可写事件,然后调用onCompletion,因为提供给它的数据已经被完全写入。注意,这个检查发生在独立于第一个if self._buffer语句的if语句中。前面的代码可能已经运行并清空了缓冲区;如果最终代码在附加到if self._buffer语句的else块中,它将不会运行,直到下一次反应器在这个套接字上检测到可写事件。为了简化资源管理,我们在这个方法中执行检查。

除了现在它通过它的bufferingWrite方法将数据委托给BuffersWrites之外,write函数看起来与我们之前的版本相似。最值得注意的是,write本身传递给BuffersWrites作为onCompletion调用。这通过间接递归创建了与先前版本相同的循环效果。write从不直接调用自己,而是将自己传递给我们的反应器最终调用的对象。这种间接方式允许此序列继续进行,而不会溢出调用堆栈。

通过这些修改,我们的客户机-服务器程序不再阻塞。相反,它失败的另一个原因是:最终,DATA变得太大,不适合你的计算机的可用内存!下面是作者电脑中的一个例子:

Client buffering 2 bytes to write.
Wrote 2 bytes
Client buffering 4 bytes to write.
Server received 2 bytes.
Wrote 4 bytes
...
Client buffering 2097152 bytes to write.
Server received 1024 bytes.
Wrote 1439354 bytes
Server received 1024 bytes.
Server received 1024 bytes.
....
Wrote 657798 bytes
Server received 1024 bytes.
Server received 1024 bytes.
....
Client buffering 268435456 bytes to write.
Traceback (most recent call last):
  File "example.py", line 76, in <module>
    loop.run()
  File "example.py", line 23, in run
    writeHandler(self, writable)
  File "example.py", line 57, in bufferingWrite
    self._onCompletion(reactor, sock)
  File "example.py", line 64, in write
    DATA.extend(DATA)
MemoryError

状态使程序变得复杂

尽管存在这个问题,但我们已经成功编写了一个事件驱动的网络程序,它使用非阻塞 IO 来控制套接字写入。然而,代码是混乱的:从writeBuffersWrites,然后是反应器,最后回到write的间接性模糊了出站数据的逻辑流,很明显,实现任何比简单的星号流更复杂的东西都将涉及扩展特设类和接口,超出它们的断点。例如,我们如何处理MemoryError?我们的方法无法扩展到实际应用中。

管理传输和协议的复杂性

使用非阻塞 I/O 编程无疑是复杂的。UNIX 权威 W. Richard Stevens 在其开创性的 Unix 网络编程系列的第一卷中写下了以下内容:

  • 但是,考虑到结果代码的复杂性,使用非阻塞 I/O 编写应用值得吗?答案是否定的。

( UNIX 网络编程,第 1 卷。第二版。第 446 页

我们代码的复杂性似乎证明史蒂文斯是正确的。然而,正确的抽象可以将复杂性封装在一个可管理的接口中。我们的例子已经有了可重用的代码:任何写入套接字的新代码单元都需要使用核心逻辑BuffersWrites。我们已经封装了写非阻塞套接字的复杂性。基于这种认识,我们可以区分两个概念领域:

  1. 传输 : BuffersWrites管理将输出写入非阻塞套接字的过程,而不考虑该输出的内容。它可以发送照片,或者音乐,或者任何我们可以想象的东西,只要它可以用字节来表达。BuffersWrites是一个传输,因为它是字节的一种传输方式。传输封装了从套接字读取数据以及接受新连接的过程。它代表我们程序中动作的原因,是我们程序自身动作的接收者

  2. 协议:我们的示例程序用一个简单的算法生成数据,并且仅仅计算它接收到的数据。更复杂的程序可能会生成网页或将语音电话处理成文本。只要它们能接收和发送字节,它们就能与我们所描述的传输协同工作。它们还可以控制传输行为,比如在收到无效数据时关闭活动连接。电信领域描述了像这样的规则,这些规则定义了如何通过 ?? 协议 ?? 来交换数据。一个协议,然后定义如何产生和处理输入和输出。它封装了我们程序的效果

反应器:使用传输

我们从改变我们的Reactor开始,在运输方面工作:

import select
class Reactor(object):
    def __init__ (self):
        self._readers = set()
        self._writers = set()
    def addReader(self, transport):
        self._readers.add(transport)
    def addWriter(self, transport):
        self._writers.add(transport)
    def removeReader(self, readable):
        self._readers.discard(readable)
    def removeWriter(self, writable):
        self._writers.discard(writable)
    def run(self):
        while self._readers or self._writers:
            r, w, _ = select.select(self._readers,self._writers, [])
            for readable in r:
                readable.doRead()
            for writable in w:
                if writable in self._writers:
                    writable.doWrite()

我们的可读和可写事件处理程序以前是函数,现在是传输对象上的方法:doReaddoWrite。此外,反应堆不再跟踪插座-它直接select的运输。从反应堆的角度来看,传输接口包括:

  1. doRead,

  2. doWrite,

  3. 使传输的状态对select可见的东西:一个fileno()方法,返回一个数字,select理解为对套接字的引用。

传输:使用协议

接下来,我们将通过回到我们的readwrite函数来考虑一个协议实现。read职能有两个职责:

  1. 计算套接字上接收的字节数。

  2. 响应关闭的连接。

write 函数只有一个职责:对要写入的数据进行排队。

由此我们可以勾画出一个Protocol界面的初稿:

class Protocol(object):
    def makeConnection(self, transport):
        ...
    def dataReceived(self, data):
        ...
    def connectionLost(self, exceptionOrNone):
        ...

我们将read的两个职责分成了两个方法:dataReceivedconnectionLost。前者的签名是不言自明的,而后者接收一个参数:如果连接因为那个异常而被关闭(例如,因为ECONNRESET),则接收一个异常对象;如果连接在没有异常的情况下被关闭(例如,因为被动关闭而具有空读取),则接收None。注意,我们的协议接口缺少一个write方法。这是因为写入数据,包括传输字节,属于传输的范畴。因此,Protocol实例必须能够访问代表底层网络连接的传输,并且它将有一个write方法。两者之间的关联通过makeConnection发生,它接受一个传输作为它的参数。

为什么不将传输作为参数传递给Protocol的初始化器呢?一个单独的方法可能看起来笨拙,但它给我们提供了更大的灵活性;例如,您可以想象这个方法将如何允许我们引入Protocol缓存。此外,我们将看到,因为传输调用协议的dataReceivedconnectionLost方法,所以它也必须与协议相关联。如果我们的TransportProtocol类都需要它们的初始化式中的对等体,那么我们将会有一个循环关系来阻止它们被实例化。我们选择让我们的Protocol通过一个单独的方法接受它的传输来打破这个循环,因为它提供了灵活性。

用协议和传输打乒乓

这足以让我们编写一个更复杂的协议来实现这个新接口。我们之前的客户机-服务器示例只是让客户机向服务器发送越来越多的字节序列;我们可以增加这个,这样两个字节来回发送,直到一个可选的最大值,超过最大值的发送方关闭连接。

class PingPongProtocol(object):
    def __init__ (self, identity, maximum=None):
        self._identity = identity
        self._received = 0
        self._maximum = maximum
    def makeConnection(self, transport):
        self.transport = transport
        self.transport.write(b'*')
    def dataReceived(self, data):
        self._received += len(data)
        if self._maximum is not None and self._received >= self._maximum:
            print(self._identity,"is closing the connection")
            self.transport.loseConnection()
        else:
            self.transport.write(b'*')
            print(self._identity,"wrote a byte")
    def connectionLost(self, exceptionOrNone):
        print(self._identity,"lost the connection:", exceptionOrNone)

初始化器接受一个用于标识协议实例的identity字符串,以及在终止连接之前可选的最大数据量。makeConnectionPingPongProtocol与其传输相关联,并通过发送单个字节开始交换。dataReceived记录接收到的数据量;如果总量超过可选的最大值,它告诉传输失去连接,或者等同于断开连接。否则,它通过发回一个字节来继续交换。最后,connectionLost在连接的协议方关闭时打印一条消息。

描述了一组行为,其复杂性远远超出了我们之前在非阻塞客户端-服务器应用方面的尝试。同时,它的实现反映了它之前的散文描述,而没有陷入非阻塞 I/O 的细节。我们已经能够增加我们的应用的复杂性,同时降低其独特的 I/O 管理的复杂性。我们将回头探讨这一问题的结果,但可以说,缩小我们的关注范围允许我们消除程序特定领域的复杂性。

在写入Transport之前,我们不能使用PingPongProtocol。然而,我们可以写一份Transport界面的初稿:

class Transport(object):
    def __init__ (self, sock, protocol):
        ...
    def doRead(self):
        ...
    def doWrite(self):
        ...
    def fileno(self):
        ...
    def write(self):
        ...
    def loseConnection(self):
        ...

Transport初始化器的第一个参数是实例包装的套接字。这加强了Transport'对现在Reactor所依赖的套接字的封装。第二个参数是协议,当新数据可用时将调用其dataReceived,当连接关闭时将调用其connectionLostdoReaddoWrite方法与我们上面列举的反应器端传输接口相匹配。新方法fileno也是这个接口的一部分;一个正确实现了fileno方法的对象可以传递给select。我们将把对我们的Transportfileno的调用代理到它所包装的套接字,使得从select的角度来看这两者无法区分。

write方法提供了接口,我们的Protocol依赖这个接口发送输出数据。我们还添加了loseConnection,一个新的Protocol端 API,它启动套接字的关闭,并代表我们的被动关闭connectionLost方法的主动关闭端。

我们可以通过在我们的read函数中吸收BuffersWrites和套接字处理来实现这个接口:

import errno

class Transport(object):
    def __init__ (self, reactor, sock, protocol):
        self._reactor = reactor
        self._socket = sock
        self._protocol = protocol
        self._buffer = b "
        self._onCompletion = lambda:None
    def doWrite(self):
        if self._buffer:
            try:
                written = self._socket.send(self._buffer)
            except socket.error as e:
                if e.errno != errno.EAGAIN:
                    self._tearDown(e)
                return
            else:
                print("Wrote", written,"bytes")
                self._buffer = self._buffer[written:]
         if not self._buffer:
             self._reactor.removeWriter(self)
             self._onCompletion()
    def doRead(self):
        data=self._socket.recv(1024)
        if data:
            self._protocol.dataReceived(data)
        else:
            self._tearDown(None)
    def fileno(self):
        return self._socket.fileno()
    def write(self, data):
        self._buffer += data
        self._reactor.addWriter(self)
        self.doWrite()
    def loseConnection(self):
        if self._buffer:
            def complete():
                self.tearDown(None)
            self._onCompletion = complete
        else:
            self._tearDown(None)
    def _tearDown(self, exceptionOrNone):
        self._reactor.removeWriter(self)
        self._reactor.removeReader(self)

        self._socket.close()
        self._protocol.connectionLost(exceptionOrNone)
    def activate(self):
        self._socket.setblocking(False)
        self._protocol.makeConnection(self)
        self._reactor.addReader(self)
        self._reactor.addWriter(self)

doReaddoWrite反映了先前示例中的插座操作readwrite功能以及BuffersWritesdoRead还将任何接收到的数据代理到协议的dataReceived方法,或者在接收到空读取时调用其connectionLost方法。最后,fileno通过使Transport s select能够完成Reactor要求的接口。

write方法像以前一样缓冲写操作,但是它不是将写操作委托给一个单独的类,而是调用它的兄弟doWrite方法将缓冲区刷新到套接字。如果缓冲区为空,对loseConnection的调用通过以下方式断开连接:

  1. 从反应器中移除运输工具;

  2. 关闭底层套接字以将其资源释放回操作系统;

  3. 向协议的connectionLost发送None以表明由于被动关闭而导致连接丢失。

如果缓冲区不为空,则数据要写入,因此loseConnection用一个闭包覆盖_onCompletion,该闭包按照与上述相同的过程断开连接。与BuffersWrites一样,Transport._onCompletion只有当我们的写缓冲区中的所有字节都被刷新到底层套接字时才会被调用。loseConnection_onCompletion的使用确保了底层连接保持打开,直到所有数据都被写入。_onCompletion的默认值在Transport的初始化器中被设置为一个 lambda,没有任何效果。这确保了对write的多个调用可以重用底层连接。这些writeloseConnection的实现一起实现了Protocol所需的传输接口。

最后,activate通过以下方式激活传输:

  1. 为非阻塞 I/O 准备包装的套接字;

  2. 通过Protocol.makeConnectionTransport实例传递给它的协议;

  3. 最后向反应器注册传输。

这通过包装连接生命周期的开始完成了Transport对其套接字的封装,其中结束已经被loseConnection封装。

Protocol允许我们通过PingPongProtocol扩展我们的关注点并将行为添加到我们的应用中的地方,Transport已经围绕套接字的输入输出生命周期缩小了它的范围。reactor——我们的事件循环——从它们的起始套接字检测和调度事件,而协议包含我们需要的事件处理程序。Transport通过将套接字事件转换为协议方法调用并在这些方法调用之间实施控制流来进行协调;例如,它确保一个协议的makeConnection在它生命的开始被调用,而loseConnection在结束时被调用。这是对我们的特定客户机-服务器示例的另一个改进;我们将套接字的控制流完全集中在Transport内,而不是分散在不相关的函数和对象上。

具有协议和传输的客户端和服务器

我们可以通过定义一个子类型Listener来展示Transport的通用性,该子类型接受传入的连接并将它们与一个唯一的PingPongProtocol实例相关联:

class Listener(Transport):
    def activate(self):
        self._reactor.addReader(self)
    def doRead(self):
        server, _ = self._socket.accept()
        protocol = PingPongProtocol("Server")
        Transport(self._reactor, server, protocol).activate()

侦听套接字不发出可写事件,所以我们覆盖了activate来只添加传输作为读取器。我们可读的事件处理程序doRead,必须接受一个新的客户端连接和协议,然后用一个激活的Transport将两者绑定在一起。

现在已经为由协议和传输提供支持的客户机-服务器示例做好了准备:

listenerSock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listenerSock.bind(('127.0.0.1',0))
listenerSock.listen(1)
clientSock = socket.create_connection(listenerSock.getsockname())

loop = Reactor()
Listener(loop, listenerSock, None).activate()
Transport(loop, clientSock, PingPongProtocol("Client", maximum=100)).activate()
loop.run()

两者将交换单个字节,直到客户端收到其最大值 100,之后客户端关闭连接:

Server wrote a byte
Client wrote a byte
Wrote 1 bytes
Server wrote a byte
Wrote 1 bytes
Client wrote a byte
Wrote 1 bytes
Server wrote a byte
Wrote 1 bytes
Client wrote a byte
Wrote 1 bytes
Server wrote a byte

Server wrote a byte
Client is closing the connection
Client lost the connection: None
Server lost the connection: None

Twisted 和反应器、协议和传输

我们已经走了很长一段路:从select开始,我们围绕一个事件循环及其处理程序开发了一组接口,它们清晰地划分了职责。我们的Reactor驱动我们的程序,Transport将套接字事件分派给在Protocol上定义的应用级处理程序。

我们的反应堆、运输机和协议显然是玩具。例如,socket.create_connection阻塞,我们还没有调查任何非阻塞的替代方案。事实上,create_connection暗示的底层 DNS 解析可能会自己阻塞!

然而,作为概念,它们已经可以认真使用了。反应器、传输和协议是 Twisted 事件驱动架构的基础。正如我们所看到的,它们的体系结构反过来又依赖于 I/O 多路复用和无阻塞的现实,从而使 Twisted 能够高效地运行。

然而,在我们探索 Twisted 本身之前,我们将把我们的例子作为一个整体来考虑,以评估事件驱动编程的优点和缺点。

事件驱动编程的价值

W.Richard Stevens 关于非阻塞 I/O 复杂性的告诫是对我们所探索的事件驱动编程范例的重要批评。然而,这不是唯一的缺点:我们的事件驱动范例在高 CPU 负载下表现不佳。

编写指数增长的字节序列的客户机-服务器示例自然会消耗大量内存,但也会消耗大量 CPU。原因是其缓冲区管理的幼稚:套接字根本不能接受大于特定大小的数据块。每次我们调用send的时候,调用send会把它复制到内核控制的内存位置。然后写入数据的一部分,然后我们切掉缓冲区的前端;因为bytes在 Python 中是不可变的,这意味着另一个副本。如果我们试图发送 N 个字节,我们将复制缓冲区一次,然后两次,一次又一次,直到 N。因为每次复制都意味着遍历缓冲区,所以这个过程的时间复杂度为 O( n 2 )。

Twisted 自己的缓冲机制性能更好,但代价是复杂性超出了事件驱动编程的可读介绍。然而,并不是所有计算要求高的任务都那么容易改进:蒙特卡罗模拟必须重复进行统计分析和随机采样;比较排序算法必须比较序列中的每一对元素;等等。

我们的事件驱动程序都执行多种逻辑行为——我们有一个客户端和一个服务器在一个进程中通信。这种通信同时发生:在暂停并允许服务器取得少量进展之前,连接的客户端取得少量进展。客户端和服务器在任何时候都不是并行运行的,就像它们在独立的 Python 解释器中一样,也许是在通过网络连接的独立计算机上。当我们简单的缓冲区管理执行一个冗长的拷贝时,在这个过程完成之前不会有任何进展,而如果客户机和服务器运行在不同的计算机上,服务器可以接受新的连接,而客户机则费力地来回移动字节。如果我们在我们的过程中运行一个计算要求很高的算法,我们的反应器不能调用select来发现新的事件以对其做出反应,直到这个算法完成之后。**

**因此,事件驱动编程不适合计算要求高的任务。幸运的是,许多任务对输入和输出的要求比计算更高。网络服务器就是一个典型的例子;一个聊天服务器可能有成千上万的用户,但是在任何时候只有一小部分是活跃的(而且通常在你寻求帮助的时候是不活跃的!).因此,事件驱动编程仍然是网络中一个强大的范例。

事件驱动编程有一个特别的优势,可以弥补这个缺点:它强调原因效果。一个事件的产生代表一个原因,而那个事件的处理程序代表预期的结果。

我们在TransportProtocol中对这种划分进行了整理:传输表示动作的原因——一些输入或套接字输出——而协议封装了效果。我们的PingPongProtocol通过一个清晰的接口与其传输进行交互,该接口将处理程序暴露给更高级别的事件——原因——比如传入字节的到达或连接的结束。然后,它从这些原因中产生效果,这又可能导致新的原因,例如将数据写入传输。两者之间的区别是由各自的接口决定的。

这意味着我们可以用一个传输替换另一个,并通过调用表示预期效果的方法来模拟我们的协议的执行。这将我们的客户机-服务器的核心变成了一个可测试的代码单元。

考虑一个构建在BytesIO上的传输实现,它只实现了Transport接口的Protocol端:

import io

class BytesTransport(object):

    def __init__ (self, protocol):
        self.protocol = protocol
        self.output = io.BytesIO()
    def write(self, data):
        self.output.write(data)
    def loseConnection(self):
        self.output.close()
        self.protocol.connectionLost(None)

我们可以用它来为我们的PingPongProtocol编写一个单元测试套件:

import unittest

class PingPongProtocolTests(unittest.TestCase):
    def setUp(self):
        self.maximum = 100
        self.protocol = PingPongProtocol("client", maximum=self.maximum)
        self.transport = BytesTransport(self.protocol)
        self.protocol.makeConnection(self.transport)
    def test_firstByteWritten(self):
        self.assertEqual(len(self.transport.output.getvalue()), 1)
    def test_byteWrittenForByte(self):
        self.protocol.dataReceived(b"*")
        self.assertEqual(len(self.transport.output.getvalue()), 2)
    def test_receivingMaximumLosesConnection(self):
        self.protocol.dataReceived(b"*" * self.maximum)
        self.assertTrue(self.transport.output.closed)

这个测试断言了我们为PingPongProtocol设置的需求,而没有设置任何套接字或执行任何实际的 I/O。我们可以测试我们程序的效应,而没有具体的原因。相反,我们通过用字节调用我们的协议实例的dataReceived方法来模拟可读事件,而协议通过在我们的字节传输上调用write来生成可写事件,并通过调用loseConnection来生成关闭请求。

Twisted 努力分开原因和结果。如前所述,最明显的好处是可测试性。为事件驱动的 Twisted 程序编写全面的测试更容易,因为协议和传输之间有基本的区别。事实上,Twisted 将责任之间的这种区别作为设计中的一门深刻课程,从而产生了其庞大且有时晦涩难懂的词汇。让这么多东西明确地分离对象需要大量的名称。

我们现在准备用 Twisted 编写一个事件驱动的程序。我们将会遇到我们在玩具例子中遇到的同样的设计问题,而编写这些玩具的经验将会阐明 Twisted 为解决这些问题所提供的策略。

Twisted 的现实世界

我们从实现我们的PingPongProtocol客户机和服务器开始探索 Twisted:

from twisted.internet import protocol, reactor

class PingPongProtocol(protocol.Protocol):
    def __init__ (self):
        self._received = 0
    def connectionMade(self):
        self.transport.write(b'*')
    def dataReceived(self, data):
        self._received += len(data)
        if self.factory._maximum is not None and self._received >= self.factory._maximum:
            print(self.factory._identity, "is closing the connection")
            self.transport.loseConnection()
        else:
            self.transport.write(b'*')
            print(self.factory._identity,"wrote a byte")
    def connectionLost(self, exceptionOrNone):
        print(self.factory._identity,"lost the connection:", exceptionOrNone)

class PingPongServerFactory(protocol.Factory):
    protocol = PingPongProtocol
    _identity = "Server"

    def __init__ (self, maximum=None):
        self._maximum = maximum

class PingPongClientFactory(protocol.ClientFactory):
    protocol = PingPongProtocol
    _identity = "Client"

    def __init__ (self, maximum=None):
        self._maximum = maximum

    listener=reactor.listenTCP(port=0,
                               factory=PingPongServerFactory(),
                               interface='127.0.0.1')
    address = listener.getHost()
    reactor.connectTCP(host=address.host,
                       port=address.port,
                       factory=PingPongClientFactory(maximum=100))
    reactor.run()

我们的PingPongProtocol类与我们的玩具实现几乎相同。有三个变化:

  1. 我们继承自twisted.internet.protocol.Protocol。这个类提供了重要功能的有用的默认实现。在最初设计 Twisted 的传输和协议时,继承是一种流行的代码重用方法。围绕公共和私有 API 的困难以及关注点的分离已经正确地导致了它的受欢迎程度的下降。对继承缺点的完整讨论超出了本章的范围,但是我们不建议编写依赖继承的新 API!

  2. 我们用connectionMade替换了makeConnection,这是一个事件处理程序,当底层连接就绪时,它会 Twisted 调用。Twisted 的Protocol类为我们实现了makeConnection,并留下了connectionMade作为我们可以填充的存根。实际上,我们不希望改变传输与协议的关联方式,但是我们经常希望代码在连接就绪后立即运行。这个处理程序提供了这样做的方法。

  3. 最大字节数和协议标识不再是实例变量;相反,它们是新的factory实例变量的属性。

协议工厂协调协议的创建及其与传输的绑定。这是我们第一个 Twisted 将责任本地化到类的例子。协议工厂有两种基本类型:服务器和客户端。顾名思义,一个管理服务器端协议的创建,而另一个管理客户端协议的创建。两者都通过不带参数地调用它们的protocol属性来创建协议实例。这就是为什么PingPongProtocol的初始化器不接受参数。

PingPongServerFactory子类化twisted.internet.protocol.Factory并将它的_identity属性设置为"Server."它的初始化器接受反应器作为参数和可选的最大值。然后,它依赖其超类的实现来创建其协议的实例——在类级别设置为PingPongProtocol——并将它们与自身相关联。这就是为什么PingPongProtocol实例有一个factory属性:Factory默认为我们创建。

PingPongClientFactory子类化twisted.internet.protocol.ClientFactory,并将其_identity属性设置为"Client.",其他方面与PingPongServerFactory相同。

工厂为存储所有协议实例共享的状态提供了一个方便的地方。因为协议实例对于连接来说是唯一的,所以当连接存在时,它们就不再存在,并且不能自己保持状态。因此,像我们的最大允许值和我们的协议客户机或服务器标识字符串这样的设置转移到它们的工厂遵循 Twisted 中的一个常见模式。

reactor公开了listenTCPconnectTCP方法,它们将工厂与服务器和客户端连接相关联。listenTCP返回一个Port对象,其getHost方法类似于socket.getsockname。然而,它不是返回一个元组,而是返回一个twisted.internet.address.IPv4Address的实例,该实例又具有方便的hostport属性。

最后,我们通过调用run来启动reactor,就像我们对玩具实现所做的那样。迎接我们的是类似于我们的玩具实现打印的输出:

Client wrote a byte
Server wrote a byte
Client wrote a byte
Server wrote a byte
Client wrote a byte
Server wrote a byte
Client wrote a byte
Server wrote a byte
Client is closing the connection
Client lost the connection: [Failure instance: ...: Connection was closed cleanly.
]
Server lost the connection: [Failure instance: ...: Connection was closed cleanly.
]

抛开传递给connectionLostFailure对象(我们将在 Twisted 中讨论异步编程)不谈,这个输出似乎证明了我们的新实现的行为与旧实现的行为相匹配。

然而,通过修改我们的协议测试,我们可以做得比比较输出更好:

from twisted.trial import unittest
from twisted.test.proto_helpers import StringTransportWithDisconnection, MemoryReactor

class PingPongProtocolTests(unittest.SynchronousTestCase):
    def setUp(self):
        self.maximum = 100
        self.reactor = MemoryReactor()
        self.factory = PingPongClientFactory(self.reactor,self.maximum)
        self.protocol = self.factory.buildProtocol(address.IPv4Address(
            "TCP","localhost",1234))
        self.transport = StringTransportWithDisconnection()
        self.protocol.makeConnection(self.transport)
        self.transport.protocol = self.protocol
    def test_firstByteWritten(self):
        self.assertEqual(len(self.transport.value()), 1)
    def test_byteWrittenForByte(self):
        self.protocol.dataReceived(b"*")
        self.assertEqual(len(self.transport.value()), 2)
    def test_receivingMaximumLosesConnection(self):
        self.protocol.dataReceived(b"*" * self.maximum)
        self.assertFalse(self.transport.connected)

Twisted 有自己的测试基础设施,我们将在异步编程的讨论中涉及到它;现在,我们可以将SynchronousTestCase视为等同于标准库的unittest.TestCase。我们的setUp方法现在构建了一个MemoryReactor赝品,它代替了我们真正的反应堆。它将其传递给PingPongClientFactory,然后通过调用从ClientFactory继承的buildProtocol方法构建一个PingPongProtocol客户端。这又需要一个地址参数,为此我们提供了另一个假参数。然后我们使用 Twisted 的内置StringTransportWithDisconnection,它的行为和接口与我们的 toy BytesTransport实现一致。Twisted 称之为StringTransport,因为在编写它的时候,所有发布的 Python 版本都有一个默认的字符串类型bytes。在 Python 3 的世界中,StringTransport已经成为一个误称,因为它仍然必须以字节为单位工作。

我们的测试方法调整到StringTransportWithDisconnection的接口:value返回写入的内容,而connected在协议调用loseConnection时变成False

客户端和服务器的 Twisted 实现清楚地表明了 Twisted 和我们的示例代码之间的相似之处:反应器多路复用来自套接字的事件,并通过传输将它们分派给协议,然后协议可以通过它们的传输创建新的事件。

虽然这种动态形成了 Twisted 的事件驱动架构的核心,并通知其设计决策,但它是相对较低的级别。许多程序从不实现自己的Protocol子类。接下来,我们转向一种事件,它是许多 Twisted 程序中直接使用的模式和 API 的基础。

时间上的事件

到目前为止,我们看到的所有事件都源于输入,比如用户点击按钮或新数据到达套接字。程序必须经常安排动作在未来某个时间点运行,与任何输入分开。考虑一个心跳:每 30 秒左右,网络应用将向其连接写入一个字节,以确保远程终端不会因为不活动而关闭它们。

Twisted 提供了一个底层接口,通过reactor.callLater来安排未来的行动。我们通常不直接调用这个 API,但是现在将这样做来解释它是如何工作的。

from twisted.internet import reactor

reactor.callLater(1.5, print,"Hello from the past.")
reactor.run()

reactor.callLater接受数字延迟和可调用。当调用 callable 时,任何其他位置或关键字参数都会传递给它。运行该程序将不会产生任何输出,直到大约 1.5 秒后,此时Hello from the past将会出现。

reactor.callLater返回一个可以取消的DelayedCall实例:

from twisted.internet import reactor

call = reactor.callLater(1.5, print,"Hello from the past.")
call.cancel()
reactor.run()

这个程序没有输出,因为DelayedCall在反应器运行它之前就被取消了。

显然reactor.callLater发出一个事件,表明指定的时间已经过去,并运行它作为该事件的处理程序接收的可调用程序。然而,这种情况发生的机制还不太清楚。

幸运的是,实现基本上很简单,这也说明了为什么延迟只是近似值。回想一下,select接受一个可选的超时参数。当我们希望select立即告诉我们已经生成了什么事件,而不是等待新的事件时,我们用 0 作为超时来调用它。除了基于套接字的事件之外,我们现在可以使用这个超时来复用基于时间的事件:为了确保我们的DelayedCall运行,我们可以调用select,超时时间等于应该调度的下一个DelayedCall的延迟,也就是时间上最近的那个。

想象一个包含以下内容的程序:

reactor.callLater(2, functionB)
reactor.callLater(1, functionA)
reactor.callLater(3, functionC)
reactor.run()

reactor 将DelayedCall记录在一个 min-heap 中,按照它计划运行的挂钟时间排序:

def callLater(self, delay, f,*args,**kwargs):
    self._pendingCalls.append((time.time()+delay, f, args, kwargs)
    heapq.heapify(self._pendingCalls)

如果第一个reactor.callLater发生在时间 t ,并且每个调用不占用时间,那么在所有三个调用之后,pendingCalls将如下所示:

[
    (t+1, <DelayedCall: functionA>),
    (t+2, <DelayedCall: functionB>),
    (t+3, <DelayedCall: functionB>),
]

向堆中添加一个元素的时间复杂度为 O(log n ),因此重复的callLater调用的总最坏情况时间复杂度为 O( n log n )。如果反应器改为排序_pendingCalls,重复的callLater调用将取 O(n)* O(nlogn)= O(n2)。

现在,在反应堆进入select,之前,它检查是否有任何未决的DelayedCalls;如果有,它提取堆的顶部元素,并使用其目标运行时间和当前时间之差作为select的超时。然后,在处理任何套接字事件之前,它从堆中弹出每个时间已过的元素并运行它,跳过取消的调用。如果没有未决的DelayCall,反应器调用select,超时None,表示没有超时。

class Reactor(object):
    ...
    def run(self):
        while self.running:
            if self._pendingCalls:
                targetTime, _ = self._pendingCalls[0]
                delay=targetTime-time.time()
            else:
                targetTime = None
            r, w, _ = select.select(self.readers,self.writers, [], targetTime)
            now = time.time()
            while self._pendingCalls and (self._pendingCalls[0][0] <= now):
                targetTime, (f, args, kwargs) = heapq.heappop()
                if not call.cancelled:
                    f(*args,**kwargs)
            ...

在我们的三个reactor.callLater调用中,functionA的延迟最短,因此位于pendingCalls堆的顶部。如果我们的反应器的run循环随后立即开始(即,也在时间 t ),那么delay变量将为( t + 1) - t = 1,并且select调用将在不超过一秒钟后返回。现在,time.time返回 t + 1,所以functionADelayedCall,从而functionA运行。然而,functionBfunctionCDelayedCall仍然留在将来,因此内部while循环结束,过程再次开始。

该实现揭示了为什么DelayedCall在延迟过后不立即运行:它们的调用取决于它们在pendingCalls堆中的位置以及前面的DelayedCall需要多长时间来完成。如果functionA运行的时间超过一秒钟,functionB就会运行得比截止时间晚。这对于延迟相同时间的DelayedCall s 来说尤其可能。

使用LoopingCall重复事件

足以实现我们的心跳。我们可以定义一个用自身调用callLater的函数,然后通过直接调用该函数一次来启动间接递归:

def f(reactor, delay)
    reactor.callLater(delay, f, reactor, delay)
f(reactor,1.0)

这是可行的,但是很笨拙。在对f的初始呼叫之后,我们不能访问代表对f的下一次呼叫的DelayedCall,所以如果对方终止连接,我们不能轻易取消它。我们可以手动跟踪这些呼叫,但幸运的是,Twisted 提供了一个方便的包装器callLater,为我们处理这一切:twisted.internet.task.LoopingCall。下面是一个使用LoopingCall来实现心跳的协议:

from twisted.internet import protocol, task

class HeartbeatProtocol(protocol.Protocol):
    def connectionMade(self):
        self._heartbeater = task.LoopingCall(self.transport.write, b"*")
        self._heartbeater.clock = self.factory._reactor
        self._heartbeater.start(interval=30.0)
    def connectionLost(self):
        self._heartbeater.stop()

class HeartbeatProtocolFactory(protocol.Factory):
    protocol = HeartbeatProtocol
    def __init__ (self, reactor):
        self._reactor = reactor

该协议创建了一个新的LoopingCall实例,它将在连接建立时向协议的传输写入一个星号。然后它用工厂的反应堆替换了LoopingCall的时钟;我们很快就会看到,这种间接方式有助于测试。最后,该协议以 30 秒的间隔启动LoopingCall,这样大约每 30 秒它就会用一个星号调用transport.writeLoopingCall从什么时候开始计时 30 秒?它是从 0 开始计数,在这种情况下应该立即调用它的函数,还是从 1 开始计数,在这种情况下应该等待整整 30 秒?答案取决于程序员。LoopingCall.start的第二个可选参数now决定了该函数是应该作为对start的调用的一部分被调用,还是在一个完整的间隔过去之后被调用。它默认为True,所以我们的心跳会立即向传输写一个星号。

从工厂取回反应堆使得HeartbeatProtocolPingPongProtocol一样容易测试:

from twisted.trial import unittest
from twisted.internet import main, task
from twisted.test.proto_helpers import StringTransportWithDisconnection

class HeartbeatProtocolTests(unittest.SynchronousTestCase):
    def setUp(self):
        self.clock = task.Clock()
        self.factory = HeartbeatProtocolFactory(self.clock)
        self.protocol = self.factory.buildProtocol(address.IPv4Address(
            "TCP","localhost",1234))
        self.transport = StringTransportWithDisconnection()
        self.protocol.makeConnection(self.transport)
        self.transport.protocol = self.protocol
    def test_heartbeatWritten(self):
        self.assertEqual(len(self.transport.value()), 1)
        self.clock.advance(60)
        self.assertEqual(len(self.transport.value()), 2)
    def test_lostConnectionStopsHeartbeater(self):
        self.assertTrue(self.protocol._heartbeater.running)
        self.protocol.connectionLost(main.CONNECTION_DONE)
        self.assertFalse(self.protocol._heartbeater.running)

HeartbeatProtocolTest.setUpPingPongProtocolTests.setUp几乎相同,除了它用twisted.internet.task.Clock代替MemoryReactorClock,顾名思义,提供了一个反应器的时间相关接口的实现。最重要的是,它有一个callLater方法:

>>> from twisted.internet.task import Clock
>>> clock = Clock()
>>> clock.callLater(1.0, print,"OK")

因为它们旨在单元测试中使用,Clock实例自然没有自己的select循环。我们可以通过调用advance来模拟select超时的终止:

>>> clock.advance(2)
OK

test_heartbeatWritten调用advance使其协议的LoopingCall写入一个字节。这类似于PingPongProtocolTests.test_byteWrittenForByte对其协议的dataReceived的调用;两者都模拟了反应堆在这些测试之外管理的事件的发生。

Twisted 的事件驱动编程方法依赖于清晰描述的接口,如ProtocolClock的接口。然而,到目前为止,我们都认为每个接口的本质是理所当然的:我们怎么知道ClockMemoryReactor可以取代测试套件中的真实反应器呢?我们可以通过探索 Twisted 用来管理其接口的工具来回答这个问题。

与 zope.interface 的事件接口

Twisted 使用一个名为zope.interface的包来形式化它的内部接口,包括那些描述它的事件驱动范例的接口。

Zope 是一个古老但仍然活跃的项目,已经产生了几个 web 应用框架,其中最老的是在 1998 年首次公开发布的。许多技术起源于 Zope,并被用于其他项目。Twisted 使用 Zope 的接口包来定义它的接口。

zope.interface的完整解释超出了本书的范围。然而,接口在测试和文档中起着重要的作用,所以我们通过研究前面例子中使用的 Twisted 类的接口来介绍它们。

我们首先询问Clock的一个实例,它提供了什么接口:

>>> from twisted.internet.task import Clock
>>> clock = Clock()
>>> from zope.interface import providedBy
>>> list(providedBy(clock))
[<InterfaceClass twisted.internet.interfaces.IReactorTime>]

首先,我们创建一个Clock的实例。然后我们从zope.interface包中检索providedBy;因为 Twisted 本身依赖于zope.interface,所以我们可以在交互会话中使用它。在我们的Clock实例上调用providedBy会返回它提供的接口的一个 iterable。

与其他语言的接口不同,zope.interface的接口可以是实现的提供的。符合接口的单个对象提供该接口,而创建那些提供接口的对象实现该接口。这种微妙的区别与 Python 的“鸭子打字”相匹配。一个接口定义可能描述一个call方法,并因此应用于一个用deflambda创建的函数对象。这些语法元素不能被标记为我们接口的实现者,但是功能对象本身可以说是提供了它。

一个接口zope.interface.Interface的子类,它使用一个特殊的 API 来描述所需的方法和它们的签名以及属性。下面是我们的Clock提供的twisted.internet.interfaces.IReactorTime接口的摘录:

class IReactorTime(Interface):
    """
    Time methods that a Reactor should implement.
    """

def callLater(delay, callable,*args,**kw):
    """
    Call a function later.

    @type delay:  C{float}
    @param delay: the number of seconds to wait.

    @param callable: the callable object to call later.

    @param args: the arguments to call it with.

    @param kw: the keyword arguments to call it with.

    @return: An object which provides L{IDelayedCall} and can be used to
             cancel the scheduled call, by calling its C{cancel()} method.
             It also may be rescheduled by calling its C{delay()} or
             C{reset()} methods.
    """

注意,callLater“方法”没有self参数。这是接口不能实例化的结果。它也没有主体,而是通过只提供一个 docstring 来满足 Python 的函数定义语法。不像抽象类,比如那些由标准库的abc模块提供的,它们也不能包含任何实现代码。相反,它们只是作为描述对象功能子集的标记而存在。

Zope 提供了一个名为verifyObject的助手,如果一个对象没有提供接口,它会抛出一个异常:

>>> from zope.interface.verify import verifyObject
>>> from twisted.internet.interfaces import IReactorTime
>>> verifyObject(IReactorTime, clock)
True
>>> verifyObject(IReactorTime, object()))
Traceback (most recent call last):
  File"<stdin>", line1, in <module>
  ...
zope.interface.exceptions.DoesNotImplement: An object does not implement interface<Interface

我们可以用这个来确认反应器提供了与Clock实例相同的IReactorTime接口:

>>> from twisted.internet import reactor
>>> verifyObject(IReactorTime, reactor) True

稍后当我们编写自己的接口实现时,我们将回到verifyObject。不过现在,只要知道我们可以在任何依赖IReactorTime.callLater的地方用Clock实例替换反应器就足够了。一般来说,如果我们知道一个对象所提供的接口包含了我们所依赖的方法或属性,我们就可以用其他提供相同接口的对象来替换这个对象。虽然我们可以用providedBy交互地发现一个对象提供的接口,但是 Twisted 的在线文档对接口有特殊的支持。图 1-2 描述了 Twisted 网站上Clock的文档。

img/455189_1_En_1_Fig2_HTML.jpg

图 1-2

twisted.internet.task.Clock文档。虚线框突出显示了到IReactorTime接口的链接。

Clock类实现的接口在虚线矩形中突出显示。单击每一个都可以看到该接口的文档,其中包括所有已知实现者和提供者的列表。如果您知道对象是什么,那么您可以通过访问它的文档来确定它的接口。

我们接下来讨论一个问题,这个问题的 Twisted 解决方案涉及到定义接口的实现者。

事件驱动程序中的流量控制

PingPongProtocol不同于我们为上一个非 Twisted 事件驱动的示例编写的流协议:PingPongProtocol中的每一端都写入一个字节来响应接收到的字节,而流协议让客户端向服务器发送越来越大的字节序列,当服务器不堪重负时暂停其写入。调整发送方的写入速率以匹配接收方的读取速率被称为流量控制

当与事件驱动的编程相结合时,非阻塞 I/O 使我们能够编写在任何给定时间可以响应许多不同事件的程序。同步 I/O,就像我们看到的根据sendall实现的流客户端协议,暂停或阻塞我们的程序,阻止它做任何事情,直到 I/O 操作完成。虽然这增加了并发性的难度,但它使流控制变得更加容易:超过其读取器速度的编写器会被操作系统暂停,直到读取器接受挂起的数据。在我们的流客户端中,这导致了死锁,因为慢速读者运行在由于写得太快而暂停的同一进程中,因此永远无法跟上。更常见的情况是,读取器和写入器运行在不同的进程中,如果不是在不同的机器上,它们的同步、阻塞 I/O 自然提供了流控制。

然而,在网络应用中很少遇到简单的阻塞 I/O。即使是最简单的也必须为每个连接同时管理两件事:数据通信和与每个 I/O 操作相关的超时。Python 的socket模块允许程序员在recvsendall操作上设置这些超时,但在幕后这是通过调用带有超时的select来实现的!

我们有实现流控制所必需的事件。select通知我们可写事件,而EAGAIN表明套接字的发送缓冲区已满,从而间接表明接收者不堪重负。我们可以组合这些来暂停和恢复写入程序,并实现类似于阻塞 I/O 所提供的流控制。

流控制与生产者和消费者纠缠在一起

Twisted 的流量控制系统有两个组成部分:生产者消费者。生产者通过调用消费者的write方法向消费者写入数据。消费者包装生产者;每个消费者可以与一个生产者相关联。这种关系确保了消费者可以访问其生产者,因此它可以通过调用生产者的某些方法来调节数据流,从而对生产者施加反压力。常见的传输,比如绑定到像我们的PingPongProtocol这样的协议的 TCP 传输,既可以是消费者也可以是生产者。

我们通过重新实现我们预先 Twisted 的流客户端示例来探索生产者和消费者之间的交互。

推动生产者

我们从客户的制作人开始:

from twisted.internet.interfaces import IPushProducer
from twisted.internet.task import LoopingCall
from zope.interface import implementer

@implementer(IPushProducer)
class StreamingProducer(object):
    INTERVAL=0.001
    def __init__ (self, reactor, consumer):
        self._data = [b"*", b"*"]
        self._loop = LoopingCall(self._writeData, consumer.write)
        self._loop.clock = reactor
    def resumeProducing(self):
        print("Resuming client producer.")
        self._loop.start(self.INTERVAL)
    def pauseProducing(self):
        print("Pausing client producer.")
        self._loop.stop()
    def stopProducing(self):
        print("Stopping client producer.")
        if self._loop.running:
            self._loop.stop()
    def _writeData(self, write):
        print("Client producer writing", len(self._data),"bytes.")
        write(b"".join(self._data))
        self._data.extend(self._data)

我们的生产者StreamingProducer,实现twisted.internet.interfaces.IPushProducer。该接口描述了不断向其消费者写入数据直到暂停的生产者。StreamingProducer上的以下方法满足IPushProducer接口:

  • resumeProducing:恢复或启动向消费者写入数据的过程。因为我们的实现通过在每次写入后将一个字节序列加倍来生成数据,所以它需要某种类型的循环来向其消费者提供连续的流。简单的while循环是行不通的:如果不将控制权交还给反应器,程序就不能处理新的事件,直到循环终止。事件驱动的程序(如 web 浏览器)在大文件上传期间会有效地暂停其执行。StreamingProducer通过一个LoopingCall实例将写循环委托给反应器来避免这种情况,因此它的resumeProducing方法启动了那个LoopingCall。一毫秒的间隔是任意低的。我们的生产者不能比这更快地写入数据,所以间隔是延迟的来源,一毫秒可以接受地最小化它。

  • pauseProducing:暂停向消费者写入数据的过程。消费者称这表明它已经不堪重负,无法接受更多的数据。在我们的实现中,停止底层的LoopingCall就足够了。当底层资源可以接受更多数据时,消费者可以稍后调用resumeProducing。这个resumeProducingpauseProducing调用的循环构成了流量控制。

  • stopProducing:这终止了数据的产生。这与pauseProducing不同,因为在调用stopProducing之后,消费者再也不能调用resumeProducing来接收更多的数据。最明显的是,当一个套接字连接被关闭时,它被调用。StreamingProducer的实现与pauseProducing方法的唯一不同之处在于,它必须首先检查循环调用是否正在运行。这是因为当生产者已经暂停时,消费者可能请求不再写入数据。更复杂的推送生产者将执行额外的清理;例如,从一个文件传输数据的生产者需要在这里关闭该文件,以将其资源释放回操作系统。

请注意,IPushProducer并没有指定它的实现者如何向消费者写入数据,甚至如何访问数据。这使得界面更加灵活,但也使其更难实现。StreamingProducer遵循一种典型的模式,在其初始化器中接受消费者。我们将很快介绍完整的消费者接口,但是现在,知道消费者必须提供一个write方法就足够了。

我们可以测试StreamingProducer实现了IPushProducer的预期行为:

from twisted.internet.interfaces import IPushProducer
from twisted.internet.task import Clock
from twisted.trial import unittest
from zope.interface.verify import verifyObject

class FakeConsumer(object):
    def __init__ (self, written):
        self._written = written
    def write(self, data):
        self._written.append(data)

class StreamingProducerTests(unittest.TestCase):
    def setUp(self):
        self.clock = Clock()
        self.written = []
        self.consumer = FakeConsumer(self.written)
        self.producer = StreamingProducer(self.clock,self.consumer)
    def test_providesIPushProducer(self):
        verifyObject(IPushProducer,self.producer)
    def test_resumeProducingSchedulesWrites(self):
        self.assertFalse(self.written)
        self.producer.resumeProducing()
        writeCalls = len(self.written)
        self.assertEqual(writeCalls,1)
        self.clock.advance(self.producer.INTERVAL)
        newWriteCalls = len(self.written)
        self.assertGreater(newWriteCalls, writeCalls)
    def test_pauseProducingStopsWrites(self):
        self.producer.resumeProducing()

        writeCalls = len(self.written)
        self.producer.pauseProducing()
        self.clock.advance(self.producer.INTERVAL)
        self.assertEqual(len(self.written), writeCalls)
    def test_stopProducingStopsWrites(self):
        self.producer.resumeProducing()
        writeCalls = len(self.written)
        self.producer.stopProducing()
        self.clock.advance(self.producer.INTERVAL)
        self.assertEqual(len(self.written), writeCalls)

FakeConsumer接受一个列表,每个write调用都会将收到的数据附加到该列表中。这允许测试套件断言StreamingProducer已经在预期的时候调用了它的消费者的write方法。

test_providesIPushProducer确保StreamingProducer定义了IPushProducer要求的方法。如果没有,这个测试将通过zope.interface.exceptions.DoesNotImplement失败。像这样断言实现满足其接口的测试在开发和重构中是一个有用的高通过滤器。

test_resumeProducingSchedulesWrites断言调用resumeProducing意味着向消费者写入数据,并且每次经过指定的时间间隔,都会写入更多的数据。test_pauseProducingStopsWritestest_stopProducingStopsWrites都断言相反的情况:调用pauseProducingstopProducing防止在每个间隔过去后发生进一步的写操作。

顾客

StreamingProducer放出数据却无处安放。为了完成我们的流媒体客户端,我们需要一个消费者StreamingProducer的初始化器清楚地表明,消费者的接口必须提供一个write方法,概述表明,额外的消费者方法管理与生产者的交互。twisted.internet.interfaces.IConsumer要求实施者实施三种方法:

  • write:接受来自生产者的数据。这是在我们上面的测试中由FakeConsumer提供的唯一方法,因为它是IConsumer接口IPushProducer调用的唯一部分。

  • registerProducer:这将生产者与消费者关联起来,确保它可以调用生产者的resumeProducingpauseProducing来调节数据流,调用stopProducing来终止数据流。这接受了两个论点:生产者和一面streaming旗帜。我们稍后将解释这第二个论点的目的;现在,知道我们的流媒体客户端会将此设置为True就足够了。

  • 这就把生产者和消费者分开了。一个消费者可能在其一生中接受来自多个生产者的数据;再考虑一个 web 浏览器,它可能通过单个连接向服务器上传多个文件。

IConsumer实现者和传输者都公开了write方法,这并不是巧合;如上所述,绑定到连接协议的 TCP 传输是一个消费者,我们可以向其注册一个StreamingProducer实例。我们可以修改我们的PingPongProtocol示例,在成功连接后用其底层传输注册StreamingProducer:

from twisted.internet import protocol, reactor
from twisted.internet.interfaces import IPushProducer
from twisted.internet.task import LoopingCall
from zope.interface import implementer

@implementer(IPushProducer)
class StreamingProducer(object):
    INTERVAL=0.001
    def __init__ (self, reactor, consumer):
        self._data = [b"*", b"*"]
        self._loop = LoopingCall(self._writeData, consumer.write)
        self._loop.clock = reactor
    def resumeProducing(self):
        print("Resuming client producer.")
        self._loop.start(self.INTERVAL)
    def pauseProducing(self):
        print("Pausing client producer.")
        self._loop.stop()
    def stopProducing(self):
        print("Stopping client producer.")

        if self._loop.running:
            self._loop.stop()
    def _writeData(self, write):
        print("Client producer writing", len(self._data),"bytes.")
        write(b"".join(self._data))
        self._data.extend(self._data)

class StreamingClient(protocol.Protocol):
    def connectionMade(self):
        streamingProducer = StreamingProducer(
            self.factory._reactor,self.transport)
        self.transport.registerProducer(streamingProducer,True)
        streamingProducer.resumeProducing()

class ReceivingServer(protocol.Protocol):
    def dataReceived(self, data):
        print("Server received", len(data),"bytes.")

class StreamingClientFactory(protocol.ClientFactory):
    protocol = StreamingClient
    def __init__ (self, reactor):
        self._reactor = reactor

class ReceivingServerFactory(protocol.Factory):
    protocol = ReceivingServer

listener = reactor.listenTCP(port=0,
                           factory=ReceivingServerFactory(),
                           interface='127.0.0.1')
address = listener.getHost()
reactor.connectTCP(host=address.host,
                   port=address.port,
                   factory=StreamingClientFactory(reactor))
reactor.run()

StreamingClient协议创建一个StreamingProducer,然后向其传输注册。如前所述,registerProducer的第二个参数是True。然而,注册一个生产者并不会自动恢复它,所以我们必须通过调用resumeProducing来开始StreamingProducer的写循环。注意,StreamingClient从不调用它的生产者的stopProducing:当反应堆发出断开信号时,transports 代表它们的协议调用这个。

运行此命令会产生如下输出:

Resuming client producer.
Client producer writing 2 bytes.
Server received 2 bytes.
Client producer writing 4 bytes.
Server received 4 bytes.
Client producer writing 8 bytes.
Server received 8 bytes.
...
Client producer writing 524288 bytes.
Pausing client producer.
Server received 65536 bytes.
Server received 65536 bytes.
Server received 65536 bytes.
Server received 65536 bytes.
Resuming client producer.
Client producer writing 1048576 bytes.
Pausing client producer.
...

最终,程序将消耗所有可用的内存,从而构成一个成功的流量控制实验。

拉动生产者

存在第二个生产者接口:twisted.internet.interfaces.IPullProducer。不像IPushProducer,它只在它的resumeProducing方法被调用时写给它的消费者。这就是IConsumer.registerProducer的第二个论点的目的:IPullProducer s 要求streamingFalse。不写IPullProducer s!大多数传输的行为类似于套接字,并生成可写事件,从而消除了对类似StreamingProducer的写循环的需要。当数据必须手动从源中抽出时,编写和测试LoopingCall反而更容易。

摘要

我们已经看到事件驱动编程如何将程序分成事件和它们的处理程序。程序发生的任何事情都可以被建模为事件:来自用户的输入、通过套接字接收的数据,甚至是时间的流逝。一个事件循环使用一个多路复用器来等待任何可能发生的事件,为那些已经发生的事件运行适当的处理程序。操作系统提供底层接口,比如select,来复用网络套接字 I/O 事件。使用select的事件驱动网络编程在使用非阻塞时最为有效,它为sendrecv等操作生成事件,指示程序应该停止运行事件处理程序。

由非阻塞套接字发出的停止事件会导致没有正确抽象的复杂代码。协议传输原因结果之间划分程序代码:传输将读取、写入和停止事件翻译成协议可以响应的更高级原因,依次生成新事件。协议和传输之间的这种责任划分允许实现事件处理程序,通过用内存中的假货替换传输,可以很容易地测试这些事件处理程序。稍后,我们将看到协议-传输分离的其他实际好处。

协议、传输和反应器——事件循环的名称——是 Twisted 运行的基础,并贯穿其整体架构。Twisted 的反应器可以对非 I/O 事件做出反应,比如时间的流逝。测试这些并不比测试协议更困难,因为反应堆,像传输一样,在内存中有假货。Twisted 形式化了反应器和其他对象必须通过zope.interface实现的接口。通过确定一个对象提供了什么接口,就有可能选择一个适合测试的替代品,它保证是等价的,因为它提供了相同的接口。Twisted 的在线文档使得在 Python 会话中发现接口比检查活动对象更容易。

接口的一个实际应用是 Twisted 对事件驱动的网络编程难以解决的问题的解决方案:流量控制。IPushProducerIConsumer定义了一组行为,允许流数据的接收者在不堪重负时暂停数据源。

这个介绍足以解释 Twisted 中事件驱动编程的核心原则。然而,还有更多:在下一章,我们将了解 Twisted 如何通过允许程序处理尚未计算的值来进一步简化事件驱动编程。**

二、Twisted 异步编程简介

前一章从基本原则中推导出 Twisted 的事件驱动架构。Twisted 程序和所有事件驱动的程序一样,以增加数据流控制的难度为代价,使并发变得更容易。当事件驱动程序发送的数据超过接收方的处理能力时,它不会通过阻塞 I/O 来自动暂停执行。确定这种情况何时发生以及如何处理是程序的责任。

通信方之间的数据流动方式也会影响数据在单个程序中的流动方式。因此,组成一个事件驱动应用的不同组件的策略不同于阻塞程序中使用的策略。

事件处理程序和合成

考虑一个不是事件驱动的程序,它使用阻塞 I/O 来执行网络操作:

def requestField(url, field):
    results = requests.get(url).json()
    return results[field]

requestFieldrequests HTTP 库检索一个 URL,将响应的主体解码为 JSON,然后从结果字典中返回所请求的field属性的值。requests使用阻塞 I/O,所以对requestField的调用会暂停整个程序,直到 HTTP 请求所需的网络操作完成。因此,该函数可以假设在它返回之前,results将可用于操作。这个函数的调用者可以做出同样的假设,因为requestField会阻塞它们,直到它计算出结果:

def someOtherFunction(...):
    ...
    url = calculateURL(...)
    value = requestField(url, 'someInteger')
    return value + 1

x = someOtherFunction(...)

requestField从 JSON 响应中检索到 URL 并提取出someInteger属性的值之前,someOtherFunction和顶级x赋值都无法完成。这是一种合成 : someOtherFunction调用requestField来完成自己执行的一部分。我们可以通过显式函数组合使这一点更清楚:

def someOtherFunction(value):
    return value + 1

x = someOtherFunction(requestField(calculateURL(...), 'someInteger'))

这段代码用嵌套的函数调用替换了someOtherFunction的局部变量,但在其他方面是等效的。

功能组合是组织程序的基本工具。它允许一个程序被分解,或者分解成独立的单元,形成一个整体,其行为与未分解的版本完全匹配。这提高了可读性、可重用性和可测试性。

不幸的是,事件处理程序不能像someOtherFunctionrequestFieldcalculateURL那样构成。考虑一个假设的非阻塞版本的requestField:

def requestField(url, field):
    ??? = nonblockingGet(url)

在非阻塞版本的requestField中,什么可以取代????这是一个很难回答的问题,因为nonblockingGet不会暂停程序的执行来完成构成对url的 HTTP 请求的网络操作;相反, requestField外的一个事件循环复用可读和可写的事件,只要有可能就调用事件处理程序发送和接收数据。没有明显的方法可以从我们假设的nonblockingGet函数中返回event handlers'值。

幸运的是,通过将事件处理程序表示为函数,我们可以利用函数组合的通用性将事件驱动的程序分解成独立的组件。让我们假设假设的nonblockingGet函数本身接受一个事件处理函数作为参数,当请求的完成事件发生时,它调用这个函数。这个较高级别的事件将由较低级别的事件合成,类似于我们在第一章中看到的传输为了它们的协议而发出一个connectionLost事件的方式。然后我们可以重写requestField来利用这个新论点:

def requestField(url, field):
    def onCompletion(response):
        document = json.loads(response)
        value = response[field]

    nonblockingGet(url, onCompletion=onCompletion)

onCompletion是一个回调,或者是一个可调用的对象,作为一个参数传递给一些其他可调用的对象,这些对象执行一些期望的操作。当该操作完成时,用一些相关的参数调用回调。在这种情况下,nonblockingGet在其 HTTP 请求解析为完整的响应对象时调用其onCompletion回调。在前一章的BuffersWrites实现中,我们看到了一个等价的onCompletion回调;在那里,当所有缓冲的数据都被写入套接字时,它被调用。

回调在内部组合,而其他函数,比如上面的someOtherFunction示例,在外部组合;在可调用程序的执行过程中,值可用于回调,从而获得所需的结果,而不是从该可调用程序返回。**

**与nonblockingGet提取事件驱动的 HTTP 请求代码的方式相同,requestField可以通过接受自己的回调提取字段的使用方式。我们将让requestField接受一个useField回调,然后让onCompletion回调调用它:

def requestField(url, field, useField):
    def onCompletion(response):
        document = json.loads(response)
        value = response[field]
        useField(value)

    nonblockingGet(url, onCompletion=onCompletion)

我们可以通过将someOtherFunction作为useField回调来编写一个事件驱动的程序,它相当于我们的阻塞 I/O 版本:

def someOtherFunction(useValue):
    url = calculateURL(...)
    def addValue(value):
        useValue(value + 1)
    requestField(url,"someInteger", useField=addValue)

反过来,someOtherFunction也必须通过接受其自己的回调来进行内部合成,这与之前在外部合成的calculateURL不同。这种回调驱动的方法足以编写任何程序;事实上,在计算机科学的研究中,回调可以被细化为称为延续的控制流原语,并被用于一种称为延续传递风格的技术中,在这种技术中,函数通过调用它们的延续并产生结果来终止。延续传递风格已经在各种语言编译器中使用,以支持程序分析和优化。

尽管延续传递式的理论很强大,但读起来和写起来都很笨拙。此外,外部构图——如requestFieldcalculateURL——和内部构图——如requestFielduseField——彼此之间没有明显的构图。例如,很难想象calculateURL如何会被视为回调。最后,错误处理是一个关键的因果关系;想象一下我们将如何以延续传递的方式处理异常!在这个例子中,我们有意省略了任何错误处理,以保持代码足够短,便于阅读。

幸运的是,异步编程提供了一个强大的抽象,简化了事件处理程序的组成,并解决了这些问题。

什么是异步编程?

我们最初的requestField实现是同步,因为整个程序的执行是随着时间的推移线性进行的。例如,给定对request.get的两个调用,第一个将在第二个之前完成。同步编程是一种适用于阻塞 I/O 的常见范例。包括 Python 在内的大多数编程语言都默认通过阻塞 I/O 来启用同步操作。

我们的事件驱动requestField的延续传递风格是一种异步编程:当通过nonblockingGet回调的逻辑流暂停直到必要的数据可用时,整个程序的执行继续。两个独立的nonblockingGet调用的执行将交错进行,没有保证它们完成的顺序;一个比另一个早开始并不保证它会先完成。这就是并发的定义。

利用非阻塞 I/O 的事件驱动程序必然是异步的,因为所有的 I/O 操作都是基于可以在任何时间以任何顺序到达的事件进行的。值得注意的是,异步程序不需要事件驱动的 I/O;不同的平台基于完全不同的原语提供 I/O 和调度模式。例如,Windows 提供了 I/O 完成端口(IOCP ),它通知程序请求操作的完成,而不是执行操作的机会。例如,请求 IOCP 基础设施在套接字上执行读取的程序将被通知读取完成的时间和数据。Twisted 以其 IOCP 反应器的形式对此提供了一些支持,但就我们的目的而言,我们可以将异步编程理解为事件驱动范式的脱节和零碎执行的结果,就像同步编程是阻塞 I/O 的结果一样

未来值的占位符

事件驱动程序中的回调模糊了控制流,因为它们在内部组成了;它们不是将值返回给调用者,而是将结果转发给作为参数接收的回调。这导致了应用逻辑和控制流的混合,使重构变得困难,并且在错误发生点和对错误感兴趣的代码之间出现了脱节。

引入一个表示尚未计算的值的对象允许回调在外部被组合*。考虑一下当允许返回这种占位符时,我们的非阻塞requestField示例是如何变化的:

def requestField(url, field):
    def onCompletion(response):
        document = json.load(response)

    return jsonDoc[field]
placeholder = nonblockingGet(url)
return placeholder.addCallback(onCompletion)

nonblockingGet现在返回一个占位符,这个占位符不是响应,而是一个容器,当响应准备好时,它将被放入这个容器。没有操作的容器不会提供太多好处,所以这个占位符接受它在值准备好时调用的回调。我们没有将onCompletion直接传递给nonblockingGet,,而是将其作为回调附加到占位符nonblockingGet的返回中。内部onCompletion回调的实现现在可以返回值——从 JSON 文档中提取的字段——该值将成为后续回调的参数。

requestField现在可以暂时删除自己的回调参数,并将占位符返回给someOtherFunction,后者可以添加自己的回调:

def someOtherFunction(...):
    url = calculateURL(...)
    def addValue(value)
        return value + 1
    placeholder = requestField(url,"someInteger")
    return placeHolder.addCallback(addValue)

我们的占位符值并没有完全消除回调。相反,它提供了一个控制流抽象,将回调定位到它们的起始范围,这样它们就可以在外部被组合。当多个回调处理一个异步结果时,这变得更加清晰。考虑以下内部编写的回调:

def manyCallbacks(url, useValue, ...):
    def addValue(result):
        return divideValue(result + 2)
    def divideValue(result):
        return multiplyValue(result // 3)
    def multiplyValue(result):
        return useValue(result * 4)
    requestField(url, "someInteger", onCompletion=addValue)

控制从addValue流向divideValue,最后从multiplyValue退出,进入由manyCallbacks的调用者提供的useValue回调。改变三个内部回调的顺序需要重写每一个。然而,占位符对象将该顺序移出每个回调:

def manyCallbacks(url, ...):
    def addValue(result):
        return result + 2
    def divideValue(result):
        return result // 3
    def multiplyValue(result):
        return result * 4
placeholder = requestField(url, "someInteger")
placeholder.addCallback(addValue)
placeholder.addCallback(divideValue)
placeholder.addCallback(multiplyValue)
return placeholder

divideValue不再直接依赖于multiplyValue,所以可以在multiplyValue之前移动,甚至不需要改变它或者multiplyValue就可以移除。

回调的实际组合发生在placeholder对象中,其核心实现非常简单。我们将我们的占位符类命名为Deferred,因为它代表一个延迟值——一个尚未准备好的值:

class Deferred(object):
    def __init__ (self):
        self._callbacks = []
    def addCallback(self, callback):
        self._callbacks.append(callback)
    def callback(self, result):
        for callback in self._callbacks:
            result = callback(result)

当结果可用时,Deferred实例的创建者调用callback。每个回调用当前结果调用,其返回值成为传递给下一个回调的结果。这就是上面的onCompletion如何将 HTTP 响应变成唯一感兴趣的 JSON 字段。

Deferredfor循环施加的控制流足以依次调用每个回调,但是不能比内部合成的回调更好地处理异常。解决这个问题需要添加某种分支逻辑来检测异常并将异常重新路由到它们的目的地。

异步异常处理

同步 Python 代码用tryexcept处理异常:

def requestField(url):
    response = requests.get(url).content
    try:
        return response.decode('utf-8')
    except UnicodeDecodeError:
        # Handle this case

通过addCallback方法添加到Deferred的回调在没有异常发生时运行,因此是try块的异步等价物。我们可以通过为except块引入一个类似的回调来添加错误处理,该回调接受异常作为它的参数。像这样被异常调用的回调被称为错误返回

同步代码可以通过省略tryexcept来选择让异常向上传播到它的调用者。然而,Deferred的控制流将允许回调引发的异常从for循环向上返回到Deferred.callback的调用者。这将是放置异常处理的错误位置,因为为Deferred提供值的代码不知道添加回调的代码想要的错误处理行为。将这种错误处理封装在我们传递给Deferred s 的errbacks中,允许那些Deferred s 在正确的时间调用它们,而不是麻烦Deferred.callback的调用者。

然后,在回调链的每一步,循环必须捕捉任何异常,并将其转发给下一个 errback。因为每个步骤都可能调用回调或错误返回,所以我们的callbacks列表将更改为包含(callbackerrback)对:

def passthrough(obj):
    return obj

class Deferred(object):
    def __init__ (self):
        self._callbacks = []
    def addCallback(self, callback):
        self._callbacks.append((callback, passthrough))
    def addErrback(self, errback):
        self._callbacks.append((passthrough, errback))
    def callback(self, result):
        for callback, errback in self._callbacks:
            if isinstance(result,BaseException):
                handler = errback
            else:
                handler = callback
            try:
                result = handler(result)
            except BaseExceptionas e:
                result = e

循环的每次迭代都检查当前结果。异常被传递给下一个 errback,而其他的都像以前一样被传递给下一个回调。由 errback 或回调引发的任何异常都将成为链中下一个 errback 要处理的结果。这就产生了下面的Deferred代码:

someDeferred = Deferred()
someDeferred.addCallback(callback)
someDeferred.addErrback(errback)
someDeferred.callback(value)

相当于这个同步代码:

try:
    callback(value)
except BaseExceptionas e:
    errback(e)

错误返回通过返回异常来传播异常,并通过返回任何不是而不是异常的值来抑制异常。下面的Deferred代码过滤掉 ValueErrors,同时让所有其他异常传播到下一个 errback:

def suppressValueError(exception):
    if not isinstance(exception, ValueError):
        return exception

someDeferred.addErrback(suppressValueError)

isinstance(exception, ValueError)评估为TruesuppressValueError隐式返回None,因此Deferred回调循环中的异常检查将None传递到下一个回调。每隔一个异常从suppressValueError返回,进入for循环,并继续下一个 errback。总的效果相当于下面的同步代码:

try:
    callback(value)
except ValueError:
    pass

当我们考虑它可能遇到异常的两个地方时,Deferred的新控制流的一个方便的结果变得明显:

  1. Deferred的回调列表中的任何回调都可能引发异常。例如,我们的manyCallback函数的回调序列中的一个错误可能导致addValue返回None,在这种情况下divideValue将引发一个TypeError

  2. 将实际值传递给Deferredcallback方法的代码可能会引发一个异常。例如,想象一下,nonblockingGet试图将 HTTP 响应的主体解码为 UTF-8,并使用结果回调一个Deferred。如果主体包含非 UTF 8 字节序列,将引发一个UnicodeDecodeError。这种异常意味着实际值永远无法计算,这是Deferred的 errbacks 应该知道的错误情况。

Deferred现在处理这两种情况;第一个问题可以通过在一个try块中运行每个回调和错误返回来解决,而第二个问题可以通过捕捉异常并将其转发给Deferred.callback来解决。考虑一个 HTTP 协议实现,它试图用 UTF-8 解码的响应体调用Deferred的回调:

class HTTP(protocol.Protocol):
    def dataReceived(self, data):
        self._handleData(data)
        if self.state == "BODY_READY":
            try:
                result = data.decode('utf-8')
            except Exceptionas e:
                result = e
            self.factory.deferred.callback(e)

class HTTPFactory(protocol.Factory)
    protocol = HTTP
    def __init__ (self, deferred):
        self.deferred = deferred

def nonblockingGet(url):
    deferred = Deferred()
    factory = HTTPFactory(deferred)
    ...
    return deferred

这是因为Deferredfor循环通过检查当前结果的性质来开始每次迭代。第一次通过循环,结果是无论调用者提供什么callback;在对Exception进行编码的情况下,上面的代码向callback提供了那个异常。

异常处理现在可以在 errbacks 中本地化,就像应用逻辑在回调中本地化一样。这允许我们将同步异常控制流转换为异步异常控制流。此代码:

def requestField(url, field):
    results = requests.get(url).json()
    return results[field]

def manyOperations(url):
    result = requestField(url, field)
    try:
        result += 2
        result //= 3
        result *= 4
    except TypeError:
        return -1
    return result

变成了这样的代码:

def manyCallbacks(url):
    def addValue(result):
        return result + 2
    def divideValue(result):
        return result // 3
    def multiplyValue(result):
        return result * 4
    def onTypeError(exception):
        if isinstance(exception,TypeError):
            return -1
        else:
            return exception
    deferred = requestField(url, "someInteger")
    deferred.addCallback(addValue)
    deferred.addCallback(divideValue)
    deferred.addCallback(multiplyValue)
    deferred.addErrback(onTypeError)
    return deferred

Twisted 提供了一个Deferred实现,它的 API 是这里显示的 API 的超集;正如我们将在下一节中看到的,真正的Deferred自己组成,并提供额外的功能,如超时和取消。然而,在其核心,它的行为与我们的玩具实现相匹配。

Twisted 的延期介绍

了解 Twisted 的Deferred的最好方法是在 Python 会话中使用它。我们将从从twisted.internet.defer导入开始:

>>> from twisted.internet.defer import Deferred

回收

像我们的玩具实现一样,twisted.internet.defer.DeferredaddCallback方法接受一个回调来添加到实例的回调列表中。与我们的实现不同,Twisted 还接受将传递给回调的位置和关键字参数:

>>> d = Deferred()
>>> def cbPrint(result, positional, **kwargs):
...     print("result =", result, "positional =", positional,
...           "kwargs =", kwargs)
...
>>> d.addCallback(cbPrint, "positional", keyword=1) is d
True
>>> d.callback("result")
result = result positional = positinal, kwargs = {'keyword': 1}

我们创建一个名为 d 的Deferred,添加cbPrint作为回调,然后用"result". d回调 d,将它传递给cbPrint作为它的第一个位置参数,而传递给d.addCallback的附加参数作为它的剩余参数。

注意,d.addCallback返回 d 本身,这允许像

d.addCallback(...).addCallback(...).addCallback(...).

现在d已经用一个值回调了,它不能再被回调:

>>> d.callback("whoops")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "site-packages/twisted/internet/defer.py", line 459, in callback
    self._startRunCallbacks(result)
  File "site-packages/twisted/internet/defer.py", line 560, in _startRunCallbacks
    raise AlreadyCalledError
twisted.internet.defer.AlreadyCalledError

这是因为Deferred s 记得他们被召回的价值:

>>> d2 = Deferred()
>>> d2.callback("the result")
<Deferred at 0x12345 current result: 'the result'>

Deferreds存储结果的事实提出了一个问题:当有结果的Deferred被添加了回调时会发生什么?

>>> d2.addCallback(print)
the result

一旦将print作为回调添加到d2中,它就会运行。一个有结果的Deferreds立即运行添加给它的回调。人们很容易想象Deferred s 总是代表一个尚不可用的值。然而,假设这一点的代码是错误的,并且是令人沮丧的错误的来源。请考虑以下几点:

class ReadyOK(twisted.internet.protocol.Protocol):
    def connectionMade(self):
        someDeferred = someAPI()
        def checkAndWriteB(ignored):
            self.transport.write(b"OK\n")
        someDeferred.addCallback(checkAndWriteB)
        self.transport.write(b"READY\n")

顾名思义,这个ReadyOK协议应该用一条READY线路来迎接新的连接,只写OK,当someAPI回叫它的Deferred时就断开。当someDeferred直到connectionMade返回后才被回调时,READY才会出现在OK之前,但这并不能保证;如果someAPI返回someDeferred一个结果,那么OK出现在READY之前。这种预期行顺序的颠倒会破坏正确要求先发送READY的客户端。

这种情况下的解决方法是将self.transport.write(b"READY\n") 移到 someDeferred = someAPI()之前。您可能需要类似地重新组织您自己的代码,以确保结果的Deferreds不违反不变量。

错误和失败

Deferreds也有 errbacks 来处理由回调和调用代码提供的异常Deferred.callback。我们首先考虑第一种情况:

>>> d3 = Deferred()
>>> def cbWillFail(number):
...     1 / number
...
>>> d3.addCallback(cbWillFail)
<Deferred at 0x123456>
>>> d3.addErrback(print)
<Deferred at 0x123456>
>>> d3.callback(0)
[Failure instance: Traceback: <class 'ZeroDivisionError'>: division by zero
<stdin>:1:<module>
site-packages/twisted/internet/defer.py:459:callback
site-packages/twisted/internet/defer.py:567:_startRunCallbacks
--- <exception caught here> ---
site-packages/twisted/internet/defer.py:653:_runCallbacks
<stdin>:2:cbWillFail
]

d3 Deferred有一个将 1 除以其参数的回调函数,内置的print函数作为 errback,因此回调函数引发的任何异常都将出现在我们的交互会话中。用 0 回调d3自然会产生一个ZeroDivisionError,但也会产生其他东西:一个失败实例。请注意,Failure字符串表示是用括号([。。。]).errback 打印的是单个故障,而不是有一个故障的list

Python 2 中的异常对象不包含回溯或其他关于其来源的信息。为了提供尽可能多的上下文,Twisted 引入了Failures作为记录回溯的异步异常的容器类型。在except块中构造的Failure吸收活动异常及其回溯:

>>> from twisted.python.failure import Failure
>>> try:
...     1 /0
... except:
...     f = Failure()
...
>>> f
<twisted.python.failure.Failure builtins.ZeroDivisionError: division by zero>
>>> f.value ZeroDivisionError('division  by  zero',)
>>> f.getTracebackObject()
<traceback object at 0x1234567>
>>> print(f.getTraceback())Traceback (most recent call last):
--- <exception caught here> ---
  File "<stdin>", line 2, in <module>

builtins.ZeroDivisionError: division by zero

Failure实例在其value属性下存储实际的异常对象,并以几种不同的方式使回溯本身可用。

还有一些方便的方法,可以在出错时轻松地与它们交互。check方法接受多个异常类,并返回属于Failure的异常或None的一个:

>>> f.check(ValueError)
>>> f.check(ValueError, ZeroDivisionError)
<class 'ZeroDivisionError'>

Failure.trap的行为类似于check,除了当Failure的异常与任何提供的异常类都不匹配时,它会重新引发异常。这允许 errbacks 复制过滤 except 子句的行为:

>>> d4 = Deferred()
>>> def cbWillFail(number):
...     1 / 0
...
>>> def ebValueError(failure):
...     failure.trap(ValueError):
...     print("Failure was ValueError")
...
>>> def ebTypeErrorAndZeroDivisionError(failure):
...     exceptionType = failure.trap(TypeError, ZeroDivisionError):
...     print("Failure was", exceptionType)
...
>>> d4.addCallback(cbWillFail)
<Deferred at 0x12345678>
>>> d4.addErrback(ebValueError)
<Deferred at 0x12345678>
>>> d4.addErrback(ebTypeErrorAndZeroDivisionError)
<Deferred at 0x12345678>
>>> d4.callback(0)
Failure was <class 'ZeroDivisionError'>

ebValueErrorebTypeErrorAndZeroDivisionError的功能类似于同步代码中的两个块:

try:
    1/0
except ValueError:
    print("Failure was ValueError")
except (TypeError,ZeroDivisionError) as e:    exceptionType = type(e)
    print("Failure was", exceptionType)

最后,Deferreds可以被提供一个Failure或者可以从当前异常合成一个。

用一个Failure实例回调一个Deferred开始执行它的错误返回。someDeferred.callback(Failure())因此类似于通过我们的玩具实现的callback异常。

Deferreds还要暴露一个errback方法。传递这个Failure实例与传递callback实例具有相同的效果;然而,不带参数调用Deferred.errback会导致失败,从而很容易捕获异步处理的异常:

>>> d5 = Deferred()
>>> d5.addErrback(print)
<Deferred at 0x12345678>
>>> try:
...    1/0
... except:
...    d.errback()
...
[Failure  instance:  Traceback:<  class 'ZeroDivisionError'>:  division  by  zero
---<exception caught here>---
<stdin>:2:<module>
]

撰写延期

是一个控制流抽象,支持回调和错误的组合。他们还和自己一起作曲,这样一个Deferred可以侍候一个Deferred

考虑一个名为outerDeferredDeferred,它有以下回调序列,其中一个返回innerDeferred,它有自己的回调:

>>> outerDeferred = Deferred()
>>> def printAndPassThrough(result, *args):
...     print("printAndPassThrough",
...           " ".join(args), "received", result)
...     return result
...
>>> outerDeferred.addCallback(printAndPassThrough, '1')
<Deferred at 0x12345678>
>>> innerDeferred = Deferred()
>>> innerDeferred.addCallback(printAndPassThrough,  '2',  'a')
<Deferred at 0x123456789>
>>> innerDeferred.addCallback(printAndPassThrough,  '2',  'b')
<Deferred at 0x123456789>
>>> def returnInnerDeferred(result, number):
...     print("returnInnerDeferred #", number, "received", result)
...     print("Returning innerDeferred...")
...     return innerDeferred
...
>>> outerDeferred.addCallback(returnInnerDeferred, '2')
<Deferred at 0x12345678>
>>> outerDeferred.addCallback(printAndPassThrough, '3')
<Deferred at 0x12345678>
>>> outerDeferred.addCallback(printAndPassThrough, '4')
<Deferred at 0x12345678>

回调outerDeferred清楚地调用了标识符为 1 的printAndPassThrough回调,但是当控制到达returnInnerDeferred时会发生什么呢?

我们可以用图 2-1 中执行流程的可视化表示来回答这个问题。

img/455189_1_En_2_Fig1_HTML.jpg

图 2-1

outerDeferredinnerDeferred之间的执行和数据流。执行遵循虚线箭头,而数据流遵循实线箭头。

标有 A 的方框代表开始outerDeferred回调循环的outerDeferred.callback( ' result ' )调用,而虚线和实线箭头分别表示执行和数据的流向。

标识符为1的第一个回调函数—printAndPassThrough—接收‘result’作为第一个参数,并打印出一条消息。因为它返回' ',outerDeferred用相同的对象调用下一个回调。returnInnerDeferred打印它的标识符和一条它正在返回的消息innerDeferred在这样做之前:

>>> outerDeferred.callback("result")
printAndPassThrough 1 received result
returnInnerDeferred 2 received result
Returning innerDeferred...

outerDeferred内部的回调循环检测到returnInnerDeferred返回了一个Deferred而不是一个实际值,并且暂停自己的回调循环,直到innerDeferred解析为一个值。图 2-1 中的虚线箭头表示执行已经转移到innerDeferred处,outerDeferredrepr也是如此:

>>> outerDeferred
<Deferred at 0x12345678 waiting on Deferred at 0x123456789>

标有 B 的方框代表继续执行的innerDeferred.callback( ' result ' )调用。自然地,innerDeferred自己的回调,printAndPassThrough的标识符2 a2 b,现在运行。

一旦innerDeferred已经运行了它所有的回调,执行返回到outerDeferred的回调循环,其中printAndPassThrough34innerDeferred最后一次回调返回的值执行。

>>> innerDeferred.callback('inner result')
printAndPassThrough 2 a received inner result
printAndPassThrough 2 b received inner result
printAndPassThrough 3 received inner result
printAndPassThrough 4 received inner result

实际上,printAndPassThrough 34变成了innerDeferred的回调。如果任何innerDeferred自己的回调返回Deferred s,它的回调循环将以与outerDeferred相同的方式暂停。

从回调(以及错误返回)返回Deferreds的能力允许在外部组合返回Deferreds的函数:

def copyURL(sourceURL, targetURL):
    downloadDeferred = retrieveURL(sourceURL)
    def uploadResponse(response):
        return uploadToURL(targetURL, response)
    return downloadDeferred.addCallback(uploadResponse)

copyURL使用两个假设的 API:retrieveURL,它检索一个 URL 的内容;和uploadToURL,它上传数据到一个目标 URL。添加到由retrieveURL返回的Deferred中的uploadResponse回调使用来自源 URL 的数据调用uploadResponse,并返回结果Deferred。记住一个DeferredaddCallback返回相同的实例,所以copyURL返回downloadDeferred给它的调用者。

copyURL的用户首先等待下载,然后等待上传。copyURL的实现组合返回Deferred的函数,就像它组合回调函数一样,没有任何特殊用途的 API。

Twisted 的Deferred s 的基本接口允许用户在外部编写回调、错误返回和Deferred s,简化了异步程序的构建。

异步程序可以从外部组合它们的事件处理程序,这并不是唯一的方法。自从 Twisted 的Deferred问世以来的近 20 年里,Python 已经开发了语言级机制来暂停和恢复特殊类型的函数。

生成器和内联回调

产量

Python 从 2.5 版本开始支持生成器。生成器是在它们的主体中使用一个yield表达式的函数和方法。调用生成器会返回一个可迭代的生成器对象。迭代执行生成器主体,直到下一个yield表达式,此时执行暂停,迭代器计算出yield表达式的操作数。

考虑以下生成器的执行:

>>> def generatorFunction():
...     print("Begin")
...     yield 1
...     print("Continue")
...     yield 2
...
>>> g = generatorFunction()
>>> g
<generator object generatorFunction at 0x12345690>
>>> result = next(g)
Begin
>>> result
1

被调用时返回一个新的生成器对象。请注意,generatorFunction的身体还没有跑起来。内置的next函数推进了一个迭代器;推进生成器对象g开始执行generatorFunction的主体,将Begin输出到我们的交互式 Python 会话中。执行在到达第一个yield表达式时暂停,提供给yield的值成为next调用的返回值。再次调用next继续执行发生器,直到它到达第二个yield:

>>> nextResult = next(g)
Continue
>>> nextResult
2

再次调用next恢复生成器。这一次它的整个身体都被处决了。没有进一步的yields可以暂停,所以生成器对象不能为后续的next调用提供另一个值。根据 Python 的迭代协议,在 generato 对象上调用next会引发StopIteration来表明它已经被耗尽:

>>> next(g)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

因此,生成器遵循与任何其他迭代器相同的 API:要么像上面那样通过对next的显式调用返回值,要么像在for循环中那样通过隐式调用返回值,而StopIteration异常表明不能再返回更多的值。然而,生成器实现的不仅仅是迭代 API。

派遣

发生器可以接收值,也可以发送它们。yield操作数可以出现在赋值语句的右边。通过将值传递给生成器的send方法,可以使生成器暂停的yield表达式计算出某个值。给定生成器gPrime中的以下yield表达式:

def gPrime():
    a = yield 4

gPrime.send(5)导致赋值右侧的yield计算为5,这样生成器中的代码就相当于:

def gPrime():
    a = 5

结果,发电机本地变量a取值为 5。与此同时,gPrime().send(5)调用推进生成器,并评估为4。让我们通过检查一个完全工作的例子及其在图 2-2 中的可视化来更详细地探索send的控制流。

img/455189_1_En_2_Fig2_HTML.jpg

图 2-2

执行和数据流入流出receivingGenerator。执行向下移动,而数据流沿着实线箭头。

>>> def receivingGenerator():
...     print("Begin")
...     x = 1
...     y = yield x
...     print("Continue")
...     z = yield x + y
...     print(x + y + z)
...
>>> g = receivingGenerator()
>>> result = next(g) # A Begin
>>> result
1
>>> nextResult = g.send(2) # B
Continue
>>> nextResult
3
>>> g.send(3) # C
6
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

我们从next开始执行receivingGenerator,与我们开始执行generatorFunction的方式相同;生成器必须总是通过迭代一次来启动。图 2-2 中标有 A 的方框表示对next的初始调用。和以前一样,g一直运行,直到暂停在它的第一个yield表达式上,这个next调用计算那个yield的操作数。因为该操作数是被赋值为 1 的局部变量x,所以next调用的值为 1。从yield x出来的黑色箭头,穿过方框 A ,在数值1穿过next离开发生器时追踪该数值。

现在,发电机已经启动,我们可以使用 send 再次恢复它,如框 B 所示。g. send(2)将值2传递给生成器,生成器将其赋给变量y。执行继续,经过print("Continue"),直到在下一个yield暂停。这里的操作数是表达式x + y,其计算结果为 3,并通过g.send(2)返回。从x + y穿过框 B 的黑色箭头显示结果 3 采用的退出路径。

由框 C 表示的调用g.send(3),将 3 发送到生成器并再次继续执行,将x + y + z = 6 打印到会话。然而,生成器不能像以前一样暂停执行,因为在receivingGenerator中没有进一步的yield表达式。因为生成器遵循迭代协议,所以当耗尽时它们抛出StopIterationg.send(3)因此引发 StopIteration,而不是计算一个值,如图 2-2 所示,并在示例代码中演示。

正如send允许将值传递给生成器一样,throw允许在生成器中引发异常。考虑以下代码:

>>> def failingGenerator():
...     try:
...         value = yield
...     except ValueError:
...         print("Caught ValueError")
...
>>> tracebackG = failingGenerator()
>>> next(tracebackG)
>>> tracebackG.throw(TypeError())
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in failingGenerator
TypeError
>>> catchingG = failingGenerator()
>>> next(catchingG)
>>> catchingG.throw(ValueError())
Caught ValueError
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

failingGenerator将其yield表达式包装在一个try块中,该块的except捕获ValueError,然后打印一条消息。所有其他异常都传递回调用者。

我们通过调用failingGenerator并将其命名为tracebackG来创建一个新的生成器。我们像往常一样先给next打个电话。注意failingGeneratoryield缺少一个操作数;Python 用None表示值的缺失,所以next计算为None(当函数返回None时,迭代 Python 会话不打印None)。在生成器内部,第一个yield本身评估为None,因为next不能向生成器发送任何值。因此,g.send(None)相当于next(g)。当我们研究协程时,这种等价将变得非常重要。

接下来,我们通过throw方法将TypeError扔进tracebackG。发生器恢复到其yield表达式,但是yield 没有计算出一个值,而是提高了throw传递的TypeError。结果回溯在failingGenerator内终止。从回溯中不太清楚的是TypeErrortracebackG.throw上升。这是有意义的:调用throw导致了生成器的恢复,这又引发了TypeError,未处理的异常返回调用堆栈是很自然的。

一个名为catchingG的新生成器演示了当failingGeneratorexcept方块遇到ValueError时会发生什么。正如所料,yield引发了传递给throw的异常,正如 Python 的异常处理所料,except块捕获了ValueError并输出了它的消息。然而,没有进一步的yield来暂停发电机,所以这次throw产生一个StopIteration来指示failingGenerator的耗尽。

带内联回调的异步编程

生成器暂停和恢复执行对应于Deferred执行回调和错误返回:

  • 当到达一个yield表达式时,生成器暂停其执行,而当一个返回另一个Deferred时,Deferred暂停其回调和出错;

  • 暂停的生成器可以通过它的send方法用一个值恢复,而等待另一个DeferredDeferred在那个Deferred解析为一个值时恢复执行它的回调;

  • 暂停的生成器可以通过它的throw方法接收和捕获异常,而等待另一个DeferredDeferred在那个Deferred解决了异常时继续执行它的 errbacks。

通过比较以下两个代码示例,我们可以看到这些等效性的作用:

def requestFieldDeferred(url, field):
    d = nonblockingGet(url)

    def onCompletion(response):
        document = json.load(response)
        return jsonDoc[field]

        def onFailure(failure):
            failure.trap(UnicodeDecodeError)

        d.addCallack(onCompletion)
        d.addErrback(onFailure)

        return d

    def requestFieldGenerator(url, field):
        try:
            document = yield nonblockingGet(url)
        except UnicodeDecodeError:
            pass
        document = json.load(response)
        return jsonDoc[field]

requestFieldDeferrednonblockingGet的响应Deferred附加一个回调,将响应解码为 JSON 并提取一个属性,以及一个 errback,只隐藏UnicodeDecodeError s

requestFieldGenerator反而产生nonblockingGetDeferred。然后,当响应可用时,生成器可以恢复响应,或者如果发生异常,则恢复异常。callback 和 errback 都被移到了调用nonblockingGet的同一个作用域中。将函数体移入调用者被称为内联

我们不能像写的那样使用requestFieldGenerator: Python 2 不允许生成器返回值,我们需要一个包装器来接受yield ed Deferred,并在Deferred解析为值或异常时安排调用生成器的sendthrow

Twisted 在twisted.internet.defer.inlineCallbacks中提供了这个包装器。它修饰返回生成器的可调用程序,并在每个产出的Deferred解析为一个值或异常时调用sendthrow。反过来,调用修饰的生成器函数或方法的调用者会收到一个Deferred,而不是一个生成器对象。这确保了期望Deferreds的现有 API 与inlineCallbacks无缝协作。

这是我们用inlineCallbacks装饰的requestFieldGenerator:

from twisted.internet import defer

@defer.inlineCallbacks
def requestFieldGenerator(url, field):
    try:
        document = yield nonblockingGet(url)
    except UnicodeDecodeError:
        pass

    document = json.load(response)
    defer.returnValue(jsonDoc[field])

def someCaller(url, ...):
    requestFieldDeferred = requestFieldGenerator(url,"someProperty")
    ...

returnValue函数抛出一个包含其参数的特殊异常;inlineCallbacks捕捉到这个,并安排用那个值回调requestFieldGenerator。Python 3 中的一个return语句引发了一个等价的异常,inlineCallbacks也会捕获它,所以在只在 Python 3 下运行的代码中returnValue是不必要的。

通过将代码从回调和错误返回到单个局部范围,生成器使得异步 Twisted 程序读起来就像同步的一样。短程序尤其受益于随之而来的函数定义的减少和更清晰的控制流。

发电机用熟悉来换取新的困难。最关键的是,生成器函数或方法的调用者不可能知道返回的生成器对象是使用用send发送给它的值,还是默默地忽略它。例如,这两个发生器提供相同的接口:

def listensToSend():
    a = 1
    b = yield a
    print(a+b)

def ignoresSend():
    a = 1
    yield a
    print(a)

意外地用ignoreSend替换listensToSend会导致一个难以诊断的细微错误。两者都是有效的 Python 代码,适用于不同的环境:listensToSend允许带值的恢复,使其适用于inlineCallbacks,而ignoreSend只是产生一个值,适合于对文件中的行进行操作的处理管道。Python 生成器 API 模糊了这两个不同的用例。

幸运的是,Python 3 的最新版本提供了为inlineCallbacks风格的生成器量身定制的新语法。

Python 中的协同程序

在计算机科学中,生成器是协同程序的一个特例,它可以挂起自己,并将执行传递给任何其他协同程序,当它们接收到返回值时继续执行。我们的inlineCallbacks修饰生成器类似于协程,因为它可以产生和接收值,但是它不像协程,它不能像调用任何其他函数那样直接调用另一个生成器。相反,它需要inlineCallbacks内部的机器来代表它将执行任务交给另一个生成器。这个机器管理执行代码的请求,并将结果返回给请求者,被称为蹦床。为了理解其中的原因,想象一下执行就好像在不同的发生器之间反弹inlineCallbacks

产自的协同程序

Python 3.3 引入了一种新的语法,允许一个生成器直接将其执行委托给另一个生成器:yield from。以下 Python 3.3+专用代码演示了从另一个生成器生成的生成器的行为:

img/455189_1_En_2_Fig3_HTML.jpg

图 2-3

执行和数据流入流出ef。执行向下移动,而数据流沿着实线箭头。

>>> def e():
...     a = yield 1
...     return a + 2
...
>>> def f():
...     print("Begin f")
...     c = yield from e()
...     print(c)
...
>>> g = f()
>>> g.send(None)
Begin f
1
>>>  g.send(2)
4
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

生成器e的行为与上一节中描述的生成器函数完全一样:如果我们调用它,我们将通过调用它的next(或者传递它的send方法None)来启动返回的生成器,这将返回 1,它的yield的操作数;然后,我们可以用send将值传递回生成器,它会将操作数返回给下一个yield表达式或者返回给 return 语句(记住,在 Python 3 中,生成器可以返回值)。

f 返回的生成器g产生由e返回的生成器,暂停以允许子生成器执行。对g发出的nextsendthrow调用被代理到底层的e生成器,因此生成器g看起来是一个e生成器。在图 2-3 中,方框 A 表示开始执行g的初始g.send(None)。执行通过f()yield from移动到由e()返回的生成器,暂停在e主体内的yield表达式上,该表达式将1发送回g.send(None)

当子生成器终止时,用yield from将执行委托给另一个生成器的生成器重新获得控制。图 2-3 中的框 B 表示对g.send(2)的第二次调用,该调用通过暂停的f生成器将值2传递给子生成器e,子生成器恢复并将2赋给变量a。执行进行到return语句,并且e子生成器以值4退出。现在f在其yield from表达式的左侧重新开始,并将接收到的4赋给变量c。在print调用之后,没有进一步的yieldyield from表达式,因此f()终止,导致g.send(2)引发StopIteration错误。

这种语法不需要像inlineCallbacks这样的蹦床将调用从一个生成器分派到另一个生成器,因为它允许生成器直接将执行委托给其他生成器。有了yield from,Python 生成器的行为就像真正的协程一样。

协同程序异步和等待

不幸的是,yield from仍然遭受着和yield一样的不确定性:接受值和忽略值的生成器对于调用代码来说是一样的。Python 以后的版本通过在区分协程的yield from:asyncawait之上引入新的语法特性来解决这种歧义。

当应用于一个函数或方法定义时,async标记将那个函数或方法变成一个协程:

>>>  async def function(): pass
...
>>> c = function()
>>> c
<coroutine object function at 0x9876543210>

与生成器不同,协程不能迭代:

>>> list(function())
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'coroutine' object is not iterable
>>> next(function())
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'coroutine' object is not iterable

像生成器一样,协程有 sendthrow 方法,调用者可以用这些方法恢复它们:

>>> function().send(None)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>> function().throw(Exception)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 1, in function
Exception

协程可以等待其他协程,语义与从其他生成器生成的生成器相同:

>>> async def returnsValue(value):
...     return 1
...
>>> async def awaitsCoroutine(c):
...     value = await c
...     print(value)
...
>>> awaitsCoroutine(returnsValue(1)).send(None)
1
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

这种行为展示了协同程序合成的先决条件,但是await做一些立即返回值的事情并不能激发它们在异步编程中的使用。我们需要能够向一个暂停的协程发送一个任意值,但是因为asyncawait的目的是呈现一个与普通生成器不兼容的 API,我们既不能像yield from那样await一个普通生成器,也不能像yield那样省略它的操作数:

>>> def plainGenerator():
...     yield 1
...
>>> async def brokenCoroutineAwaitsGenerator():
...     await plainGenerator()
...
>>> brokenCoroutineAwaitsGenerator().send(None)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in brokenCoroutineAwaitsGenerator
TypeError: object generator can't be used in 'await' expression
>>> async def brokenCoroutineAwaitsNothing():
...     await
  File "<stdin>", line 2
    await
        ^
SyntaxError: invalid syntax

为了学习如何用值恢复协程,我们回到yield from。我们之前的例子为yield from提供了另一个生成器,因此对包装生成器的sendthrow方法的调用被代理到内部生成器。可能有许多生成器,每个生成器都通过yield from将执行委托给继任者,但是在底层,必须有一些东西产生向上的价值。例如,考虑一组五个发电机,如图 2-4 所示。

img/455189_1_En_2_Fig4_HTML.jpg

图 2-4

一堆发电机。g1g4已经向下委托g5执行。

>>> def g1(): yield from g2
...
>>> def g2(): yield from g3
...
>>> def g3(): yield from g4
...
>>> def g4(): yield from g5
...
>>> def g5(): yield 1

g1g2g3g4不能取得任何进展,直到g5产生一个将从g4传播到g1的值。g5不必是发电机,但是;如下例所示,yield from只需要一个可迭代对象来推进它的生成器:

>>> def yieldsToIterable(o):
...     print("Yielding from object of type", type(o))
...     yield from o
...
>>> list(yieldsToIterable(range(3)))
Yielding from object of type <class 'range'>
[0, 1, 2]

yieldsToIterable将执行委托给它的参数,在本例中是一个range对象。通过构建一个列表来迭代yieldsToIterable生成器,演示了range对象就像生成器一样接管迭代。

async def定义的协程与yield from共享它们的实现,因此通过适当的步骤,它们也可以await特殊类型的可迭代程序和生成器。

与前面的例子显示的相反,只要生成器被用types.coroutine装饰器标记为协程,它们就可以被等待。使用这种修饰生成器的协程接收该生成器的返回值:

>>> import types
>>> @types.coroutine
... def makeBase():
...     return (yield "hello from a base object")
...
>>> async def awaitsBase(base):
...     value = await base
...     print("From awaitsBase:", value)
...
>>> awaiter = awaitsBase(makeBase())
>>> awaiter.send(None)
'hello from a base object'
>>> awaiter.send("the result")
From awaits base: the result
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

send(None)启动awaitsBase协程跳转到base生成器的yield语句,并遵循生成器的典型执行路径,返回"hello from base object."现在协程已将执行委托给base,因此send("the result")用该字符串恢复basebase立即returns这个值,这导致协程的await解析到它的值。

如果 Iterable 对象实现了一个返回迭代器的特殊的__await__方法,也可以等待它。这个迭代器的最终值——也就是说,无论它最后产生什么或者包装在一个StopIteration异常中——都将成为传递给await的结果。一个符合这个接口的物体被说成是类未来。当我们稍后探索asyncio时,我们将看到它的Future提供了这个接口,因此授予它它们的名字。

一个类似未来的对象的简单实现演示了控制流:

class FutureLike(object):
    _MISSING="MISSING"
    def __init__(self):
        self.result = self._MISSING
    def __next__(self):
        if self.result is self._MISSING:
            return self
        raise StopIteration(self.result)
    def __iter__(self):
        return self
    def __await__(self):
        return iter(self)

async def awaitFutureLike(obj):
    result = await obj
    print(result)

obj = FutureLike()
coro = awaitFutureLike(obj)
assert coro.send(None) is obj
obj.result = "the result"
try:
    coro.send(None)
except StopIteration:
    pass

FutureLike的实例是可迭代的,因为它们的__iter__方法返回一个本身具有__next__方法的对象。在这种情况下,迭代一个FutureLike实例将一遍又一遍地产生同一个实例,直到它的result属性被设置,这时它将引发一个包含该值的StopIteration异常。这相当于来自发电机的returning值。

FutureLike的实例也是类似未来的,因为它们的__await__方法返回一个迭代器,所以awaitFutureLike可以await一个FutureLike的实例。通常,协程从send(None)开始。这将返回awaitFutureLike协程await s 的FutureLike实例,这是我们传递给它的同一个实例。设置FutureLike对象的result属性允许我们通过将其 await 解析为一个值来恢复协程,协程接收结果,打印结果,然后以一个StopIteration异常终止。

注意,第二个coro.send调用None传递给协程。协程,await类未来对象解析为这些对象的迭代器提供的最后一个值。它们仍然必须被恢复以利用这些值,但是它们必然会忽略它们的send方法的参数。

Twisted 提供了一个可适应的对象和一个协程适配器,这样协程和现有的 API 就可以无缝地交互。正如我们所见,协程与asyncio是完全分离的,所以我们在本节讨论的 Twisted API 不足以集成两者。我们将在下一章学习必要的附加 API。

等待延期

从 Twisted 16.4.0 开始,延迟是类似未来的对象,提供了一致的__next____iter____await__方法。这允许我们用一个Deferred替换前面代码中的FutureLike:

from twisted.internet.defer import Deferred

async def awaitFutureLike(obj):
    result = await obj
    print(result)

obj = Deferred()
coro = awaitFutureLike(obj)
assert coro.send(None) is obj
obj.callback("the result")
try:
    coro.send(None)
except StopIteration:
    pass

awaiting一个Deferred解析为Deferred在其正常回调和错误返回处理循环后做的任何事情:

>>> from twisted.internet.defer import Deferred
>>> import operator
>>> d = Deferred()
>>> d.addCallback(print, "was received by a callback")
<Deferred at 0x7eff85886160>
>>> d.addCallback(operator.add, 2)
<Deferred at 0x7eff85886160>
>>> async def awaitDeferred():
...     await d
...
>>> g = awaitDeferred()
>>> g.send(None)
<Deferred at 0x7eff85886160>
>>> d.callback(1)
1 was received by a callback
>>> g.send(None)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in awaitDeferred
  File "twisted/src/twisted/internet/defer.py", line 746, in send
    raise result.value
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'

我们的Deferredprint回调运行,但返回None,导致第二次回调失败,当它试图向第一个参数添加 2 时,返回TypeError。恢复的协程因此失败,并且TypeError存储在Deferred中。

在这种情况下,协程和Deferreds的组合暴露了一个 bug,但是测试的代码路径表明错误和数据在两者之间自然地流动。

允许我们在协程中调用 Twisted 的 API,但是如果我们想让 Twisted 的 API 使用我们的协程呢?

使用 ensureDeferred 延迟的协同例程

Twisted 可以用Deferreds包装协程,允许期望Deferreds的 API 接受协程。

twisted.internet.defer. ensureDeferred接受一个协程对象并返回一个 Deferred,当协程返回一个:

>>> from twisted.internet.defer import Deferred, ensureDeferred
>>> async def asyncIncrement(d):
...     x = await d
...     return x + 1
...
>>> awaited = Deferred()
>>> addDeferred = ensureDeferred(asyncIncrement(awaited))
>>> addDeferred.addCallback(print)
<Deferred at0x12345>
>>> awaited.callback(1)
2
>>>

我们的协程asyncIncrement awaits是一个解析为一个数字的对象,然后返回这个数字和 1 的和。我们用ensureDeferred将它转换成一个Deferred,分配给addDeferred,然后给它添加一个print回调。回调asyncIncrement等待的awaited Deferred依次回调ensureDeferred返回的addDeferred Deferred,而不需要我们调用send。换句话说,addDeferred的行为与手动构建的Deferred相同。异常传播也以同样的方式工作:

>>>from twisted.internet.defer import Deferred, ensureDeferred
>>> async def asyncAdd(d):
...     x = await d
...     return x + 1
...
>>>awaited = Deferred()
>>>addDeferred = ensureDeferred(asyncAdd(awaited))
>>>addDeferred.addErrback(print)
Unhandled error in Deferred:

<Deferred at0x7eff857f0470>
>>>awaited.callback(None)
[Failure  instance:  Traceback:<  class 'TypeError'>:  ...
...
<stdin>:3:asyncAdd
]

协同程序比Deferred管理的回调更类似于同步代码,Twisted 使得使用协同程序变得足够容易,以至于你可能会怀疑Deferred是否会带来麻烦。一个显而易见的答案是它们已经被使用过了;许多 Twisted 的代码使用了Deferred,所以即使你很少使用它们,你仍然需要熟悉它们。不使用协程的另一个原因是,您必须编写在 Python 2 上运行的代码。随着 Python 2 寿终正寝,这已经不再是一个问题,PyPy,一个替代的 Python 运行时,它的实时(JIT)编译器可以极大地提高纯 Python 代码的速度,扩展了它们对 Python 3 的支持。

然而,为什么 Twisted 的Deferreds在一个后协同程序的世界里仍然有价值,还有一些不太明显但更持久的原因。

多路传输延迟

如果我们想要两个异步操作的结果,其中一个可能在另一个之前完成,会发生什么?例如,假设我们编写一个程序,同时发出两个 HTTP 请求:

def issueTwo(url1, url2):
    urlDeferreds = [retrieveURL(url1), retrieveURL(url2)]
    ...

协程会让我们依次等待每一个:

async def issueTwo(url1, url2):
    urlDeferreds = [retrieveURL(url1),  retrieveURL(url2)]
    for d in urlDeferreds:
        result = await d
        doSomethingWith(result)

issueTwo await完成其中一个的时候,反应堆将继续回收url1url2;等待url1的回收完成并不妨碍反应堆回收url2。这种并发性确实是异步和事件驱动编程的要点!

然而,随着操作变得更加复杂,这种效率变得不那么重要了。假设我们只想要首先检索的 URL。我们不能只使用await来写一个fastestOfTwo协程,因为我们不知道先给哪个await。只有反应器知道指示协程值准备就绪的底层事件何时发生,并且如果我们只有协程,事件循环将不得不暴露同步原语,该原语同时等待多个协程并检查是否所有协程都已完成。

幸运的是,无需特殊的反应堆级同步机制,多个Deferreds就可以轻松复用成单个Deferred。最简单地说,twisted.internet.defer.DeferredList是一个Deferred,它接受一个延期列表,并在所有这些Deferreds都有值时回调自己。

考虑以下代码:

>>> from twisted.internet.defer import Deferred, DeferredList
>>>  url1 = Deferred()
>>>  url2 = Deferred()
>>> urlList = DeferredList([url1, url2])
>>> urlList.addCallback(print)
<Deferred at 0x123456>
>>> url2.callback("url2")
>>> url1.callback("url1")
[(True, "url1)", (True, "url2")]

DeferredList urlList 包装了两个url1url2 Deferreds,并有一个print函数作为自己的回调函数。该回调仅在url1url2都被回调后运行,因此urlList与上面的issueTwo协程中的全有或全无同步相匹配。

第一个线索是DeferredList更强大的特性集在于它返回给回调函数的list。每个元素是一个长度为 2 的tuple;第二个元素显然是传入的list中同一索引处的Deferred的值,所以索引 0 的第二个元组成员是"url1",对应于索引 0 处的url1 Deferred

tuple中的第一项表示Deferred是否成功终止。url1url2都解析为字符串,而不是Failures,因此结果列表中相应的索引将True作为它们的第一个元素。

导致至少一个DeferredListDeferreds失败演示了Failures是如何通信的:

>>> succeeds = Deferred()
>>> fails = Deferred()
>>> listOfDeferreds = DeferredList([succeeds, fails])
>>> listOfDeferreds.addCallback(print)
<Deferred at 0x1234567>
>>> fails.errback(Exception())
>>> succeeds.callback("OK")
[(True, 'OK'), (False, <twisted.python.failure.Failure builtins.Exception: >)]

现在,返回列表中的第二个元组将False作为其第一个元素,并将代表导致其Deferred失败的ExceptionFailure作为其第二个项目。

这个特殊的(successvalue or Failure)对列表通过使用Failures的回溯捕获工具保留了所有可能的信息。作为这种方法带来的灵活性的一个例子,DeferredList的用户可以在一次回调中轻松地过滤聚合结果。

有了DeferredList的基本行为,我们可以研究允许我们实现fastestOfTwo : fireOnOneCallback的附加特性。

list中的任何一个Deferreds有值时,fireOnOneCallback选项指示DeferredList回调自己:

>>> noValue = Deferred()
>>> getsValue = Deferred()
>>> waitsForOne = DeferredList([noValue, getsValue], fireOnOneCallback=True)
>>> waitsForOne.addCallback(print)
<Deferred at 0x12345678>
>>> getsValue.callback("the value") ('the  value',  1)

现在,当只有getsValue Deferred解析为一个值时,waitsForOneprint回调就会运行。传递给回调函数的值DeferredList也是一个长度为 2 的tuple,但是这一次,第一项是对应的Deferred解析到的值,而第二项是它在列表中的索引。getsValue"the value,"回调,它是我们传递给DeferredList的列表中的第二个项目,所以回调接收("the value," 1)作为结果。

我们现在可以实现fastestOfTwo:

def fastestOfTwo(url1, url2):
    def extractValue(valueAndIndex):
        value, index = valueAndIndex
        return value
    urlList = DeferredList([retrieveURL(url1), retrieveURL(url2)],
                          fireOnOneCallback=True,
                          fireOnOneErrback=True)
    return urlList.addCallback(extractValue)

DeferredList也允许用fireOnOneErrback模拟多路传输错误。在第一个错误时触发DeferredList并展开它的值是一种常见的模式,Twisted 在twisted.internet.defer.gatherResults中提供了一个方便的包装器:

>>> from twisted.internet.defer import Deferred, gatherResults
>>> d1, d2 = Deferred(), Deferred()
>>> results = gatherResults([d1, d2])
>>> results.addCallback(print)
<Deferred at 0x123456789>
>>> d1.callback(1)
>>> d2.callback(2)
>>> [1, 2]
>>> d1, d2 = Deferred(), Deferred()
>>> fails = gatherResults([d1,  d2])
>>> fails.addErrback(print)
<Deferred at 0x1234567890>
>>> d1.errback(Exception())
[[Failure instance: Traceback ...: <class 'Exception'>: ]]

回想一下,Failure__str__方法返回一个以[]开始和结束的字符串,因此打印出的失败出现了两组括号:一组来自其__str__,另一组来自其包含的list

还要注意的是gatherResults等待所有成功Deferreds,所以它不能用于fastestOfTwo

DeferredListgatherResults提供了允许复杂行为但隐含分支的高级 APIs 每个选项的输出取决于它们自己的选项和它们包装的Deferred的输出之间的相互作用。任何一个方面的改变都可能导致意想不到的输出,从而产生令人不快的 bug。

这超出了Deferred s 的一般间接性:因为Deferred.callback几乎总是由反应器调用,而不是由间接操纵套接字的代码用户代码调用,所以在异常的来源和它的最终原因之间可能存在差距。

Twisted 通过提供对测试的特殊支持解决了异步代码固有的困难。

测试延期

在前一章中,我们看到 Twisted 的trial.unittest包提供了一个SynchronousTestCase,它的 API 模仿了unittest.TestCase的。事实上,SynchronousTestCase的 API 是unittest.TestCase的超集,它的附加特性的重要部分涉及到关于Deferred的断言

我们可以通过为上一节定义的fastestOfTwo函数编写测试来探索这些特性。首先,我们将把它一般化,接受任意两个Deferreds,而不是检索 URL 本身:

def fastestOfTwo(d1, d2):
    def extractValue(valueAndIndex):
        value, index = valueAndIndex
        return value
    urlList = DeferredList([d1, d2],
                          fireOnOneCallback=True,
                          fireOnOneErrback=True)
    return urlList.addCallback(extractValue)

我们可以为这个新版本的fastestOfTwo编写的第一个测试断言,当它的两个Deferreds都没有解析为值时,它返回的Deferred没有解析为值:

from twisted.internet import defer
from twisted.trial import unittest

class FastestOfTwoTests(unittest.SynchronousTestCase):
    def test_noResult(self):
        d1 = defer.Deferred()
        self.assertNoResult(d1)
        d2=defer.Deferred()
        self.assertNoResult(d2)
        self.assertNoResult(fastestOfTwo(d1, d2))

顾名思义,synchronoustestcase . assertnoresult 断言它所传递的延迟没有结果,这是一个很有价值的工具,可以确保执行符合您的预期。

然而,当它们确实有结果时,是最有用的。在fastestOfTwo的情况下,我们期望返回的Deferred取两个Deferreds中第一个的值:

def test_resultIsFirstDeferredsResult(self):
    getsResultFirst = defer.Deferred()
    neverGetsResult = defer.Deferred()
    fastestDeferred = fastestOfTwo(getsResultFirst, neverGetsResult)
    self.assertNoResult(fastestDeferred)
    result = "the result"
    getsResultFirst.callback(result)
    actualResult = self.successResultOf(fastestDeferred)
    self.assertIs(result, actualResult)

SynchronousTestCase.successResultOf要么返回Deferred的当前结果,要么导致其测试失败。我们的测试在用它回调getsResultFirst之后,用它从fastestDeferred中提取"the result",这样测试可以断言fastestOfTwo确实返回了第一个可用的结果。

注意,在我们回调getsResultFirst之前,我们仍然断言fastestOfTwo返回的Deferred没有结果。鉴于test_noResult已经做出了这个断言,这看起来可能是多余的,但是请记住,在您的代码添加回调或错误返回之前,可以回调Deferred s。在这种情况下,fastestOfTwo可能会错误地返回一个已经用the result回调的Deferred,而忽略传入的Deferred s,然而我们的测试仍然会通过。这在如此简单的代码中是不太可能的,但是当 a Deferred得到结果时,关于的隐含假设可能会潜入代码中,导致测试忽略 bug。断言Deferred实际上处于给定的状态是一种好的做法,而不是假设这样以避免这些错误,并且更好的做法是针对已经有结果的Deferred和没有结果的Deferred来测试您的代码。

我们可以添加一个测试,断言即使在Deferred已经触发时fastestOfTwo也能工作:

def test_firedDeferredIsFirstResult(self):
    result = "the result"
    fastestDeferred = fastestOfTwo(defer.Deferred(),
                                 defer.succeed(result))
    actualResult = self.successResultOf(fastestDeferred)
    self.assertIs(result, actualResult)

twisted.internet.defer.succeed函数接受一个参数并返回一个Deferred,这个参数会立即被回调,所以fastestOfTwo的第二个参数是一个Deferred,在任何fastestOfTwo运行之前,它已经被用the result回调了。

为了完整起见,我们还可以测试当fastestOfTwo收到两个已经被回调的Deferreds时会发生什么:

def test_bothDeferredsFired(self):
    first = "first"
    second = "second"
    fastestDeferred = fastestOfTwo(defer.succeed(first),
                                 defer.succeed(second))
    actualResult = self.successResultOf(fastestDeferred)
    self.assertIs(first, actualResult)

底层的DeferredList将其内部处理回调按顺序添加到其列表中的每个Deferreds中。有了fireOnOneCallback=True,列表中最早有结果的Deferred回调代表列表的Deferred。在我们的测试中,我们期望first是回调fastestDeferred的值。

错误处理是测试的关键部分,所以我们对fastestDeferred的测试也应该测试它如何处理Failure。为了保持测试简短,我们将只展示在Deferred被传递到fastestOfTwo之前失败的情况:

def test_failDeferred(self):
    class ExceptionType(Exception):
        pass
fastestDeferred = fastestOfTwo(defer.fail(ExceptionType()),
                             defer.Deferred())
failure = self.failureResultOf(fastestDeferred)
failure.trap(defer.FirstError)
failure.value.subFailure.trap(ExceptionType)

SynchronousTestCase.successResultOfSynchronousTestCase.failureResultOf从一个Deferred返回当前的Failure;如果Deferred还没有被调用或者没有-Failure结果,failureResultOf导致测试失败。

因为返回的对象是一个Failure,所以我们可以在 errbacks 中使用的所有方法和属性在我们的测试中都是可用的。DeferredListfireOnOneErrback=True将失败包装在twisted.internet.defer.FirstError异常中,所以我们在测试中使用了trap这种类型;如果Failure包装了任何其他异常,trap将再次引发它。导致FirstError的底层Failure在其subFailure属性上是可访问的,并且由于我们传入了ExceptionType的一个实例,我们trap断言第一个Deferred由于预期的原因而失败。

使用successResultOffailureResultOfassertNoResult鼓励使用关于Deferred状态的显式假设来编写测试。正如fastestOfTwo所展示的,即使是对Deferred的简单使用也必须进行隐式排序依赖和错误处理测试。这些也是协程和任何其他并发原语的关注点。Twisted 的测试套件自然拥有在Deferred环境中处理常见并发问题的最佳工具。

摘要

这一章通过解释事件处理程序是一种_callback_的方式,继承了上一章未完成的事件驱动编程。由于延续传递式的理论力量,非常复杂的程序可以用回调来表达。回调通过直接调用其他回调来传递值,而不是返回给它们的调用者。我们将这种组合命名为内部组合,因为它发生在每个回调的主体中。

内部组合使得维护回调驱动的程序变得困难:每个回调都必须知道它的后继者的名字和签名,这样它才能调用它。对一系列回调进行重新排序或消除一个回调可能需要修改几个回调。一个解决方案在于异步编程的范例,它允许程序在所有输入准备好之前继续运行。代表异步结果的占位符值可以收集回调,然后在真实值可用时运行它们。这个占位符允许回调返回值,从而在外部组合,反过来使得逻辑单元不知道它们是如何以及在哪里被使用的。使用这些异步占位符的事件驱动代码可以像非回调驱动代码一样被分解。

*Twisted 的异步占位符值是Deferred。我们看到Deferred在一个循环中运行它们的回调,将一个的结果传递给下一个,并在任何异常时调用错误处理程序或错误返回。?? 内部的这个处理循环使它们成为强大的控制流抽象 ??。

控制流抽象的一个重要部分是以不同的方式响应不同的错误。Twisted 的Failure类捕获回溯信息以及引发的异常,并公开允许 errbacks 过滤和重新引发异常的实用方法。我们看到了回调和错误返回如何完全代表使用tryexcept的同步代码。

就像回调允许组合一样,它们自己组合。当回调或错误返回一个Deferred时,该回调或错误自身的Deferred暂停其执行,直到新的Deferred完成。这意味着返回Deferred的函数和方法可以被用作回调和错误返回,而不需要开发人员做任何特别的努力。

尽管Deferred功能强大,但它们并不是组成异步动作的唯一方式。Python 的生成器可以暂停它们的执行,并在从外部来源接收到值后继续执行。这个控制流映射到由延迟器提供的控制流,回调和错误可以通过使用inlineCallbacks转移到生成器中。

然而,生成器是不明确的,因为它们可能表示简单的迭代器或类似于Deferred的控制流。Python 3.5 增加了对协程的特殊支持,这些协程是以控制流为中心的生成器,可以通过将执行委托给其他协程来挂起自己,而不需要inlineCallbacks。协程可以await直接 TwistedDeferred的,可以用ensureDeferred变成Deferred的。这些 API 允许 Twisted 无缝地使用协程。

不是所有的程序都可以用协程直接表达:我们的fastestOfTwo例子需要同时等待两件事情。幸运的是,DeferredList,一个建立在Deferreds之上的抽象,允许 Twisted 去复用的异步结果。

Twisted 还特别支持测试Deferreds。SynchronousTestCase提供了assertNoResultsuccessResultOffailureResultOf,允许测试对Deferred s 的状态做出精确的断言。影响所有原语(协程、生成器和Deferred s)的并发问题可以用这套工具进行测试。****

三、使用treq和 Klein 的应用

前面几章深入解释了 Twisted 的基本原理。熟悉这些核心概念是必要的,但不足以编写真正的应用。在这一章中,我们将通过使用两个强大的 Twisted web 库:treq和 Klein 构建一个提要聚合器来探索现代的高级 API 和整个程序设计。

treq ( https://treq.readthedocs.io )用受流行的同步 HTTP 库requests启发的 API 包装twisted.web.client.Agent。其方便安全的默认值使得发送异步 HTTP 请求变得很容易,而treq.testing提供的 fakes 简化并标准化了编写测试。

Klein ( https://klein.readthedocs.io )是一个用户友好的包装器,围绕着 Twisted 的古老的twisted.web.server web 框架。它允许使用从 Werkzeug ( https://werkzeug.readthedocs.io/ )借来的熟悉的路由范例来开发动态、异步的 web 应用。

为什么是图书馆?

Twisted 本身提供了 Klein 和treq的核心功能。那为什么不直接用 Twisted 的那些部分呢?这两个库的界面都与 Twisted 的有很大不同;例如,twisted.web使用对象遍历而不是路由将 URL 路径与 Python 代码关联起来。一个twisted.web.server.Site不匹配一个请求的路径和查询字符串对一个字符串模板,如“/some/”;相反,它将路径段匹配到嵌套的Resource对象。在设计twisted.web的时候,这是 Python web 应用框架中流行的范例。Klein 的作者没有给 Twisted 本身增加一个新的路由抽象,而是选择在一个单独的代码库中试验一种不同的方法。他们的结果是成功的,Klein 的独立存在允许它在不破坏依赖于twisted.web.server的应用的情况下成长和适应。

同样,treq在高级 API 中封装了常见的twisted.web.client.Agent使用模式;例如,Agent要求将所有请求体表示为IBodyProducer对象,包括短到可以用字节串表示的有效载荷,而treq的请求方法直接接受字节串体。使用treq并不妨碍你使用Agent,它的全部力量在 Twisted 中仍然存在。

用于安装第三方 Python 包的工具pip目前运行得非常好,额外的要求不会给开发者带来不必要的负担。我们还将在后面的章节中看到如何使用 Docker 来开发和部署使用第三方库的 Twisted 应用,使之健壮并可重复。最后,Klein 和treq都属于 Twisted GitHub 组织,由 Twisted 的核心贡献者开发和使用。它们是图书馆中风险最低的。

饲料聚集

追溯到互联网历史上一个不同的、更加开放的时代。在其全盛时期,网站通过 HTTP 提供 feed 文件,这些文件以结构化的方式组织其内容,因此其他网站可以出于各种目的使用它们。像 RSS (真正简单的联合或丰富文档格式站点摘要)和 Atom 这样的开放标准描述了这些结构,并允许任何人编写这些提要的消费者。将许多网站的信息聚合在一个地方的服务成为用户获取最新新闻和博客的流行方式。这些格式的扩展,比如 RSS 的附件,允许 feeds 引用外部媒体,使得播客之类的东西得以兴起。

2013 年谷歌阅读器的消亡恰逢订阅源的受欢迎程度下降。网站删除了他们的提要,一些消费软件失去了消费它们的能力。尽管有所下降,但基于 feed 的网络聚合还没有单一的替代品,它仍然是组织来自许多不同在线来源的内容的有效方式。

许多标准定义了 RSS 的变体。如果需要直接使用提要格式,我们将只支持由哈佛大学伯克曼中心( http://cyber.harvard.edu/rss/rss.html )定义的 RSS 2.0 的以下子集:

  1. A <channel>是 RSS 2.0 提要文件的根元素,由它的<title><link>元素描述;

  2. 一个<channel>中的网页由<item>描述,每个网页都有自己的<title><link>元素。

我们将使用测试驱动开发用 Klein 和treq编写一个提要聚合器。然而,在此之前,我们将通过编写探索性程序来了解它们以及定义提要聚合的问题空间。然后,我们将使用我们学到的知识来设计、实现和迭代地改进我们的应用。因为不先下载提要就无法显示它们,所以我们将从探索如何用treq发送 HTTP 请求开始。

介绍treq

提要聚合器必须先下载提要,然后才能显示它们,所以我们将从探索treq开始。请注意,下面的例子应该适用于 Python 2 和 3。

使用您喜欢的工具创建一个新的虚拟环境,并将 PyPI 中的treq安装到其中。有许多工具可以实现这一点;出于通用性的考虑,我们建议像这样使用virtualenv ( https://virtualenv.pypa.io/en/stable/ )和pip ( https://pip.pypa.io/en/stable/ ):

$ virtualenv treq-experiment-env
...
$ ./treq-experiment-env/bin/pip install treq
...
$ ./treq-experiment-env/bin/python experiment.py

其中experiment.py包含以下代码:

from argparse import ArgumentParser
from twisted.internet import defer, task
from twisted.web import http
import treq

@defer.inlineCallbacks
def download(reactor):
    parser = ArgumentParser()
    parser.add_argument("url")
    arguments = parser.parse_args()
    response = yield treq.get(
        arguments.url, timeout=30.0, reactor=reactor)
    if response.code != http.OK:
        reason = http.RESPONSES[response.code]
    raise RuntimeError("Failed:{}{}".format(response.code,
                                            reason))
    content = yield response.content()
    print(content)

task.react(download)

download函数用标准库的argparse模块提取一个 URL 命令行参数,然后使用treq.getGET它。treq的客户端 API 接受bytesunicodeURL,根据定义文本 URL 的复杂规则对后者进行编码。这使得我们的程序更容易编写,因为ArgumentParser.parse_args返回代表 Python 2 和 3 上命令行参数的str对象;在 Python 2 中,它们是字节字符串,而在 Python 3 中,它们是 unicode 字符串。我们不必担心将 URL str编码或解码成适合特定 Python 版本的类型,因为treq会为我们正确地完成这项工作。

treq的客户端 API 接受一个timeout参数,该参数终止未能在指定超时内开始的请求。reactor参数指定哪个反应器对象用于网络和内部簿记。这是一个依赖注入 : treq 依赖于反应器,但是treq可以提供这个依赖而不是导入twisted.internet.reactor本身。我们将在后面看到依赖注入如何使测试和分解代码变得更容易。

treq.get返回一个解析为treq.response._Response对象的Deferred(其名称中的下划线暗示我们不应该自己构造实例,而不是说我们不应该与它交互)。这实现了twisted.web.iweb.IRequest接口,所以它在code属性中包含了响应的状态代码。我们的示例程序检查这个值,以确保服务器的响应表明我们的请求是成功的;如果不是,它将使用响应的状态代码及其对应的状态短语引发一个RuntimeError,这是由twisted.web.http.RESPONSES字典提供的,它将二者相互映射。

Deferred也可以解析为Failure。例如,如果在Response对象可以被构造之前,经过了由timeout参数指定的时间量,Deferred将失败,出现CancelledError

treq的回复也有额外的方法,使得与他们的互动更加方便。其中之一是content,它返回一个Deferred,将请求的整个主体解析为一个单独的bytes对象。treq在幕后为我们处理收集响应的所有细节。

最后,我们的例子从不直接调用reactor.runreactor.stop。相反,它使用了一个我们从未见过的 Twisted 的库函数:twisted.internet.task.reactreact为我们处理反应堆的启动和停止。它接受一个 callable 作为它唯一需要的参数,这个 callable 是它在运行的反应器中调用的;可调用函数本身必须返回一个Deferred,当它解析到一个值或Failure时,该函数会导致反应器停止。由于其twisted.internet.defer.inlineCallback s 装饰器的缘故,download函数返回了这样一个Deferred。因为react本身接受一个 callable 作为它的第一个参数,所以它也可以用作装饰器。我们可以这样写我们的例子:

..
from twisted.internet import defer, task
...

@task.react
@defer.inlineCallbacks
def main(reactor):
    ...

这实际上是用 Twisted 编写简短脚本的一种流行方式。今后,当我们使用react时,我们将把它用作装饰器。

针对 web 提要的 URL 运行这个treq示例程序可以检索提要的内容。我们可以修改我们的程序,使用 Python feedparser库来打印提要的摘要。首先,用pipfeedparser安装到您的虚拟环境中:

$ ./treq-experiment-env/bin/pip install feedparser

然后,将下面的程序保存到feedparser_experiment.py并根据 RSS URL 运行它:

$ ./treq-experiment-env/bin/python feedparser_experiment.py http://planet.twistedmatrix.com

from __future__ import print_function
from argparse import ArgumentParser
import feedparser
from twisted.internet import defer, task
from twisted.web import http
import treq

@task.react
@defer.inlineCallbacks
def download(reactor):
    parser = ArgumentParser()
    parser.add_argument("url")
    arguments = parser.parse_args()
    response = yield treq.get(arguments.url, reactor=reactor)
    if response.code != http.OK:
        reason = http.RESPONSES[response.code]
        raise RuntimeError("Failed:{}{}".format(response.code,
                                                reason))
    content = yield response.content()
    parsed = feedparser.parse(content)
    print(parsed['feed']['title'])
    print(parsed['feed']['description'])
    print("*** ENTRIES ***")
    for entry in parsed['entries']:
        print(entry['title'])

运行此命令应该会产生如下输出:

Planet Twisted
Planet Twisted - http://planet.twistedmatrix.com/
*** ENTRIES ***
Moshe Zadka: Exploration Driven Development
Hynek Schlawack: Python Application Deployment with Native Packages
Hynek Schlawack: Python Hashes and Equality
...

介绍克莱因

既然我们已经知道了如何使用treq来检索和解析提要,我们需要学习足够多的关于 Klein 的知识来在网站中呈现它们。

为了保持我们的实验有条理,为 Klein 创建一个新的虚拟环境,并用pip install Klein安装它。然后,运行以下示例:

import klein

application = klein.Klein()

@application.route('/')
def hello(request):
    return b'Hello!'

application.run("localhost",8080)

现在,在您最喜欢的网络浏览器中访问http://localhost:8080/。(如果已经有一个程序绑定到了另一个端口,您可能需要将8080改为另一个端口。)你会看到从我们程序的hello路由处理器返回的字符串Hello!

Klein 应用从一个Klein类的实例开始。通过使用Klein.route方法作为装饰器,可调用程序与路由相关联。route的第一个参数是 Werkzeug 风格的 URL 模式;可能的格式指令与 Werkzeug 的路由文档中的相匹配,可以在这里找到: http://werkzeug.readthedocs.io/en/latest/routing/ 。让我们修改我们的程序,使用这样一个指令从路径中提取一个整数:

import klein

application = klein.Klein()

@application.route('/<int:amount>')
def increment(request, amount):
    newAmount = amount + 1
    message = 'Hello! Your new amount is:{} '.format(newAmount)
    return message.encode('ascii')

application.run("localhost",8080)

运行这个程序并访问http://localhost:8080/1会得到一个类似图 3-1 的网页。

img/455189_1_En_3_Fig1_HTML.jpg

图 3-1

增加.png

URL 模式指定一个路径组件,Klein 提取该路径组件,将其转换为指定的 Python 类型,并作为位置参数传递给处理函数。金额参数是第一个路径元素,必须是整数;否则,请求将失败,并显示 404。Werkzeug 文档中提供了一份转换器的列表。

还要注意,处理程序不能返回 unicode 字符串;在 Python 3 上;这意味着本地字符串必须在从 Klein 路由的处理程序返回之前被编码成字节字符串。因此,在执行了字符串格式化之后,我们将变量message编码为ascii。在 Python 3.5 和更高版本中,我们可以使用字节字符串格式,但是在撰写本文时,Python 3.4 仍然被广泛使用。同样,这段代码在 Python 2 上隐式地将 message解码为ascii。当使用除了ascii编码之外的任何编码时,这种不幸的行为会导致一个奇怪的错误消息,但是在处理只包含 ASCII 并且必须在 Python 2 和 3 上都工作的原生字符串的 Twisted 代码中,这是一种常见的模式。

克莱因和德弗雷德

Klein 是一个 Twisted 的项目,自然对Deferreds有特殊的支持。返回Deferreds的处理函数会产生一个响应,等待延迟解析为一个值或Failure。我们可以通过修改我们的程序来模拟一个缓慢的网络操作来看到这一点,方法是返回一个在接收到请求后至少一秒钟触发的Deferred:

from twisted.internet import task
from twisted.internet import reactor
import klein

application = klein.Klein()

@application.route('/<int:amount>')
def slowIncrement(request, amount):
    newAmount = amount + 1
    message = 'Hello! Your new amount is:{} '.format(newAmount)
    return task.deferLater(reactor,1.0, str.encode, message, 'ascii')

application.run("localhost",8080)

正如所料,这个程序只在一秒钟后才响应http://localhost:8080/1。它通过使用接受一个twisted.internet.interfaces.IReactorTime提供者、一个延迟、一个函数以及延迟过后将应用于该函数的参数的twisted.internet.task.deferLater来实现这一点。注意,我们对函数和参数的选择利用了实例方法存储在它们的类中的事实,并且它们的第一个参数必须是它们所绑定的实例;因此,str.encode(message, 'ascii' ),其中message是一个str,相当于message.encode(' ascii' )。这是 Twisted 代码中出现的另一种模式。

最后一个例子演示了使用 decorators 作为注册路由的方式所固有的局限性:被修饰函数的参数必须完全由路由框架提供。这使得编写引用某个状态或依赖于某个现有对象的处理函数变得困难。在我们的例子中,我们的代码依赖于反应器来满足deferLater的 API,但是我们不能将反应器传递给我们的处理程序,因为只有 Klein 可以调用它。在解决这个问题的许多方法中,Klein 特别支持一种方法:特定于实例的 Klein 应用。我们将再次重写我们的slowIncrement例子来利用这个特性。

from twisted.internet import task
from twisted.internet import reactor
import klein

class SlowIncrementWebService(object):
    application = klein.Klein()
    def init (self, reactor):
        self._reactor = reactor
    @application.route('/<int:amount>')
    def slowIncrement(self, request, amount):
        newAmount = amount + 1
        message = 'Hello! Your new amount is:{} '.format(newAmount)
        return task.deferLater(self._reactor,1.0, str.encode, message, 'ascii')

webService = SlowIncrementWebService(reactor) webService.application.run("localhost",8080)

SlowIncrementWebService类有一个分配给它的application类级变量的Klein应用。我们可以用那个变量的route方法来修饰这个类的方法,就像我们用模块级Klein对象的route方法来修饰模块级slowIncrement函数一样。因为我们现在正在修饰实例方法,所以我们可以访问实例变量,比如reactor。这允许我们在不依赖模块级对象的情况下参数化我们的 web 应用。

对象本身通过实现描述符协议来定位它们的内部状态。webService.application返回一个特定于请求的Klein实例,该实例包含我们向SlowIncrementWebServiceapplication注册的所有路由及其处理程序。因此,Klein 保持了健壮的封装,并最小化了共享的可变状态。

电镀克莱因模板

在准备构建提要聚合器的简单版本之前,我们需要的最后一件事是一个 web 页面模板系统。我们可以使用 Jinja2,或者樱井真子,或者任何其他用于生成网页的 Python 模板系统,但是 Klein 自带了自己的模板工具,叫做 Plating。让我们修改SlowIncrementWebService示例,使用klein.Plating来生成可读性更好的响应:

from twisted.internet import task, reactor
from twisted.web.template import tags, slot
from klein import Klein, Plating

class SlowIncrementWebService(object):
    application = Klein()
    commonPage = Plating(
        tags=tags.html( tags.head(
            tags.title(slot("title")),
            tags.style("#amount { font-weight: bold; }"
                       "#message { font-style: italic; }")),
            tags.body(
                tags.div(slot(Plating.CONTENT)))))
    def __init__ (self, reactor):
        self._reactor = reactor

    @commonPage.routed(
        application.route('/<int:amount>'),
        tags.div(
            tags.span("Hello! Your new amount is: ", id="message"),
            tags.span(slot("newAmount"), id="amount")),
    )
    def slowIncrement(self, request, amount):
        slots = {
            "title":"Slow Increment",
            "newAmount": amount + 1,
    }
    return task.deferLater(self._reactor,1.0, lambda: slots)

webService=SlowIncrementWebService(reactor) webService.application.run("localhost",8080)

新的commonPage Plating对象代表了对我们的SlowIncrementWebService的根本改变。因为Plating是建立在 Twisted 自己古老的twisted.web.template系统之上的,所以我们必须在继续之前了解它的基本原理。

twisted.templatestwisted.web.template.Tagtwisted.web.template.slot实例构成。Tags表示 html、bodydiv等 HTML 标签。它们是通过访问它们的名称作为标签工厂实例上的方法来创建的,标签工厂实例可作为twisted.web.template.tags获得。例如,这叫:

tags.div()

表示一个div标签,将被呈现如下:

<div></div>

这些实例方法的位置参数代表了它们标签的子标签,所以我们可以通过嵌套它们的方法调用向我们的div添加一个span:

tags.div(tags.span("A span."))

这个简单的标记树将呈现如下:

<div><span>A span.</span></div>

请注意,标记的文本内容也表示为子标记。

这些方法的关键字参数表示它们的属性,因此我们可以在 div 树中包含一个图像:

tags.div(tags.img(src="picture.png"), tags.span("A span."))

渲染时,这棵树看起来像这样:

<div><img src="picture.png"><span>A span.</span></div>

twisted.web.template保留一个关键字参数供内部使用:render。这是一个命名特殊的呈现方法的字符串,该方法将用于将标签呈现为 HTML。一会儿我们将看到一个特定于 Klein 的渲染方法的例子。

有时将标签的属性写在其子标签之前更容易阅读,但是关键字参数必须总是在位置参数之前。为了在不违反 Python 语法的情况下提高可读性,tags可以和它们的子节点一起被称为。我们可以重写我们的标记树,这样就可以添加它的子树了:

tags.div()(tags.img(src="picture.png"), tags.span("A span."))

是占位符,在模板渲染过程中可以用名字来填充,我们将在后面看到。它们允许我们参数化标签内容和属性。给定这个标记树,然后:

tags.div(tags.img(src=slot('imageURL')), tags.span(slot("spanText")))

我们可以提供“anotherimage.png”作为 imageURL 槽的值,并提供“Different text”对于spanText槽,导致如下结果:

<div><img src="anotherimage.png"><span>Different text.</span></div>

当用包含 HTML 文字的字符串填充槽时,twisted.webtemplate转义它们以避免将用户数据误解为模板指令。这反过来减少了常见的 web 应用错误,如跨站点脚本(XSS)攻击。然而,槽可以用其他tags填充,从而实现复杂的模板重用模式。这些规则意味着这棵树:

tags.div(slot("child")).fillSlots(child="<div>")

渲染到:

<div>&lt;div&gt;</div>

而这棵树:

tags.div(slot("child")).fillSlots(child=tags.div())

渲染到:

<div><div></div></div>

饲料聚集的初稿

既然我们已经熟悉了twisted.web.template的基本原理,我们可以回到我们的示例应用的klein.Plating对象:

commonPage = Plating(
    tags=tags.html(
        tags.head(
            tags.title(slot("title")),
            tags.style("#amount { font-weight: bold; }"
                       "#message { font-style: italic; }")),
            tags.body(
                tags.div(slot(Plating.CONTENT)))))

作为tags参数传递的标记树描述了这个Plating实例将呈现的所有 HTML 页面的结构。它包括两个插槽:titlePlating.CONTENTtitle插槽和其他插槽一样;每当我们想要呈现一个属于这个标签树的页面时,我们都必须为这个槽提供一个值。然而,Plating.CONTENT槽代表标签树中的位置,在这个位置上Plating将插入特定于页面的内容。我们的示例应用只呈现了一个来自commonPage的页面:

@commonPage.routed(
    application.route('/<int:amount>'),
    tags.div(
        tags.span("Hello!    Your new amount is: ", id="message"),
        tags.span(slot("newAmount"), id="amount")),
)
def slowIncrement(self, request, amount):
    slots={
        "title":"Slow Increment",
        "newAmount": amount+1,
    }
    return task.deferLater(self._reactor,1.0, lambda: slots)

我们通过用基本页面的routed装饰器包装 Klein route来表示派生页面。routed装饰器的第二个位置参数表示将填充基页的Klein.CONTENT槽的标记树。这个slowIncrement页面包装了我们之前定义的相同路由,并指定一个标签树作为其内容,该标签树包括一个用于递增量的槽。

在 Klein 中,槽是通过返回一个字典来填充的,该字典将它们的名称映射到来自页面处理程序的值,或者返回一个解析为 1 的Deferred。这个处理程序通过使用deferLater来延迟返回插槽字典,直到一秒钟过去。

结果是一个更有个性的网页,如图 3-2 所示。

Klein 的电镀提供了一个独特的特性:您可以通过指定json查询参数来请求将 slots 字典作为序列化的 JSON 返回。在图 3-3 中,我们可以看到当提供这个参数时,我们的“慢增量”页面是什么样子。

这使得Plating用户可以编写处理程序来呈现 HTML 和 JSON,作为简单的页面,或者为复杂的单页面应用(SPA)或本地移动应用提供后端。我们的提要聚合器的 HTML 前端不会成为一个 SPA,因为这是一本关于 Twisted 而不是 JavaScript 的书,但是我们将在开发应用时继续支持和探索 JSON 序列化。

我们现在可以编写一个简单的提要聚合器来探索它的设计。我们将编写一个SimpleFeedAggregation类,它接受提要 URL,并在用户访问根 URL 时使用treq来检索它们。我们将把每个提要呈现为一个表格,表格的标题链接到提要,表格的行链接到每个提要条目。

首先将 feedparser 和 treq 安装到 Klein 虚拟环境中,就像在 treq 虚拟环境中一样。

import feedparser

img/455189_1_En_3_Fig3_HTML.jpg

图 3-3

作为 JSON 递增

img/455189_1_En_3_Fig2_HTML.jpg

图 3-2

风格的增加

from twisted.internet import defer, reactor
from twisted.web.template import tags, slot
from twisted.web import http
from klein import Klein, Plating
import treq

class SimpleFeedAggregation(object):
    application = Klein()
    commonPage = Plating(
        tags=tags.html(
            tags.head(
                tags.title("Feed Aggregator 1.0")),
            tags.body(
                tags.div(slot(Plating.CONTENT)))))

    def __init__ (self, reactor, feedURLs):
        self._reactor = reactor
        self._feedURLs = feedURLs

    @defer.inlineCallbacks

    def retrieveFeed(self, url):
        response = yield treq.get(url, timeout=30.0, reactor=self._reactor)
        if response.code != http.OK:
            reason = http.RESPONSES[response.code]
            raise RuntimeError("Failed:{}{}".format(response.code,
                                                    reason))
        content = yield response.content()
        defer.returnValue(feedparser.parse(content))

@commonPage.routed(
    application.route('/'),
    tags.div(render="feeds:list")(slot("item")))
def feeds(self, request):

    def renderFeed(feed):
        feedTitle = feed[u"feed"][u"title"]
        feedLink = feed[u"feed"][u"link"]
        return tags.table(
            tags.tr(tags.th(tags.a(feedTitle, href=feedLink)))
        )([
            tags.tr(tags.td(tags.a(entry[u'title'], href=entry[u'link'])))
            for entry in feed[u'entries']
        ])

    return {
            u"feeds": [
                self.retrieveFeed(url).addCallback(renderFeed)
                for url in self._feedURLs

            ]
        }

webService = SimpleFeedAggregation(reactor,
                              ["http://feeds.bbci.co.uk/news/technology/rss.xml",
                               "http://planet.twistedmatrix.com/rss20.xml"])
webService.application.run("localhost",8080)

retrieveFeed方法类似于我们第一个treq程序的下载函数,而feeds方法以一个电镀装饰器开始,类似于我们的 slowIncrement Klein 应用。然而,在提要的情况下,特定于路由的模板由一个带有特殊的呈现方法div标签组成。Klein 将feeds:list解释为为列表中的每个项目复制div标签并将其放入项目槽的方向。例如,如果我们的feeds方法返回下面的字典:

{"feeds": ["first","second","third"]}

Klein 将为feeds路线呈现以下 HTML:

<div>first</div><div>second</div>third</div>

我们的feeds方法不仅返回一个槽字典,它的feeds键返回一个列表,而且还返回一个包含延迟的列表。这利用了twisted.web.template's的独特能力来呈现Deferred的结果:当遇到一个结果时,呈现暂停,直到它解析为一个值,然后被呈现,或者失败发生。

我们的feeds列表中的每一个Deferred都源于一个retrieveURL调用,这个调用通过treqfeedparser为一个 URL 创建一个解析的提要。renderFeed回调将经过解析的提要转换成标签树,标签树将提要呈现为一个链接表。这利用了twisted.web.template在插槽中嵌入tag元素的能力。

在浏览器中访问该页面时,首先呈现的是 BBC 提要,然后是更大更慢的 Twisted 矩阵提要,如图 3-4 和 3-5 所示。

我们的SimpleFeedAggregation类成功地检索并呈现了提要。它的基本设计反映了服务中的数据流:给定一个可迭代的提要 URL,通过对每个请求应用treq.get来同时检索它们。数据流通常会影响 Twisted 程序的设计。

然而,我们的执行不力:

  1. 它有虫子。用户实际上不能请求 JSON,因为代表每个提要的标记树不是 JSON 可序列化的。

img/455189_1_En_3_Fig5_HTML.jpg

图 3-5

一个包含 BBC 和 Twisted Matrix 的完整页面

img/455189_1_En_3_Fig4_HTML.jpg

图 3-4

只有英国广播公司信息的不完整页面

  1. 它的错误报告很差。虽然由SimpleFeedAggregation.retrieveFeed引发的RuntimeError是信息性的,但它是以不可操作的方式呈现给用户的,尤其是那些请求了 JSON 的用户。

在我们解决这些和其他问题之前,我们需要一个测试套件。我们将通过使用测试驱动的开发来指导我们,确保我们的提要聚合器的下一个实现符合我们的期望。

用 Klein 和treq进行测试驱动开发

编写测试需要时间和精力。测试驱动开发通过将测试作为开发过程的一部分来简化这一点。我们从某个代码单元应该实现的接口开始。接下来,我们编写一个空的实现,比如一个具有空方法体的类,然后进行测试,在给定已知输入的情况下验证该实现的期望输出。运行这些测试一开始应该会失败,开发变成了填充实现的过程,这样测试才能通过。结果,我们在早期发现实现的一部分是否与其他部分冲突,并且最终我们有一个完整的测试套件。

编写测试需要时间,所以从最有价值的接口开始很重要。对于一个 web 应用,这是客户机将使用的 HTTP 接口,所以我们的第一次测试将涉及对我们的FeedAggregation Klein 应用使用一个内存中的 HTTP 客户机。

在可安装项目上运行测试

测试驱动开发需要重复运行项目的测试,因此在我们开始编写任何测试之前,我们需要做好准备,以便 Twisted 的测试运行器trial能够找到它们。

trial命令接受包含或表示可运行测试用例的完全限定路径名作为它唯一的强制参数。trial的设计遵循与 Python 的unittest相同的受 xUnit 影响的模式,所以它的测试用例是twisted.trial.unittest.TestCasetwisted.trial.unittest.SynchronousTestCase的子类。这些名称本身是完全限定的路径名,或 FQPNs。从最顶层的包开始,它们指定了向下到特定函数、类或方法的属性访问路径。例如,下面的命令行运行位于 Twisted 自己的异步消息协议(AMP)测试套件中的ParsingTests测试用例的test_sillyEmptyThing方法:

trial twisted.test.test_amp.ParsingTests.test_sillyEmptyThing

给定一个更短因而更通用的 FQPN,trial递归进入模块和包树寻找测试,就像python -m unittest discover一样。例如,你可以用trial twisted运行 Twisted 自己的所有测试。

因为测试是用 FQPNs 指定的,所以它们必须是可导入的。trial超越了这一点,要求它们也驻留在 Python 运行时的模块搜索路径下。这符合 Twisted 的惯例,即在特殊的test子包下的库代码中包含测试。

Python 允许程序员以几种方式影响它的搜索路径。设置PYTHONPATH环境变量或者直接操作sys.path都允许它从特定于项目的位置导入代码。然而,告诉 Python 可以找到代码的新位置是不可靠的,因为它依赖于定制的配置和特定的运行时入口点。更好的方法是依靠虚拟环境将 Python 的搜索路径定位到特定于项目的目录树,然后将项目及其依赖项安装到其中。通过利用相同的工具和模式,以管理其依赖关系的相同方式管理我们自己的应用给了我们更大的一致性。

对虚拟环境和 Python 打包的全面讨论超出了本书的范围。相反,我们将概述一个最小的项目布局和配置,展示如何将我们的项目链接到一个虚拟环境中,然后为一个空的测试套件提供一个示例trial调用。

该项目的目录结构如下:

img/455189_1_En_3_Fig6_HTML.jpg

图 3-6

提要聚合项目目录结构

也就是说,在作为当前工作目录的某个目录下,存在一个setup.pysrc/目录。src/目录又包含顶层feed_aggregation包和一个_service子模块。feed_aggregation.test.test_service将存放_service中代码的测试用例。

将包含一个 Twisted 的应用插件,这将使运行我们的 Klein 应用更容易。

我们将把我们的FeedAggregation类放在feed_aggregation._service中:

class FeedAggregation(object):
    pass

这是一个私有模块,所以我们将通过在feed_aggregation/__init__.py中导出它来公开访问我们的类:

from feed_aggregation._service import FeedAggregation
__all__ =["FeedAggregation"]

将实现放在私有子模块中,然后在顶层包的 __ init__ .py中公开它,这是 Twisted 代码中的常见模式。它确保文档工具、linters 和 ide 将公共 API 的来源视为公共包,从而限制了私有实现细节的暴露。

我们将让feedaggregation/test/ __init__ .py为空,但将SynchronousTestCase的一个小子类放入feed_aggregation/test/test_service.py中,这样trial在我们完成设置后就可以运行了:

from twisted.trial.unittest import SynchronousTestCase

class FeedAggregationTests(SynchronousTestCase):
    def test_nothing(self):
        pass

twisted/plugins/feed_aggregation_plugin.py也为空,我们准备考虑setup.py:

from setuptools import setup, find_packages

setup(
    name="feed_aggregation",
    install_requires=["feedparser", "Klein", "Twisted", "treq"],
    package_dir={"": "src"},
    packages=find_packages("src") + ["twisted.plugins"],
)

这将我们的项目名称声明为feed_aggregation,其依赖项为feedparser(用于解析提要)、Klein(用于我们的 web 应用)、Twisted(用于trial)和treq(用于检索提要)。它还指示 setuptools 在src下查找包,并在twisted/plugins下包含feed_aggregation_plugin.py

假设我们为我们的项目激活了一个新的虚拟环境,并且我们在项目根中,我们现在可以运行这个:

pip install -e .

-e标志指示pip install执行我们项目的可编辑安装,这将把一个指针从虚拟环境放回我们项目根目录。因此,一旦我们保存编辑内容,它们就会出现在虚拟环境中。

最后,trial feed_aggregation应该显示以下内容:

feed_aggregation.test.test_service
  FeedAggregationTests
    test_nothing ...[OK]

---------------------------------------------------------------------------
Ran 1 tests in 0.001s

PASSED (successes=1)

证明我们实际上已经通过我们的虚拟环境对我们的项目进行了测试。

用 StubTreq 测试 Klein

现在我们可以运行测试,我们可以用测试某些东西的方法来代替FeedAggregationTests.test_nothing。如上所述,这应该是我们的 Klein 应用将呈现给客户端的 HTTP 接口。

测试 HTTP 服务的一种方法是运行一个 web 服务器,就像运行一个实时服务一样,可能绑定到一个可预测的端口上的localhost,并使用一个 HTTP 客户端库来连接它。这可能会很慢,更糟糕的是,端口是一种操作系统资源,它的稀缺会导致获取它们的测试不稳定。

幸运的是,Twisted 的传输和协议允许我们在测试中运行内存中的 HTTP 客户端和服务器对。特别是,treqtreq.testing.StubTreq中提供了一个强大的测试工具。StubTreq的实例暴露了与treq模块相同的接口,因此通过依赖注入获得treq的代码可以在测试中使用这个存根实现。由treq项目来验证StubTreq是否符合与treq模块相同的 API 我们不需要在测试中这样做。

StubTreq将一个twisted.web.resource.Resource作为它的第一个参数,它的响应决定了各种 treq 调用的结果。因为 Klein 实例公开了一个生成twisted.web.resource.Resourceresource()方法,所以我们可以将一个StubTreq绑定到我们的 web 应用,以获得一个适合我们测试的内存中 HTTP 客户端。

让我们用一个使用StubTreq请求我们服务的根 URL 的方法来替换test_nothing:

# src/feed_aggregation/tests/test_service.py

from twisted.trial.unittest import SynchronousTestCase
from twisted.internet import defer
from treq.testing import StubTreq
from .. import FeedAggregation

class FeedAggregationTests(SynchronousTestCase):
    def setUp(self):
        self.client = StubTreq(FeedAggregation().resource())
    @defer.inlineCallbacks
    def test_requestRoot(self):
        response = yield self.client.get(u'http://test.invalid/')
        self.assertEqual(response.code,200)

setUp方法为我们的FeedAggregation的 Klein 应用创建一个绑定到twisted.web.resource.ResourceStubTreq实例。test_requestRoot使用这个客户端向 Klein 资源发出一个GET请求,验证它收到了一个成功的响应。

注意,只有传递给self.client.get的 URL 的路径部分对我们的测试有影响。treq 和 StubTreq 只能对一个完整的 web URL 发出带有 scheme 和 netloc 的请求,所以我们使用一个.invalid域来满足这个需求。那个。invalid顶级域名被定义为永远不会解析到实际的互联网地址,这是我们测试的最佳选择。

trial feed_aggregation运行这个新版本的FeedAggregationTests会因为一个AttributeError而失败,因为我们的FeedAggregation类的实例没有一个resource方法。然而,添加正确的实现不会使测试通过;我们还需要构建一个 Klein 应用来响应对/的请求。我们将修改_service模块来满足这两个需求。

# src/feed_aggregation/_service.py

from klein import Klein

class FeedAggregation(object):
    _app=Klein()
    def resource(self):
        return self._app.resource()
    @_app.route("/")
    def root(self, request):
        return b""

新的resource实例方法将其调用委托给与该类相关联的 Klein 应用。这是 Demeter 的法则的一个例子,这是软件开发中的一个原则,反对在实例属性上调用方法;相反,像FeedAggregation.resource这样的委托方法包装了这些属性的方法,所以使用FeedAggregation的代码仍然不知道它的内部实现。我们将我们的 Klein 应用命名为_app,以表明它是FeedAggregation内部私有 API 的一部分。

*根方法充当根 URL path /的普通处理程序,并与FeedAggregation.resource一起使FeedAggregation.test_requestRoot通过。

我们现在已经完成了一个测试驱动的开发周期。我们从编写一个最小的失败测试开始,然后用最少的应用代码让它通过。

让我们跳过这一步,用一个更完整的测试套件来替换FeedAggregationTests,这个测试套件可以测试 HTML 和 JSON feed 渲染。

# src/feed_aggregation/test/test_service.py

import json
from lxml import html
from twisted.internet import defer
from twisted.trial.unittest import SynchronousTestCase
from treq.testing import StubTreq
from .. import FeedAggregation

class FeedAggregationTests(SynchronousTestCase):
    def setUp(self):
        self.client = StubTreq(FeedAggregation().resource())
    @defer.inlineCallbacks
    def get(self, url):
        response = yield self.client.get(url)
        self.assertEqual(response.code,200)
        content = yield response.content()
        defer.returnValue(content)
    def test_renderHTML(self):
        content = self.successResultOf(self.get(u"http://test.invalid/"))
        parsed = html.fromstring(content)
        self.assertEqual(parsed.xpath(u'/html/body/div/table/tr/th/a/text()'),
                        [u"First feed",u"Second feed"])
        self.assertEqual(parsed.xpath('/html/body/div/table/tr/th/a/@href'),
                        [u"http://feed-1/",u"http://feed-2/"])
        self.assertEqual(parsed.xpath('/html/body/div/table/tr/td/a/text()'),
                        [u"First item",u"Second item"])
        self.assertEqual(parsed.xpath('/html/body/div/table/tr/td/a/@href'),
                        [u"#first",u"#second"])
    def test_renderJSON(self):
        content = self.successResultOf(self.get(u"http://test.invalid/?json=true"))
        parsed = json.loads(content)
        self.assertEqual(
            parsed,
            {u"feeds": [{u"title": u"First feed", u"link": u"http://feed-1/",
             u"items": [{u"title": u"First item",u"link": u"#first"}]},
            {u"title": u"Second feed", u"link": u"http://feed-2/",
             u"items": [{u"title": u"Second item", u"link": u"#second"}]}]})

在这个测试案例中有很多事情要做。有两个测试,test_renderHTMLtest_renderJSON,它们验证我们期望我们的FeedAggregation web 服务返回的 HTML 和 JSON 的结构和内容。test_requestRoot已经被一个get方法所取代,这个方法可以被test_renderHTMLtest_renderJSON用来为我们的 Klein 应用检索一个特定的 URL。test_renderHTMLtest_renderJSON都使用SynchronousTestCase.successResultOf来断言get返回的Deferred已经触发并提取了值。

test_renderHTML使用lxml库( https://lxml.de/ )来解析和检查我们的 Klein 应用返回的 HTML。因此,我们必须在我们的setup.py中将lxml添加到install_requires列表中。请注意,您可以通过再次运行pip install -e .将虚拟环境与项目的依赖项同步。

XPaths 定位并提取 DOM 中特定元素的内容和属性。隐含的表结构与我们在原型中开发的相匹配:提要驻留在table中,其标题链接到提要的主页,其行链接到每个提要的项目。

test_renderJSON请求呈现为 JSON 的提要,将其解析成一个字典,然后断言它等于预期的输出。

这些新测试自然会失败,因为现有的FeedAggregation仅仅返回一个空的响应体。让我们通过用最少的必要实现替换FeedAggregation来让它们通过。

# src/feed_aggregation/_service.py

from klein import Klein, Plating
from twisted.web.template import tags as t, slot

class FeedAggregation(object):
    _app = Klein()
    _plating = Plating(
        tags=t.html(
            t.head(t.title("Feed Aggregator 2.0")),
            t.body(slot(Plating.CONTENT))))
    def resource(self):
        return self._app.resource()
    @_plating.routed(
        _app.route("/"),
        t.div(render="feeds:list")(slot("item")),
    )
    def root(self, request):
        return {u"feeds": [
    t.table(t.tr(t.th(t.a(href=u"http://feed-1/")(u"First feed"))),
            t.tr(t.td(t.a(href=u"#first")(u"First item")))),
    t.table(t.tr(t.th(t.a(href=u"http://feed-2/")(u"Second feed"))),
            t.tr(t.td(t.a(href=u"#second")(u"Second item"))))

]}

因为我们还没有编写提要检索的测试,所以这个实现还不能检索 RSS 提要。相反,它通过返回与我们的断言相匹配的硬编码数据来满足我们的测试。除此之外,它类似于我们的原型:一个root方法处理根 URL 路径,该路径使用 Klein 的:list渲染器将一系列twisted.web.template.tag转换成 HTML。

这个版本的FeedAggregation通过了test_renderHTML但是在test_renderJSON上失败:

(feed_aggregation) $ trial feed_aggregation
feed_aggregation.test.test_service
  FeedAggregationTests
    test_renderHTML ...                                           [OK]
    test_renderJSON ...                                        [ERROR]
                                                               [ERROR]

======================================================================= [ERROR]
Traceback (most recent call last):
...
exceptions.TypeError: Tag('table', ...) not JSON serializable

feed_aggregation.test.test_service.FeedAggregationTests.test_renderJSON
======================================================================= [ERROR]
Traceback (most recent call last):
...
twisted.trial.unittest.FailTest: 500 != 200

feed_aggregation.test.test_service.FeedAggregationTests.test_renderJSON
-----------------------------------------------------------------------
Ran 2 tests in 0.029s

FAILED (failures=1, errors=1, successes=1)

第二个错误对应于FeedAggregationTests.get中的self.assertEqual(response.code, 200),而第一个错误指出了真正的问题:Klein 无法序列化FeedAggregation.root返回给 JSON 的tag

最简单的解决方案是检测请求何时应该序列化为 JSON,并返回一个可序列化的字典。当前的设计需要复制必要的数据来满足测试,所以在我们解决 bug 的同时,我们还要添加存储提要数据的容器类,以及存储提要来源并控制其表示的顶级类。这些将允许我们定义一次数据,但同时呈现给 HTML 和 JSON。事实上,我们可以安排FeedAggregation在其初始化器中接受顶级 feed 容器类的实例,这样测试就可以使用它们自己的 fixture 数据。让我们按照这种方法重写_service.py。我们将使用 Hynek Schlawack 的attrs ( https://attrs.readthedocs.io )库来保持代码简洁明了;一定要把它加到你的setup.pyinstall_requires里。

# src/feed_aggregation/_service.py

import attr
from klein import Klein, Plating
from twisted.web.template import tags as t, slot

@attr.s(frozen=True)
class Channel(object):
    title = attr.ib()
    link = attr.ib()
    items = attr.ib()

@attr.s(frozen=True)
class Item(object):
    title = attr.ib()
    link = attr.ib()

@attr.s(frozen=True)

class Feed(object):
    _source = attr.ib()
    _channel = attr.ib()

    def asJSON(self):
        return attr.asdict(self._channel)

    def asHTML(self):
        header = t.th(t.a(href=self._channel.link)
                    (self._channel.title))
        return t.table(t.tr(header))(
                [t.tr(t.td(t.a(href=item.link)(item.title)))
                 for item in self._channel.items])

@attr.s
class FeedAggregation(object):
    _feeds = attr.ib()
    _app = Klein()
    _plating = Plating(
        tags=t.html(
        t.head(t.title("Feed Aggregator 2.0")),
        t.body(slot(Plating.CONTENT))))
def resource(self):
    return self._app.resource()
@_plating.routed(
    _app.route("/"),t.div(render="feeds:list")(slot("item")),
)
def root(self, request):
    jsonRequested = request.args.get(b"json")
    def convert(feed):
        return feed.asJSON() if jsonRequested else feed.asHTML()
    return {"feeds": [convert(feed) for feed in self._feeds]}

使用attrs可以很容易地定义像ChannelItem这样的容器类。在其最基本的操作中,attr.s类装饰器生成一个init方法,该方法设置对应于类的attr.ib变量的实例变量。

attrs也使得通过 decorator 的frozen参数定义实例为不可变的类变得容易。不变性很适合我们的容器类,因为它们表示外部数据;在我们收到它之后改变它肯定会是一个错误。attrslxml,必须添加到setup.py里面的install_requires列表中。

Feed类包装了提要的源 URL 和表示其内容的Channel实例,并公开了两种表示方法。asJSON使用attrs.asdict递归地将 channel 实例转换成 JSON 可序列化的字典,而asHTML返回一个twisted.web.template.tags树,由 Klein 的电镀系统渲染。

FeedAggregation.root现在检查请求的json查询参数,可以在args字典中找到,以确定响应是否应该呈现为 JSON 或 HTML,并适当地调用asJSONasHTML

最后,FeedAggregation现在本身是一个attrs修饰类,它的初始化器接受一个要呈现的Feed对象的 iterable。

因此,FeedAggregationTests.setUp必须被重构,以将Feed对象的 iterable 传递给它的FeedAggregation实例:

# src/feed_aggregation/test/test_service.py

...
from .._service import Feed, Channel, Item

FEEDS = (
    Feed("http://feed-1.invalid/rss.xml",
         Channel(title="First feed", link="http://feed-1/",
                 items=(Item(title="First item", link="#first"),))),
    Feed("http://feed-2.invald/rss.xml",
         Channel(title="Second feed", link="http://feed-2/",
                 items=(Item(title="Second item", link="#second"),))),
)

class FeedAggregationTests(SynchronousTestCase):
    def setUp(self):
        self.client = StubTreq(FeedAggregation(FEEDS).resource())

...

这个最新版本有它的好处:最明显的是,test_renderJSON现在通过了,但是另外 fixture 的数据现在和测试驻留在同一个地方,这样就更容易和它们的断言保持同步。

它也有不利的一面。如果没有检索 RSS 提要的能力,FeedAggregation不仅作为提要聚合服务毫无用处,而且测试现在导入并依赖于我们的容器类。像这样依赖于内部实现细节的测试是脆弱的,难以重构。

我们将通过编写提要检索逻辑来解决这两个缺点。

用 Klein 测试 treq

在前一节中,我们使用了StubTreq来测试我们的 Klein 应用。颠倒关系允许我们简洁地测试treq代码。

同样,我们将从编写测试开始。我们将把它们添加到test_service模块中,新的导入显示在顶部,我们的新测试用例显示在底部。

# src/feed_aggregation/test/test_service.py

import attr
...
from hyperlink import URL
from klein import Klein
from lxml.builder import E
from lxml.etree import tostring
...
from .. import FeedRetrieval

@attr.s
class StubFeed(object):
    _feeds = attr.ib()
    _app = Klein()
    def resource(self):
        return self._app.resource()

    @_app.route("/rss.xml")
    def returnXML(self, request):
        host = request.getHeader(b    'host')
        try:
            return self._feeds[host]
        except KeyError:
            request.setResponseCode(404)
            return b'Unknown host: ' +host
def makeXML(feed):
    channel = feed._channel
    return tostring(
    E.rss(E.channel(E.title(channel.title), E.link(channel.link),
                    *[E.item(E.title(item.title), E.link(item.link))
                      for item in channel.items],
          version = u"2.0")))

class FeedRetrievalTests(SynchronousTestCase):
    def setUp(self):
        service = StubFeed(
            {URL.from_text(feed._source).host.encode('ascii'): makeXML(feed)
             for feed in FEEDS})
        treq = StubTreq(service.resource())
        self.retriever = FeedRetrieval(treq=treq)
    def test_retrieve(self):
        for feed in FEEDS:
            parsed = self.successResultOf(
                self.retriever.retrieve(feed._source))
            self.assertEqual(parsed, feed)

与之前的FeedAggregationTests一样,FeedRetrievalTests类依赖于一些新概念。StubFeed是一个 Klein 应用,其/rss.xml route 返回一个特定于请求主机的 XML 文档。这允许它为 http://feed-1.invalidhttp://feed-2.invalid 返回不同的响应。作为预防措施,对未知主机的请求会导致信息性的 404“未找到”响应。

makeXML函数将一个Feed及其关联的Item转换成一个符合 RSS 2.0 的 XML 文档。我们使用lxml.builderE标签工厂,其 API 类似于twisted.web.template.tags,作为 XML 模板系统,并用lxml.etree.tostring将其标签树序列化为字节(尽管其名称如此,它确实在 Python 3 上返回字节)。

FeedRetrievalTests.setUp fixture 方法创建一个Feeds列表,并将它们传递给一个StubFeed实例,然后将它们与一个StubTreq实例相关联。这又被传递给一个FeedRetrieval实例,该实例将包含我们的提要检索代码。在treq实现上参数化这个类是一个依赖注入的例子,它简化了编写测试的过程。

注意,我们通过使用hyperlink.URL从其link元素中的 URL 派生出每个提要的主机。超链接( https://hyperlink.readthedocs.io ) URL是不可变的对象,表示解析后的 URL。超链接库是从 Twisted 自己的twisted.python.url模块中抽象出来的,提供了原始 API 的超集。因此,Twisted 现在依赖于它,所以它可以隐式地用于任何依赖 Twisted 的项目。然而,任何依赖的最佳实践是使其显式,所以我们必须将hyperlink包添加到我们的setup.pyinstall_requires列表中。我们的setup.py现在应该是这样的:

# setup.py

from setuptools import setup, find_packages

setup(
    name="feed_aggregation",
    install_requires=["attrs","feedparser","hyperlink","Klein",
                      "lxml","Twisted","treq"],
    package_dir={"":"src"},
    packages=find_packages("src")+["twisted.plugins"],
)

(记得我们我们在上面加了attrs和 lxml。)

我们的FeedAggregationTests测试用例中的一个测试test_retrieve断言FeedRetrieval.retrieve将从其_source URL 检索到的提要解析为与其 XML 表示匹配的Feed对象。

现在我们已经有了一个提要检索器的测试,我们可以实现一个。首先,我们将把FeedRetrieval添加到src/feed_aggregation/__init__.py中,这样就可以在不与私有 API 交互的情况下导入它:

# src/feed_aggregation/ init .py

from ._service import FeedAggregation, FeedRetrieval

__all__ = ["FeedAggregation","FeedRetrieval"]

现在,我们可以实现通过测试所需的最少代码:

# src/feed_aggregation/_service.py

...
import treq
import feedparser

@attr.s
class FeedRetrieval(object):
    _treq = attr.ib()
    def retrieve(self, url):
        feedDeferred = self._treq.get(url)
        feedDeferred.addCallback(treq.content)
        feedDeferred.addCallback(feedparser.parse)
    def toFeed(parsed):
        feed = parsed[u'feed']
        entries = parsed[u'entries']
        channel = Channel(feed[u'title'], feed[u'link'],
                          tuple(Item(e[u'title'], e[u'link'])
                              for e in entries))
        return Feed(url, channel)

        feedDeferred.addCallback(toFeed)
        return feedDeferred

正如所料,FeedRetrieval通过attr.s类装饰器和一个_treq attr.ib接受一个treq实现作为它的唯一参数。它的retrieve方法遵循与我们的探索性程序相同的模式:首先,它使用treq检索提供的 URL 并收集其主体,然后使用feedparser将收集的 XML 解析到 Python 字典中。

接下来,toFeed提取提要的标题、链接及其条目的标题和链接,然后将它们组装成一个ChannelItem和一个Feed

这个版本的FeedRetrieval通过了我们的测试,但是它缺少错误处理。如果一个提要已经被删除或者返回的 XML 无效怎么办?照目前的情况来看,FeedRetrieval.retrieve返回的Deferred将会异常失败,这将是FeedAggregation的问题。

网站和 JSON 服务都不应该显示回溯。同时,应该记录任何回溯以帮助调试。幸运的是,Twisted 有一个复杂的日志系统,我们可以用它来跟踪应用的行为。

twisted.logger记录

Twisted 为许多版本提供了自己的日志系统。从 Twisted 15.2.0 开始,twisted.logger已经成为 Twisted 程序中记录事件的首选方法。

像标准库的logging模块一样,应用通过调用一个twisted.logger.Logger实例上的适当方法,在不同的级别发出日志消息。下面的代码在info级别发出一条消息。

from twisted.logger import Logger
Logger().info("A message with{key}", key="value")

logging一样,Logger.info这样的发射方法接受一个格式字符串和值进行插值;与logging不同,这是一个新样式的格式化字符串,它是在底层日志事件中发送的。也不同于 Python 的标准logging系统,twisted.logger.Logger没有等级,而是通过观察者来路由它们的消息。格式字符串被保留的事实启用了twisted.logger最强大的特性之一:它可以以传统的格式发出日志消息供人们使用,并且可以将它们作为 JSON 序列化的对象发出。后者允许在像 Kibana 这样的系统中进行复杂的过滤和收集。当我们为提要聚合应用编写 Twisted 应用插件时,我们将看到如何在这些格式之间切换。

我们还使用描述符协议来捕获相关类的信息,所以我们将为我们的FeedRetrieval类创建一个Logger。然后,我们将安排在请求提要之前以及在成功解析或者因异常而失败时发出消息。然而,在我们这样做之前,我们必须决定当异常发生时FeedRetrieval.retrieveDeferred应该解决什么问题。它不能是一个Feed实例,因为没有任何 XML 可以解析到一个Channel实例中;但是FeedAggregation期望一个提供asJSONasHTML方法的对象,它们的唯一实现存在于Feed上。

我们可以用多态来解决这个问题。我们可以定义一个新的类FailedFeed,它表示FeedRetrieval检索提要失败。它将通过实现自己的asJSONasHTML方法来满足与Feed相同的接口,以适当的格式呈现错误。

像往常一样,我们将从编写测试开始。FeedRetrieval.retrieve可能遇到的异常情况可以分为两类:状态代码不是 200 的响应,以及任何其他异常。我们将用一个定制的异常类型ResponseNotOK对第一个进行建模,retrieve 将在内部引发并处理这个异常,我们可以通过从一个StubFeed不知道的主机请求一个提要来请求这个异常。后者可以通过向StubFeed提供一个返回空字符串的主机来请求,feedparser将无法解析空字符串。让我们给我们的FeedRetrievalTests类添加一些测试。

# src/feed_aggregation/test/test_service.py

from .. import FeedRetrieval
from .._service import Feed, Channel, Item, ResponseNotOK
from xml.sax import SAXParseException

...

class FeedRetrievalTests(SynchronousTestCase):
    ...
    def assertTag(self, tag, name, attributes, text):
        self.assertEqual(tag.tagName, name)
        self.assertEqual(tag.attributes, attributes)
        self.assertEqual(tag.children, [text])
    def test_responseNotOK(self):
        noFeed = StubFeed({})
        retriever = FeedRetrieval(StubTreq(noFeed.resource()))
        failedFeed = self.successResultOf(
            retriever.retrieve("http://missing.invalid/rss.xml"))
        self.assertEqual(
            failedFeed.asJSON(),
            {"error":"Failed to load http://missing.invalid/rss.xml: 404"}
        )
        self.assertTag(failedFeed.asHTML(),
            "a", {"href":"http://missing.invalid/rss.xml"},
            "Failed to load feed: 404")
    def test_unexpectedFailure(self):
        empty = StubFeed({b"empty.invalid": b""})
        retriever = FeedRetrieval(StubTreq(empty.resource()))
        failedFeed = self.successResultOf(
             retriever.retrieve("http://empty.invalid/rss.xml"))
        msg = "SAXParseException('no element found',)"
        self.assertEqual(
            failedFeed.asJSON(),
            {"error":"Failed to load http://empty.invalid/rss.xml: " + msg}
        )
        self.assertTag(failedFeed.asHTML(),
           "a", {"href": "http://empty.invalid/rss.xml"},
           "Failed to load feed: " + msg)
        self.assertTrue(self.flushLoggedErrors(SAXParseException))

assertTag方法确保深度为 1 的twisted.web.template标记树具有给定的名称、属性和子元素,简化了test_responseNotOKtest_unexpectedFailure方法。

test_responseNotOK方法创建了一个空的StubFeed应用,它将使用 404 来响应测试发出的任何请求。然后,它断言检索一个 URL 会导致一个被触发的Deferred,并将结果FailedFeed呈现给 JSON 和一个标记树。JSON 应该包含 URL 和 HTTP 状态代码,而 HTML 应该链接到失败的提要并包含状态代码。

test_unexpectedFailure方法创建一个StubFeed,用一个空字符串响应对empty.invalid的请求。结果FailedFeed实例的 HTML 和 JSON 呈现检查源 URL 以及导致失败的异常的repr。我们选择repr是因为许多异常的消息,像KeyError一样,没有它们的类名是无法理解的。

test_unexpectedFailure最后一行值得特别关注。与 Python 的unittest不同的是,trial不能通过任何测试,因为它不能恢复由它调用的代码记录的异常。请注意,这不包括测试本身引起的错误。

synchronoustestcase . flushloggederrors 返回到该时间点为止已记录的 twisted . python . failure . failure 列表;如果异常类型作为参数传递,则只返回与这些类型匹配的FailureflushLoggedErrors中的“flush”意味着这是一个破坏性调用,因此给定的Failure不会出现在两个连续调用返回的列表中。当测试完成时,如果记录的错误列表为非空,则测试失败。我们的测试断言至少有一个SAXParseException是由feedparser引发的,这有清除记录的错误列表的副作用,这应该允许测试通过。

让我们编写通过这些新测试所需的代码。我们将完整地展示新版本的FeedRetrieval,这样就可以在上下文中看到它的错误处理。

# src/feed_aggregation/_service.py

...
import treq import feedparser
from twisted.logger import Logger
from functools import partial
...

@attr.s(frozen=True)
class FailedFeed(object):
    _source = attr.ib()
    _reason = attr.ib()

    def asJSON(self):
        return {"error":"Failed to load{}:{}".format(
            self._source,self._reason)}

    def asHTML(self):
        return t.a(href=self._source)(
            "Failed to load feed:{}.".format(self._reason))

class ResponseNotOK(Exception):
    """A response returned a non-200 status code."""

@attr.s
class FeedRetrieval(object):
    _treq = attr.ib()
    _logger = Logger()
    def retrieve(self, url):
        self._logger.info("Downloading feed{url}", url=url)
        feedDeferred = self._treq.get(url)

        def checkCode(response):
            if response.code != 200:
                raise ResponseNotOK(response.code)
            return response

        feedDeferred.addCallback(checkCode)
        feedDeferred.addCallback(treq.content)
        feedDeferred.addCallback(feedparser.parse)

        def toFeed(parsed):
            if parsed[u'bozo']:
                raise parsed[u'bozo_exception']
            feed=parsed[u'feed']
            entries = parsed[u'entries']

            channel = Channel(feed[u'title'], feed[u'link'],
                            tuple(Item(e[u'title'], e[u'link'])
                                  for e in entries))
            return Feed(url, channel)

        feedDeferred.addCallback(toFeed)

        def failedFeedWhenNotOK(reason):
            reason.trap(ResponseNotOK)
            self._logger.error("Could not download feed{url}:{code}",
                               url=url, code=str(reason.value))
            return FailedFeed(url, str(reason.value))

        def failedFeedOnUnknown(failure):
            self._logger.failure("Unexpected failure downloading{url}",
                                 failure=failure, url=url)
            return FailedFeed(url, repr(failure.value))

        feedDeferred.addErrback(failedFeedWhenNotOK)
        feedDeferred.addErrback(failedFeedOnUnknown)
        return feedDeferred

FailedFeed类根据Feed的接口实现asJSONasHTML。因为初始化器是私有的,所以我们可以定义一个新的reason参数来解释提要下载失败的原因。

ResponseNotOK异常表示由非 200 状态代码引起的错误类别。这也是对retrieve本身的第一个更改:当treq.get返回的响应的状态代码指示失败时,checkCode 回调会引发ResponseNotOK,将代码传递给异常。

toFeed也做了改变,以适应feedparser笨拙的错误报告 API。feedparser's宽松解析的方法意味着feedparser.parse从不直接引发异常;相反,它将返回的字典中的bozo键设置为True,将bozo_exception键设置为实际的异常。

第二次加薪属于第二类意外错误。当然,还有许多可能的意外错误,确保我们的代码也能处理这些错误是很重要的。

failedFeedWhenNotOK errback 通过捕获ResponseNotOK并记录一条带有提要的 URL 和失败响应代码的error消息来处理第一类,而failedFeedOnUnknown errback 通过记录一条critical消息来处理第二类,该消息通过Logger.failure helper 方法包含失败的回溯。两者都返回了一个FailedFeed实例,该实例根据我们添加的测试的预期来呈现它们各自的失败。

当我们将错误添加到feedDeferred时以及添加的顺序都很重要。回想一下,当回调引发异常时,下一个注册的 errback 会处理它。通过在所有回调之后添加 errbacks,我们可以清楚地看到它们处理任何引发的异常。此外,由于一个 errback 会有效地引发自己的异常,并将其传递给下一个注册的 errback,因此我们在“??”和“??”之前添加了更具体的“??”和“??”。这些错误的净效果相当于以下同步代码:

try:
...
except ResponseNotOK:
    self._logger.error(...)
    return FailedFeed(...)
except:
    self._logger.failure(...)
    return FailedFeed(...)

使用twist运行 Twisted 应用

我们将项目分成两个独立的功能部分:FeedAggregation,它处理传入的 web 请求;和FeedRetrieval,它检索和解析 RSS 提要。FeedFailedFeed通过一个公共接口将两者绑定在一起,但是如果没有最后的修改,将应用组合成一个工作整体是不可能的。

就像我们的探索性SimpleFeedAggregation程序一样,当一个传入的 HTTP 请求到达时,FeedAggregation应该驱动FeedRetrieval。这个控制流意味着一个FeedAggregation实例应该包装一个FeedRetrieval实例,这可以通过依赖注入来实现;我们可以传递一个FeedRetrieval实例的retrieve方法和一个 feed URLs 列表来请求,而不是传递一个Feed条目列表给FeedAggregation。让我们修改FeedAggregationTests to那样做:

# src/feed_aggregation/test/test_service.py

...
class FeedAggregationTests(SynchronousTestCase):
    def setUp(self):
        service = StubFeed(
            {URL.from_text(feed._source).host.encode('ascii'): makeXML(feed)
             for feed in FEEDS})
        treq = StubTreq(service.resource())
        urls = [feed._source for feed in FEEDS]
        retriever = FeedRetrieval(treq)
        self.client = StubTreq(
            FeedAggregation(retriever.retrieve, urls).resource())
        ...

现在我们可以让FeedAggregation遵循这个新的 API:

# src/feed_aggregation/_service.py

@attr.s
class FeedAggregation(object):
    _retrieve = attr.ib()
    _urls = attr.ib()
    _app = Klein()
    _plating = Plating(
        tags=t.html(
            t.head(t.title("Feed Aggregator 2.0")),
            t.body(slot(Plating.CONTENT))))
    def resource(self):
        return self._app.resource()
    @_plating.routed(
        _app.route("/"),
        t.div(render="feeds:list")(slot("item")),
    )
    def root(self, request):
        def convert(feed):
            return feed.asJSON() if request.args.get(b"json") else feed.asHTML()
        return {"feeds": [self._retrieve(url).addCallback(convert)
                          for url in self._urls]}

FeedAggregation初始化器接受两个新参数:一个接受 URL 并返回解析为FeedFailedFeed实例的Deferredretrieve callable,和一个表示要检索的 RSS 提要 URL 的urls iterable。root处理程序通过将_retrieve callable 应用到每个提供的_urls来组合这两者,然后安排通过convert回调来呈现结果。

既然我们可以将应用的服务部分与检索部分组合在一起,那么我们可以在文件src/twisted/plugins/feed_aggregation_plugin.py中编写一个 Twisted application 插件来加载和运行我们的提要聚合服务。

Twisted 的twist命令行程序允许用户运行各种开箱即用的 Twisted 服务,就像带有twist web --path=/path/to/serve的静态 web 服务器一样。它还可以通过 Twisted 的插件机制进行扩展。让我们编写一个运行提要聚合 web 服务的插件。

# src/twisted/plugins/feed_aggregation_plugin.py

from twisted import plugin
from twisted.application import service, strports
from twisted.python.usage import Options
from twisted.web.server import Site
import treq
from feed_aggregation import FeedAggregation, FeedRetrieval
from zope.interface import implementer

class FeedAggregationOptions(Options):
    optParameters = [["listen", "l", "tcp:8080", "How to listen for requests"]]

@implementer(plugin.IPlugin, service.IServiceMaker)
class FeedAggregationServiceMaker(service.Service):
    tapname = "feed"
    description = "Aggregate RSS feeds."
    options = FeedAggregationOptions
    def makeService(self, config):
        urls = ["http://feeds.bbci.co.uk/news/technology/rss.xml",
                "http://planet.twistedmatrix.com/rss20.xml"]
        aggregator = FeedAggregation(FeedRetrieval(treq).retrieve, urls)
        factory = Site(aggregator.resource())
        return strports.service(config['listen'], factory)

makeFeedService = FeedAggregationServiceMaker()

A twisted.application.service.IService是由twist运行的代码单元,而 a twisted.application.service.IServiceMaker允许 twist 发现IService提供者,a twisted.plugin.IPlugin允许twisted.plugin发现插件。FeedAggregationServiceMaker类实现了这两个接口,所以它在twisted/plugins中的实例被twist选中。

tapname属性表示twist子命令的名称,我们的服务将在该子命令下可用,而description属性是twist将呈现给命令用户的文档。options属性包含一个twisted.python.usage.Options实例,它将命令行选项解析成一个传递给makeService方法的字典。我们的FeedAggregationOptions子类包含一个命令行选项--listen-l,它代表一个默认为tcp:8080端点字符串描述。我们稍后将解释这些是什么以及它们是如何工作的。

FeedAggregationServiceMaker.makeService接受我们的 Options 类返回的解析配置,并返回一个运行我们的FeedAggregation web 服务的IService提供者。我们在这里以与测试中相同的方式构造了一个FeedAggregation实例,除了这一次,我们向FeedRetrieval提供了实际的treq实现。

twisted.web.server.Site类实际上是一个知道如何响应 HTTP 请求的工厂。它接受一个twisted.web.resource.Resource作为它的第一个参数,这个参数将响应传入的请求,就像StubTreq在我们的测试中所做的一样,所以我们再次使用FeedAggregation.resource从底层的 Klein 应用中创建一个。

strports.service函数将端点字符串描述解析成管理指定端口的IService提供者。端点字符串描述为 Twisted 应用提供了极大的灵活性,使它们可以利用协议和传输来监听客户端。

默认的tcp:8080使 Twisted 在所有可用的接口上绑定 TCP 端口 8080,并将 TCP 传输与由Site工厂创建的协议实例相关联。然而,它可以被切换到ssl:port=8443;privateKey=server.pem,后者在端口 8443 上设置一个 TLS 监听器,使用server.pem证书建立连接。然后,由站点工厂创建的协议将被绑定到 TLS 包装的传输,该传输自动加密和解密与客户端的连接。strports解析器也可以通过第三方插件扩展;例如,txtorcon ( https://txtorcon.readthedocs.io/en/latest/ )允许通过onion:端点字符串描述启动 TOR 服务器。

现在,我们可以在虚拟环境中使用twist程序调用提要聚合服务:

$ twist feed
2018-02-01T12:12:12-0800 [-] Site starting on 8080
2018-02-01T12:12:12-0800 [twisted.web.server.Site#info] Starting factory <twisted.web.serve
2018-02-01T12:12:12-0800 [twisted.application.runner._runner.Runner#info] Starting reactor.
2018-02-01T12:13:13-0800 [feed_aggregation._service.FeedRetrieval#info] Downloading feed
2018-02-01T12:13:13-0800 [feed_aggregation._service.FeedRetrieval#info] Downloading feed
...

twist设置twisted.logger将日志信息格式化并打印到标准输出。FeedRetrieval消息对应于在FeedRetrieval.retrieve中发出的info消息,并暗示客户端访问了我们的应用。

twist也可以用--log-format=json将日志消息作为 JSON 对象发出

command line option:

$ twist --log-format=json feed
...
{"log_namespace": "...FeedRetrieval", "url": "http://feeds.bbci.co.uk/news/technology/rss.x
{"log_namespace": "...FeedRetrieval", "url": "http://planet.twistedmatrix.com/rss20.xml", .
...

为了使输出更具可读性,我们省略了许多细节。但是,请注意,FeedRetrieval._retrieveinfo调用的url参数是返回的 JSON 对象的一个属性。这允许日志聚合服务从日志消息中提取数据,而不需要像正则表达式那样的试探。像strports一样,这种行为上的改变根本不需要我们修改应用代码。

摘要

本章介绍了克莱因和treq。这两个库围绕 Twisted 的 web APIs 提供了高级包装器,简化了常见的开发模式。

我们使用古老的feedparser库编写了一个 RSS 2.0 feed 聚合服务,从一个简单的原型开始,然后使用测试驱动开发来构建一个可以用twist命令行程序运行的全功能 Twisted 应用。我们使用treq.testing.StubTreq在没有任何实际网络请求的情况下测试我们的 web 服务,使用SynchronousTestCase验证我们的并发操作在给定各种输入的情况下确定性地完成。在这个过程中,我们看到了 Klein 的 Plating 特性如何使我们能够构建可以用 JSON 和 HTML 响应的 web 服务,以及我们如何用twisted.logger记录结构化数据。

对并发性没有任何假设的第三方库的使用,如feedparserlxmlattrs,展示了 Twisted 程序如何与现代 Python 生态系统集成。同时,我们的程序使用了经典的 Twisted 概念,如Deferreds;我们的提要聚合服务展示了将 Python 庞大的库与 Twisted 自己的概念和代码相结合的力量。*

四、Docker 和 Twisted

Docker 常用于微服务架构。这些都是基于通过网络通信的不同组件。Twisted 本身支持多种网络范例,通常非常适合基于 Docker 的架构。

Docker 工人和一般的集装箱都是新的。工具和关于如何使用工具的共识都在快速发展。我们在这里给出了如何使用 Docker 的基础,因此我们可以在此基础上建立如何使用 Twisted in Docker 的理解。

注意 Docker 是一种基于 Linux 的技术。虽然其他操作系统也有类似的功能,但是 Docker 是建立在利用特定的 Linux 内核功能的基础上的。Docker for Windows 确实能够运行“Windows 容器”,但这超出了本章的范围。

Docker for Mac 和 Docker for Windows 使用运行 Linux 的虚拟机,并与主机操作系统(分别是 OS X 和 Windows)进行了足够的集成,以实现无缝交互。然而,重要的是要记住 Docker 容器总是在 Linux 内核上运行,即使是在 Mac 或 Windows 笔记本电脑上运行。

intro to docker

因为 Docker 既新又受欢迎,所以有几个不同的东西被称为“Docker”准确理解 Docker 是什么本身并不简单。我们试图将这里的“Docker”分成不同的概念。注意,这些中的每一个通常被称为“Docker”,以及包括它们的整体。

容器

容器是在比传统 UNIX 进程更隔离的情况下运行的进程。

在容器中,唯一可见的进程是那些由容器的根进程启动的进程,在容器中显示为进程 ID 1。注意,这是可选的:容器可以共享主机的进程 id。使用 Docker 命令行,这是通过参数--pid host完成的。

同样,容器也有自己的网络地址。这意味着容器内部的进程可以监听给定的端口,而无需与主机或其他运行的容器协调。同样,可以使用特殊的参数--net host运行容器,以便共享主机网络名称空间。

最后,每个容器都有自己的文件系统。例如,这意味着我们可以在不同的容器中安装不同的 Python,而不用担心甚至是冲突的 Python 包。直接共享主机文件系统是很棘手的。

但是,我们可以使用 Docker 的“volume mount”选项。卷装载选项要求在容器内使主机上的目录可访问(“装载”)。该选项的语法是用冒号将目录与主机(左侧)和容器(右侧)中要“装入”的目录分开。

因此,用--volume /:/from-host运行 Docker 将使主机的所有文件都可以访问。请注意,它们在容器内部是可访问的,不是在它们通常的位置,而是在/from-host目录中。

容器被精确地隔离到它们期望被隔离的程度。这类似于克隆系统调用的标志,指示父进程和子进程之间共享什么:例如,CLONE_FILES标志指示共享文件描述符表。

容器图像

容器是运行的、孤立的进程集时,容器映像允许我们实例化一个容器——它们相当于可执行映像。

在内部,容器映像由组成,每一层代表一个文件系统。容器将看到的最终文件系统(通常称为联合文件系统)是所有层的组合,较高的层覆盖较低的层。上层可以修改、添加甚至“删除”前一层中的文件。虽然下层不会受到影响,但容器内部可见的最终文件系统将受到影响。

这很重要,因为这意味着删除上层文件并不能节省空间。例如,如果第一层有一个 tarball,然后将其展开,则 tarball 通常是多余的。上层通常会有rm/path/to/file.tar.gz或类似的命令。这在文件名不可见的情况下是好的——但是,在整个容器映像的最终大小中——例如,需要下载多少字节来运行它 tarball 仍将被包括在内。

集装箱图像以其最终位置命名为(或者更准确地说是标签)。通常的命名方案是[optional host/] [ optional user/]name[:optional tag]。虽然也有例外,但那些永远不会离开构建它们的主机的映像通常会省略hostuser部分。

如果标签关闭,默认为:latest。如果主机关闭,默认为docker.io

注意,同一个容器图像可以有多个标签。

容器映像在注册中心主机之间移动:它们可以被“推”到注册中心,也可以被“拉”到主机。

Runc 和 Containerd

为了从一个映像运行一个容器,使用了一个叫做runc(“运行容器”)的特殊程序。这个程序负责设置适当的隔离机制:它使用 Linux 内核设施,比如 cgroups 和 namespaces,以便适当地隔离文件系统、进程名称空间和网络地址。

通常,容器用户不会直接与runc交互。然而,它被 Docker 堆栈和几乎所有的替代容器堆栈(如 Rocket)在幕后使用。

为了管理正在运行的容器,有必要知道哪些容器正在运行,以及它们的状态如何。出于这个原因,一个名为containerd的“守护程序”通过调用runc从图像中生成所有容器。

注意,在 Docker 的早期版本中,runc 被嵌入到containerd中——所以很多资料仍然将“Docker 守护进程”称为运行容器。

客户

与预期相反,命令行docker run不运行容器。相反,它与containerd守护进程通信,并要求它运行带有runc的容器。

默认情况下,它使用 UNIX 域套接字与服务器通信。UNIX 域套接字是基于 UNIX 的操作系统上一种特殊的进程间通信工具。它们的 API 类似于 TCP 套接字,但是它们只用于同一台机器内部的通信,允许内核做一些快捷方式。UNIX 域套接字使用文件路径作为它们的地址,而不是 IP 地址和端口。这允许应用通常的 UNIX 文件权限模型。

默认情况下,docker连接的 UNIX 域套接字是/var/run/docker.sock。根据 Docker 安装的具体细节,它可能由docker组或root组访问。Docker 客户端还可以使用 TLS over TCP 连接到服务器,使用 TLS 证书进行相互身份验证。

对于docker的所有其他子命令也是如此,比如buildimages等。(注意docker login是个例外,但是远程注册中心登录如何工作的解释超出了我们目前的范围。)

因为命令行docker主要用于向守护进程发送远程过程调用,所以我们称之为“客户端”

登记处

Docker 将图像保存在一个(通常是远程的)注册表中。注册表将每个图像存储为一些元数据,加上一组。元数据记录了层的顺序,以及容器图像的一些细节。

请注意,由于这种存储方法,同一层将只存储一次。几个图像共享层的常见情况是具有共同的祖先,这意味着从一个公共基础图像构建的多个图像不需要该图像的自己的副本。

还要注意,默认注册表docker.io内置于软件中——如果没有指定注册表,则采用默认注册表——通常称为“DockerHub”

这是单词“Docker”的一种稍微不同的用法,应该再次注意到这是一种可能会引起混淆的术语。

建设

构建映像的通常方式是使用docker build命令行。这使用了一个被称为Dockerfile的配置文件。Dockerfile以一条FROM线开始。FROM标识祖先形象。如果需要一个空图像,FROM scratch将使用没有图层的scratch图像。然而,这是罕见的。

通常,构建将从一个通用的 Linux 发行版开始,这些发行版都可以从默认的 Docker 注册表 DockerHub 中获得。比如 Debian,Ubuntu,CentOS 都有。

Dockerfile中的每一行都是一个“构建阶段”每个构建阶段都会创建一个层,并且层会被缓存。这意味着当修改一个Dockerfile时,只有被改变的行(以及它们后面的行)会被执行。

下面的例子就是这样一个Dockerfile,它将运行 Twisted web 演示服务器。

FROM debian:latest
RUN python3 -m pip install --user Twisted
ENTRYPOINT ["python3", "-m", "twisted", "web"]

这并没有展示最佳实践,我们将在查看更复杂的特性时介绍最佳实践,但是它展示了几乎总是出现在Dockerfile中的三个重要部分:

  • FROM线。在这里,我们要求的是debianlatest版本。请注意,因为我们没有使用带斜线的名称,所以这来自 DockerHub 上的“库”——一组半官方的基本图像。

  • RUN行在正在构建的容器中运行一个命令,通常的效果是以某种方式改变它。在这种情况下,我们将 Twisted 安装成一个user安装。

  • ENTRYPOINT行设置容器启动时将运行的程序。

多阶段构建

上面的解释缺少了一个重要的新功能,是在docker build中于 2017 年年中添加的。这些是多阶段构建。当Dockerfile中有一个以上的FROM行时,就会发生多阶段构建。

当这种情况发生时,构建过程开始构建新的映像,在构建结束时,所有非最终映像都将被丢弃。然而,当构建运行时,其他映像可以通过一个Dockerfile命令—COPY访问。

当使用COPY --from=<image>时,它不会从上下文中复制文件,而是从以前的图像中复制。虽然在理论上,多阶段构建可以有任意数量的阶段,但需要两个以上的阶段是非常罕见的。图像的排序使用从 0 开始的编号。大多数“多阶段”构建实际上是“两阶段”构建。第一阶段将构建所有的工件,使用一个充满编译器和构建工具的“厚”映像。第二阶段从第一阶段提取所有的工件,并生成最终的图像进行分发。正因为如此,级间指令通常采用的形式是COPY --from=0

当需要一个复杂的构建环境来生成一些将要部署的产品时,这很有用——最好不要在最终的运行时容器中提供复杂的构建环境:这样可以减少规模、层数和潜在的安全风险。

下面是一个多阶段构建的示例。请注意,在这种情况下,最终输出并不打算直接使用,而是在其他构建中构建。这是一种常见的模式:构建具有通用元素的标准库有几个优点——例如,这可以节省注册表和运行服务器中的空间(如果多个不同的映像在一台服务器上运行,这是常有的事)。另一个好处是当 bug 被修复时,有一个地方可以升级基础包。

FROM python:3
RUN mkdir /wheels
RUN pip wheel --wheel-dir /wheels pyrsistent

FROM python:3-slim
COPY --from=0 /wheels /wheels
RUN pip install --no-index --find-links /wheel pyrsistent

同样,我们将逐行解释这里发生了什么:

FROM python:3

python:3库是标准“DockerHub 库”库的另一个例子。它包括 Python 3,但也包括足够的工具来构建本机代码轮——至少是简单的,没有进一步的依赖性。

RUN mkdir /wheels

我们创建目录来存储轮子。请注意,因为这个阶段不会出现在最终输出中,所以我们对创建额外的层并不敏感。事实上,额外的层是好的——它们创造了更多的缓存点。在这种情况下,这没什么意思,但是构建基础通常包括安装更多的构建依赖项。

RUN pip wheel --wheel-dir /wheels pyrsistent

pip wheel子命令在多阶段构建中很有用。它为指定的需求及其所有依赖项构建了一个轮子。如果平台兼容,它将使用 PyPI 为manylinux构建的二进制轮——但如果需要,可以用pip wheel --no-binary :all关闭这种行为。

FROM python:3-slim

python:3-slim基础类似于 python:3,但是不包括复杂的构建时依赖集。请注意,Python 发行版中的 many :code: setup.py会自动检测编译器或依赖项的缺失,并自动关闭本机代码模块的构建。例如,pyrsistent有一个 C 优化的持久矢量实现,这是我们想要的。因此,我们不想在这个阶段安装pyrsistent

COPY --from=0 /wheels /wheels

我们复制刚刚构建的pyrsistent轮,以及从第一阶段(阶段 0)到当前阶段的任何依赖关系。第二条FROM线表示这是一个多阶段构建——但是这条COPY线使多阶段构建变得有用。

RUN pip install --no-index --find-links /wheel pyrsistent

最后,我们将库安装到本地 Python 环境中。我们小心地为pip指定了--no-index--find-links选项,这样它将使用第一阶段的轮子,而不是从 PyPI 获得新的发行版。

Docker 上的 Python

像在任何 UNIX 平台上一样,在 Docker 上部署 Python 应用有很多种方法。它们并不完全相同,有些比其他的更好。我们将调查那些效果较好的选项。

部署选项

全环境

“全环境”部署意味着有一个专门为应用安装的定制 Python 解释器。这个 Python 可以作为 Docker 构建过程的一部分或之前从源代码定制构建,也可以来自元发行版,如condanix

安装一个定制的 Python 解释器通常是有用的:我们可以在其上定制构建选项,固定解释器版本,甚至在特别极端的情况下,应用定制补丁。然而,这意味着我们承担了使解释器保持最新的任务。

无论我们如何安装这个解释器,它将完全用于我们的应用。我们使用pip install在其中安装包——或者,如果它来自元发行版(比如condanix),我们也可以从元发行版安装包。这对于 conda 尤其有用,因为有许多与数据科学相关的 Python 包可供安装。

这里有一个例子Dockerfile,它构建了一个定制的 Python 解释器,装载了必要的包。

FROM buildpack-deps:stretch

ENV PYTHON_VERSION 3.6.4
ENV PREFIX https://www.python.org/ftp/python

ENV LANG C.UTF-8

ENV GPG_KEY 0D96DF4D4110E5C43FBFB17F2D347EA6AA65421D

RUN apt-get update
RUN apt-get install -y --no-install-recommends \
        tcl \
        tk \
        dpkg-dev \
        tcl-dev \
        tk-dev

RUN wget -O python.tar.xz \
    "$PREFIX/${PYTHON_VERSION%%[a-z]*}/Python-$PYTHON_VERSION.tar.xz"
RUN wget -O python.tar.xz.asc \
    "$PREFIX/${PYTHON_VERSION%%[a-z]*}/Python-$PYTHON_VERSION.tar.xz.asc"
RUN export GNUPGHOME="$(mktemp -d)" && \
    gpg --keyserver ha.pool.sks-keyservers.net --recv-keys "$GPG_KEY" && \
    gpg --batch --verify python.tar.xz.asc python.tar.xz
RUN mkdir -p /usr/src/python
RUN tar -xJC /usr/src/python --strip-components=1 -f python.tar.xz

WORKDIR /usr/src/python

RUN gnuArch="$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)"
RUN ./configure \
    --build="$gnuArch" \
    --enable-loadable-sqlite-extensions \
    --enable-shared \
        --prefix=/opt/custom-python/
RUN make -j
RUN make install
RUN ldconfig /opt/custom-python/lib
RUN /opt/custom-python/bin/python3 –m pip install twisted

FROM debian:stretch

COPY --from=0 /opt/custom-python /opt/custom-python
RUN apt-get update && \
    apt-get install libffi6 libssl1.1 && \
    ldconfig /opt/custom-python/lib
ENTRYPOINT ["/opt/custom-python/bin/python3", "-m", "twisted", "web"]

构建定制的 Python 解释器虽然有用,但并不简单。我们一行一行地检查这个文件:

FROM buildpack-deps:stretch

对于建筑来说,buildpack-deps是一个有用的基础图像。由于我们将使用 Debian“stretch”作为我们的部署版本,在撰写本文时它是最新的稳定 Debian 版本,所以我们得到了 stretch 兼容的 buildpack。

ENV PYTHON_VERSION 3.6.4
ENV PREFIX https://www.python.org/ftp/python

设置这些可以让我们轻松地修改我们使用的 Python 版本——这对从上游获得新的安全修复和错误修复是必不可少的。我们越容易升级 Python,我们的情况就越好。

ENV LANG C.UTF-8

将语言明确设置为 UTF-8 是必要的,以避免在 Python 构建过程中出现不明显的错误。虽然在教学上没有启发性,但作为一个放置这些变通办法的地方,这是有用的。将这些细节放在 docker 文件中是确保构建成功的一个方便的地方——无论是在持续集成系统上还是在本地。

ENV GPG_KEY 0D96DF4D4110E5C43FBFB17F2D347EA6AA65421D

这是 GnuPG 公钥,对应于签署 Python tarball 上传的私钥。Gnu Privacy Guard 是一个使用密码术来实现安全保证的工具。在这种情况下,密钥允许我们知道源代码没有被篡改。这是一个好主意,增加纵深防御,并使用多种方法来验证我们的来源是真实的。这个Dockerfile,或者类似的,经常被用在持续集成环境中,在那里它们被重复地和自动地运行。只需一次破坏就能严重危及基础设施。如果源代码没有保证,确保构建失败可以消除代价高昂的生产违规。

将密钥指纹保存在 docker 文件中(可能会签入到源代码控制中),是在签入的代码中建立信任的一种方式。

RUN apt-get update
RUN apt-get install -y --no-install-recommends \
        tcl \
        tk \
        dpkg-dev \
        tcl-dev \
        tk-dev

除了 buildpack 之外,我们还需要一些额外的库。我们把它们安装在这里。

RUN wget -O python.tar.xz \
    "$PREFIX/${PYTHON_VERSION%%[a-z]*}/Python-$PYTHON_VERSION.tar.xz"

接下来,我们下载 Python 源代码 tarball。定义上面的变量可以让我们保持这一行简短明了。此外,即使对于稳定版本来说不是必需的,这个命令行也可以用于像3.6.1rc2这样的版本——如果我们想使用这个 docker 文件,只做很小的修改,来测试与候选发布版本的兼容性,那么这个命令行是必需的。

RUN wget -O python.tar.xz.asc \
    "$PREFIX/${PYTHON_VERSION%%[a-z]*}/Python-$PYTHON_VERSION.tar.xz.asc"

我们下载分离的公钥签名。尽管我们从支持 TLS 的网站下载这两个版本,一个是以https为前缀,而不是http,检查签名是一个很好的深度防御措施。

RUN export GNUPGHOME="$(mktemp -d)" && \
    gpg --keyserver ha.pool.sks-keyservers.net --recv-keys "$GPG_KEY" && \
    gpg --batch --verify python.tar.xz.asc python.tar.xz

此命令行验证公钥。注意,这是一个命令的例子,它不改变本地状态。然而,由于任何失败的命令都将停止docker build进程,一个关键验证错误将导致构建暂停。

RUN mkdir -p /usr/src/python

我们为解压后的源代码创建一个目录。注意,由于这是一个多阶段的构建,我们不关心这个目录的最终清理——整个容器都将被清理!

RUN tar -xJC /usr/src/python --strip-components=1 -f python.tar.xz

我们将 Python tarball 解压到新创建的目录中。

WORKDIR /usr/src/python

我们将当前工作目录设置为源代码目录。这使得需要从内部运行的后续构建指令更短、更容易理解。

RUN gnuArch="$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)" && \
  ./configure \
    --build="$gnuArch" \
    --enable-loadable-sqlite-extensions \
    --enable-shared \
        --prefix=/opt/custom-python/

我们运行。/configure 脚本,带有自定义前缀。自定义前缀/opt/custom-python确保我们将处于一个原始的目录中。我们还提供了一些选项来确保我们的 Python 构建是正确的:

  • 使用dpkg-architecture计算架构,并显式传递给配置脚本。这比让配置脚本自动检测它更可靠。

  • 我们启用sqlite模块。由于它是内置的,许多第三方模块将依赖于它而不声明依赖关系,所以确保它是安装的一部分是很重要的。

  • 我们启用共享库。在我们的例子中,这不是绝对必要的,但是它允许嵌入 Python 的情况。

RUN make -j

计算 CPU 的确切数量并不简单。在这个例子中,我们只是以最大并行度运行 make。这就是-j标志的作用。注意,一般情况下,建议通过给-j一个数字参数,例如-j 4,将并行度设置到一个合理的水平。

RUN make install

此阶段会将具有正确权限的文件复制到安装目录中。

RUN ldconfig /opt/custom-python/lib

我们将目录添加到我们的库搜索路径中——否则 Python(动态链接的)无法运行。

RUN /opt/custom-python/bin/python3 -m pip install twisted

我们安装 Twisted 的。在 Twisted 的许多其他好处中,它包含一个方便的默认 web 服务器,这对演示很有用。

FROM debian:stretch

对于产品构建,我们从一个合适的最小 Debian 发行版开始——保持它是构建包的匹配版本。

COPY --from=0 /opt/custom-python /opt/custom-python

我们复制整个环境——包括安装的第三方库:在本例中,是 Twisted 及其依赖项。

RUN apt-get update && \
    apt-get install libffi6 libssl1.1 && \
    ldconfig /opt/custom-python/lib

我们安装必要的库并在生产映像中运行ldconfig

ENTRYPOINT ["/opt/custom-python/bin/python3", "-m", "twisted", "web"]

我们设置入口点来运行 Twisted 内置的演示 web 服务器。如果我们构建并运行这个 docker 映像,web 服务器将会运行——如果我们导出端口,我们甚至可以用浏览器检查它。

Virtualenv(虚拟环境)

完整环境的替代方案是“轻量级”环境,也就是人们所说的虚拟环境。当使用 Python 2.7 时,我们使用virtualenv包创建一个虚拟环境。使用pip安装virtualenv是可能的,但是这有问题:毕竟,如果创建虚拟环境的原因是为了避免改变真实环境,这就失去了好处。一种方法是用我们获得 Python 的方式获得virtualenv。另一种方法是使用

pip install --user virtualenv

这将把它放在用户目录下(在 Docker 上,通常在/root下)。这通常意味着virtualenv不在默认的 shell 路径上——但是因为它在 Python 路径上,

python -m virtualenv <directory>

仍然可以工作并创建一个虚拟环境。

当使用 Python 3.x 时,这些问题是没有实际意义的:python -m venv是为 Python 3.x 创建虚拟环境的最佳方式。请注意,一些文档尚未更新,virtualenv 在 Python 3.x 上运行——这使得确保所有这些都是最新的变得更加困难。然而,venv内置模块的存在极大地简化了虚拟环境的引导。

在虚拟环境中安装代码的好处之一是,我们知道虚拟环境的目录只包含运行它所必需的内容——除了解释器。当我们构建 Docker 映像时,这个特性会派上用场。

把所有这些想法放在一起,我们可能会得出这样一个Dockerfile:

FROM python:3

因为我们要构建一个虚拟环境,所以我们需要已经安装了一个完整的环境。最简单的方法之一是从python容器开始。

RUN python -m venv /opt/venv-python

我们在/opt/venv-python中创建一个虚拟环境。

RUN /opt/venv-python/bin/pip install Twisted

我们在里面安装了扭结。注意,安装 Twisted 意味着安装几个带有 C 扩展的包——这个阶段需要一个 C 编译器。容器映像拥有构建 C 扩展所需的所有工具。

FROM python:3-slim

python:3-slim容器映像没有构建工具。由于这是我们将发布的映像,这意味着我们不会将 C 编译器发布到产品中。

COPY --from=0 /opt/venv-python /opt/venv-python

我们复制虚拟环境。请注意,虚拟环境中有几个硬编码的路径。这就是为什么我们要确保使用与部署路径相同的路径来创建它。

ENTRYPOINT ["/opt/venv-python/bin/python", "-m", "twisted", "web"]

入口点与之前的入口点几乎相同。唯一的区别是路径——这次指向的是虚拟环境,而不是完整的环境。

最大运动量

最独立的选项是 Pex——Twitter 首创的 Python 可执行格式。Pex 结合了 UNIX、Python 和 Zip 归档的功能,具有包含所有应用代码和第三方依赖项的单一文件格式。

Pex 文件应该在文件系统级别被标记为可执行的,例如使用chmod +x,并使用调用 Python 解释器的 shebang 行(!#)生成。由于 Zip 存档具有独特的属性,即它们是通过它们的最后字节而不是第一个字节来检测和解析的,因此文件的其余部分是一个 Zip 文件。

当 Python 解释器接受一个 Zip 文件,或者一个带有任意内容的 Zip 文件时,它会将其视为 sys.path 附加文件,并且会额外执行存档中的__main __.py文件。Pex 文件生成一个定制的__main__ .py,它调用入口点或执行 Python 模块,这取决于传递给 Pex builder 的参数。

Pex 可以通过使用pex命令行(随pip install pex一起安装)、使用pex作为 Python 库并使用其创建 API 来构建,也可以通过大多数现代元构建器来构建——Pants、Bazel 和 Buck 都能够生成 Pex 输出。

FROM python:3
RUN python -m venv /opt/venv-python

我们创造了一个虚拟环境。虽然我们不打算发布这个环境,但它将帮助我们构建 Pex 文件。

RUN /opt/venv-python/bin/pip install pex

我们安装 pex 实用程序。

RUN mkdir /opt/wheels /opt/pex

我们创建两个目录来包含不同种类的产品。

RUN /opt/venv-python/bin/pip wheel --wheel-dir /opt/wheels Twisted

我们用pip来制造轮子。这意味着我们将使用pip依赖解析算法。虽然客观上并不比pex算法更好,但它是其他任何地方都使用的算法。这意味着如果软件包在pip依赖关系解析过程中遇到问题,它们将添加正确安装所需的任何提示。不常用的pex就没有这种保证。

RUN /opt/venv-python/bin/pex --find-links /opt/wheels --no-index \
                             Twisted -m twisted -o /opt/pex/twisted.pex

我们构建 Pex 文件。注意,我们告诉pex忽略 PyPI 索引,只从一个特定的目录中收集包——在这个目录中pip放置了它构建的所有轮子。我们配置 Pex 文件的行为就像我们用-m twisted运行 Python 一样,我们把输出放在/opt/pex中。虽然后缀不是绝对必要的,但在检查 Docker 容器图像时,它非常有用,有助于理解事物是如何运行的。

FROM python:3-slim

同样,我们避免使用第二阶段的slim映像将构建工具交付给生产。

COPY --from=0 /opt/pex /opt/pex

我们复制目录——这一次,它只有一个文件。还要注意,这一次,文件是可重定位的:可以(尽管我们在这里不这样做)复制到不同的路径。

ENTRYPOINT ["/opt/pex/twisted.pex", "web"]

在前面的例子中驻留在ENTRYPOINT(我们想要运行python -m twisted)中的一些逻辑现在被构建到 Pex 文件中。我们的ENTRYPOINT现在更短了。

构建选项

不管 Python 是以什么方式运行的,Docker 容器是以什么方式构建的也有很多选择。

一个大包

一种方法是完全避免多阶段构建,使用构建环境所需的任何工具构建一个容器。这通常意味着容器很大,有很多层。

虽然这种方法简单、直接且易于调试,但它也有缺点。容器尺寸很容易成为生产中的一个问题。类似地,层数减慢了容器的部署。最后,将大量的包放在一个暴露于潜在的恶意用户输入的容器中会导致更多的攻击媒介。

阶段之间的复制轮

另一种方法是在构建阶段构建所有轮子,包括任何二进制轮子,然后将它们复制到生产阶段。在这种情况下,生产阶段仍然需要足够的工具来创建一个虚拟环境并在其中安装这些轮子——尽管由于venv是 Python 3 中的一个 Python 内置模块,这通常不再是一个难题。

还有另外两个问题:轮子在安装后仍然存在,因为在切换层后不可能真正删除一个文件;并且它经常创建额外的层(尽管通过巧妙的重新排序和反斜杠继续的行,这有时是可以避免的)。

在阶段之间复制环境

另一个部署选项是将环境(可以是完整的或虚拟的)从构建阶段复制到生产阶段。这种方法的优点是快速、简单,缺点是没有兼容性检查、依赖性检查或位置检查。尽管如此,如果对生成的容器进行了适当的测试,通常会发现基本的不兼容问题。

在阶段之间复制 Pex 可执行文件

最后,如果 Pex 可执行文件是在构建阶段生成的,那么复制它是很简单的。当然,Pex 文件会在运行时寻找依赖关系。然而,它会做一个可靠的检查,所以即使启动容器也足以测试它。

它也是可重定位的,所以它从哪里拷贝或者拷贝到哪里都没有关系。Pex 和 Docker 通常是很好的组合。然而,Pex 的固有限制(例如,预构建的二进制轮子支持差或 PyPy 支持差)有时使它无法成功。

Dockerpy 自动化

一个名为dockerpy的包允许用 Python 实现 Docker 步骤的自动化。虽然通常在生产中运行容器,我们将使用编排框架,这通常对构建和测试容器有用。dockerpy库允许我们仔细微调发送给 Docker 守护进程的上下文——使用tarfile Python 模块,可以精确地制作所需的上下文。

在 Docker 上扭转

入口点和 PID 1

Docker 的ENTRYPOINT Dockerfile 指令中的进程在容器内部将具有进程 ID 1。进程 ID 1 在 Linux 上有特殊的责任。当一个进程的父进程在它死亡之前死亡时,PID 1“采用”它——成为它的父进程。这意味着当子进程终止时,PID 1 需要“收获它”——等待它的退出状态,以便从进程表中清除进程条目。

这个责任有点诡异,很多节目都不是为它设置的。当运行一个不接收领养儿童的程序时,进程表将会填满。在最好的情况下,这将使容器崩溃。在最坏的情况下,当没有仔细设置进程限制时,这可能会使运行容器的整个机器(虚拟的或物理的)崩溃。

幸运的是,任何Twisted 的程序都设置为 PID 1。这是因为 Twisted 的流程基础设施会自动收获预期的和意外的孩子。

这意味着当构建一个容器时,如果我们用它来运行 WSGI 应用,或者 Klein 应用,或者 Buildbot master,让它作为入口点是很好的。

事实上,由于这个原因,如果有任何自定义的启动代码要做,可以考虑将其实现为 tap 插件。这样,Twisted 还是可以作为切入点的。

自定义插件

当编写一个在 Docker 中运行的 Twisted 应用时,我们几乎总是希望将它作为一个定制的tap插件来交付。这使得ENTRYPOINT简单

["/path/to/python", "-m", "twisted", "custom_plugin"]

这意味着插件可以获得传递给docker run命令的任何参数——因为这些参数被直接添加到ENTRYPOINT参数中。这也意味着插件可以直接读取通过--env传递给docker run的任何环境变量。

在插件中,makeService函数是返回正在运行的服务的函数。请注意,插件可以在该函数中进行任何它想要的初始化——事件循环此时还没有运行。

殖民地

有时,有必要在 Docker 容器中运行多个进程。也许是一些辅助进程来清理文件,或者是一个多进程设置来使用多个 CPU。在这种情况下,一个进程管理者是有用的——运行几个进程,监视它们,并在必要时重新启动它们。

NColony 是一个基于 Twisted 的流程主管。它是围绕twisted.runner.procmon的一个小垫片,允许几个灵活的配置选项。NColony 将配置作为描述进程的 JSON 格式文件的目录。

当然,也可以通过打开一个文件并将 JSON 写入其中来直接创建这些文件。然而,NColony 还附带了一个命令行实用程序—python -m ncolony ctl—来创建这样的文件,以及一个 Python 库—ncolony.ctllib

目录模型的一个优点是,这意味着它可以与 Docker 容器的层模型很好地交互。一个本地基础容器可以有一个["python", "-m", "twisted", "ncolony", ...]ENTRYPOINT,甚至在配置目录中有几个基础进程——通常是/var/run/ncolony/config/。然后,特定的 containerd 可以在这个目录中转储它们自己的文件,这些文件是在容器的构建阶段使用例如python -m ncolony ctl创建的。最终的容器将同时运行副进程和主进程。

这里有一个例子,它将本章讨论的大部分内容具体化了:

FROM python:3
RUN python3 -m venv /application/env
RUN /application/env/bin/pip install ncolony
RUN mkdir /application/config /application/messages
RUN /application/env/bin/python -m ncolony \
    --config /application/config \
    --messages /application/messages \
    ctl \
    --cmd /application/env/bin/python \
    --arg=-m \
    --arg=twisted \
    --arg=web

FROM python:3-slim
COPY --from=0 /application/ /application/
ENTRYPOINT ["/application/env/bin/python", \
            "-m", \
            "twisted", \
            "ncolony", \
            "--config", "/application/config", \
            "--messages", "/application/messages"]

我们一行一行地检查,这里有很多东西。

FROM python:3

获得我们的 Python 环境的一种方法是使用官方的 Docker(“库”)映像。这是基于 Debian 发行版的,其中有 Python——以及构建 Python 和 Python 扩展模块所需的所有工具。

RUN python3 -m venv /application/env

我们在/application/env中创建一个虚拟环境。如前所述,Python 3 使虚拟环境成为一个内置的概念,我们充分利用了它。

RUN /application/env/bin/pip install ncolony

为了更好的可重复构建,最好是复制一个需求文件——最好是一个也有散列的文件——pip install。然而,当我们直接使用一个包名时,更容易看到发生了什么。

RUN mkdir /application/config /application/messages

NColony 需要两个目录才能正常工作:一个用于配置,一个用于消息。我们在/application下创建它们。配置是需要运行的一组进程及其参数。消息是瞬时请求——通常是重启一个或多个进程的请求。

RUN /application/env/bin/python -m ncolony \

我们从安装在/application/env虚拟环境中的 NColony 运行子命令。

--config /application/config \
--messages /application/messages \

我们传递 NColony 的参数。虽然在这种情况下没有使用 messages 目录,但是最好将它们都传递给所有命令。

ctl \

Control ( ctl)是控制配置的 NColony 子命令。

--cmd /application/env/bin/python \

我们运行与我们运行的 Python 相同的 Python。注意,一般来说,这对于 NColony 来说是不必要的。然而,为不同的用途编写使用完全不同的解释器的代码会令人困惑。

--arg=-m \
--arg=twisted \

NColony 正在监控的进程不一定是一个 Twisted 的进程,但在我们的例子中,它将是一个 Twisted 的进程——事实上,是另一个tap插件。

--arg=web

当没有给定参数时,web tap插件显示一个演示 web 应用。这对于快速演示和检查来说非常有用——就像这个例子一样。

FROM python:3-slim

第二行FROM开始生产 Docker 图像。注意——当构建完成时,到目前为止构建的所有东西都将被丢弃。早期步骤存在的唯一原因是从那个短暂的阶段复制。这个源映像是一个最小的 Debian,加上一个已安装的 Python 3。

COPY --from=0 /application/ /application/

我们复制整个应用目录。因为这个目录既有虚拟环境又有 NColony 配置,所以我们不需要其他东西。这一行的简单性解释了我们为设置这个目录所做的所有细致工作的价值。

ENTRYPOINT ["/application/env/bin/python", \ "-m", \
            "twisted", \ "ncolony", \
            "--config", "/application/config", \
            "--messages", "/application/messages"]

最后,我们配置入口点。由于 NColony 本身是一个 tap 插件,我们再次运行的命令是python -m twisted <plugin>

在这个例子中,我们可以直接运行 web 服务器作为入口点。然而,一个实际上需要几个进程的更现实的例子会掩盖让 NColony 在 Docker 中运行的基本机制。

摘要

Docker、Python 和 Twisted 是互补的技术。具有多阶段构建和注册的 Docker 为 Python 提供了一种标准化的方式来指定构建过程和打包。Twisted 及其进程管理原语为 Docker 提供了一个有用的 PID 1,它可以独立完成有用的工作——例如 web 服务器——或者是一个强大的基础层 NColony 非常适合 Docker 层模型。

Docker 是构建、打包和运行 Twisted 应用的实用方法,而 Twisted 是在 Docker 内部运行的有用工具。

五、使用 Twisted 作为 WSGI 服务器

WSGI 简介

WSGI——Web 标准网关接口——是 Python 标准。它松散地基于 CGI——通用网关接口——标准,web 服务器用来与脚本交互。随着负载的增加,需要在 web 服务器中有一个持久的 Python 进程。最初,每个服务器都有自己独特的运行 Python 应用的方式。这意味着每个应用都必须在一个 web 服务器上做出决定,并且不能移动。WSGI 是作为用 Python 编写的 web 应用的底层标准而设计的,用于与可以在内部运行 Python 的 web 服务器进行交互(要么用 Python 编写,要么嵌入 Python 解释器)。

WSGI 标准定义了两者之间的接口:WSGI web 应用和 WSGI web 服务器

Twisted 有一个 web 服务器,它在实现自己独特的基于 web 的 API 的同时,还实现了 WSGI 标准。因为它实现了 WSGI 标准,所以它可以运行任何支持 WSGI 的 Python web 应用。

通常,Python web 应用不会直接与 WSGI 交互。相反,web 框架——如 Django、Flask 或 Pyramid——负责作为应用与 WSGI 接口,并向 web 应用提供更高级别的接口。这些接口是特定于 web 框架的——我们并不期望一个应用可以很容易地从 Django 移植到 Pyramid。

打个比方,把 web 框架的选择想象成类似于编程语言的选择,把 web 服务器的选择想象成类似于操作系统的选择。我们期望在操作系统之间移动将允许我们保持大部分代码的完整性(可移植性),但是我们不期望在转换编程语言时也是如此。

从 web 服务器的角度来看,支持 WSGI 意味着它们不知道所使用的 web 框架——运行金字塔应用与运行 Flask 是一样的。从 web 框架的角度来看,支持 WSGI 意味着它们与所使用的 web 服务器无关——在 Apache 上运行与在 uwsgi 上运行是一样的。

WSGI 不是在真空中诞生的。在设计它的时候,已经有许多服务器和许多 Python web 框架。正因为如此,WSGI 被设计成易于实现——无论是在服务器端还是在 web 框架端。事实上,它与 CGI 的相似性就是结果。许多这些框架已经支持 CGI,添加 WSGI 支持几乎不需要做什么工作。

WSGI 是在 2003 年设计的。它提到的许多框架的名字——例如,Quixote 和 Webware——现在是 web 框架早期实验的遗迹。虽然它没有明确提到它的名字,但当时唯一重要的服务器是 Apache——自那以后,它的受欢迎程度急剧下降。

然而,尽管流行的框架和流行的服务器都是较新的,WSGI 标准却经受住了考验。

WSGI API 的定义很微妙。它试图抽象的标准 HTTP 是复杂的。现代 web 应用需要访问这种复杂性。这个定义跨越了两个文档,有时会显得令人不知所措。

这一节将分解 WSGI 并解释组成它的各个部分。

精力

Python 的所有主要增强都要经过 PEP (Python 增强提案)过程。WSGI 作为一大特色,最初是在人教版 0333 中描述的。PEP 0333 最初创建于 2003 年 12 月,于 2004 年 8 月完成。

虽然这个 PEP 对于 Python 2.x 仍然是正确的,但 PEP 3333 描述了如何为 Python 2.x 和 Python 3.x 实现 WSGI。PEP 3333 创建于 2010 年 9 月,并于 2010 年 10 月完成。

这是对 PEP 0333 的一个相当小的改动,处理了在 Python 2.x 和 Python 3.x 之间 WSGI 的正确实现。为了理解为什么它是必要的,理解 Python 2.x 和 Python 3.x 之间的变化是很重要的。

Python 2.x 和 Python 3.x 之间的主要变化之一是对 unicode 的处理,特别是字节、字符串和 unicode 类型发生了重大变化。WSGI 作为一个处理(最终)通过 TCP 连接传输字节的标准,需要被细化以澄清哪些类型属于 Python 3.x 中的什么地方。

虽然对这些变化的详细解释超出了我们目前的范围,但一些解释对于澄清这些问题是很重要的。Python 2.7+和 Python 3.x 都有一个 bytes 类型(字节序列)和一个 unicode 类型(unicode 码位序列)。然而,字符串类型相当于 Python 2.7+中的字节类型,相当于 Python 3.x 中的 unicode。

编码是字节和 unicode 之间的(可能是部分)映射。ASCII 就是这样一种编码——将 128 以下的字节映射为相同值的 unicode 点,并将所有其他字节声明为无效。Latin-1(或 ISO-8859-1)是一种将所有字节映射到相同值的 unicode 点的编码,如果不存在该值的 unicode 点,则声明该字节无效。

在管理 web 的协议 HTTP 中,它被分成头部,后面是主体;如果正文是文本的,那么头会指出它是用什么编码的。

对报头本身进行编码的问题很微妙:PEP 3333 将它们视为 Latin-1(也称为 ISO-8859-1),而 Twisted 将它们编码为 UTF-8。最安全的做法是确保所有报头都符合 UTF-8 和拉丁-1 的公共子集:ASCII。这确保了无论我们的头经过什么编码/解码,它们都将保持完整。

在 PEP 3333 中,头应该是本地字符串类型 Python 2.x 的字节和 Python 3.x 的 unicode 而内容应该总是字节。

PEP 3333 和 PEP 0333 也描述了 WSGI 中间件的概念——对于应用来说它看起来像服务器,对于服务器来说它又像应用。虽然存在一些 WSGI 中间件,但是请注意一些流行的框架——特别是 Django 和 Pyramid——有它们自己的中间件概念。然而,Flask 依赖于 WSGI 中间件。

原始示例

最简单的 WSGI 应用确实很简单:

def application(
                environment,
                start_response):
    start_response('200 OK', [('Content-Type', 'text/html')])
    return [b'hello world']

我们将逐行解释每个 WSGI 应用应该具有的三个主要部分:

def application(

在 Python 中,函数定义完成两件事:

  • 创建一个函数对象。

  • 为其指定一个名称。

具体来说,这个函数定义创建了一个函数对象,并将其指定给名称application

这意味着application现在指向一个可调用的对象。这就是 WSGI 应用,按照 PEP 3333:可调用对象。

environment,

第一个参数是所谓的“环境”。这个名字可以追溯到 WSGI 的起源,它是对 CGI 标准的快速改编。

CGI 标准处理 web 服务器如何执行脚本。该标准的一部分定义了这些脚本可以访问的环境变量。事实上,大多数关于 web 请求的数据都可以从 CGI 下的环境变量中获得。WSGI 标准采用了相同的变量名和环境的概念,并将其称为 WSGI 应用的第一个参数。

environment参数是一个 Python 字典,将指定的名称映射到关于 web 请求的数据。在上面的示例应用中,这个参数被忽略了,因为我们总是使用一个常量值。如果这就是我们所需要做的,我们只需要一个静态的 HTML 页面——大多数真正的应用在某种程度上依赖于用户输入。

start_response):

第二个参数,通常称为start_response,是一个微妙的——也是经常被误解的——参数。它是可调用的,接受两个参数:HTTP 响应代码和 HTTP 头。

start_response('200 OK', [('Content-Type', 'text/html')])

我们做的第一件事是调用start_response callable。第一个参数是 200 OK,表示正常的成功 HTTP 响应。第二个参数是头列表。在这种情况下,我们发送的唯一报头是Content-Type报头。这表明我们的响应应该被浏览器解释为 HTML 文本。

return [b'hello world']

下一行返回字节字符串列表。由于我们没有在Content-Type,中包含显式编码,浏览器将使用默认编码。在这种情况下,这是相当安全的——现代浏览器的编码检测将始终正确处理 ASCII 范围内的字节。

一般来说,依靠浏览器变得聪明并不是一个好主意:最好的方法是通常使用 UTF-8,并在Content-Type中明确指出。

这很重要,因为 HTML 总是用 unicode 来定义的。浏览器会将其翻译成 unicode 字符串u'hello world',向用户显示问候消息。

在本章的其余部分,我们将假设这段代码在一个名为

wsgi_hello.py

参考实现

尽管 PEP 333(和 3333)建议没有必要在核心 Python 中实现 WSGI,但经验证明并非如此。模块wsgiref实现了一个简单的 web 服务器,它可以支持 WSGI 应用。

下面的命令行将在任何 bash 类 shell 中工作,其中引号允许换行。这样做是为了提高可读性——用分号替换前两个换行符,并删除其余的换行符,会产生一个完全可移植的命令——但是,逐行阅读和解释会更困难。

python -c '
from wsgiref import simple_server
import wsgi_hello
simple_server.make_server(
       "127.0.0.1",
       8000,
       wsgi_hello.application
).serve_forever()
'

我们将一行一行地检查:

python -c '

Python 有一个选项-c,它将下一个参数视为 Python 代码并执行它。这是一种执行短程序的便捷方式,无需将代码放在单独的文件中。

from wsgiref import simple_server

导入wsgiref.simple_server模块。这个模块实现了一个单线程单进程同步 web 服务器。虽然该服务器还不能用于生产,但有时对于简单的演示来说还是很方便的。

import wsgi_hello

假设上面的代码在一个名为wsgi_hello.py的文件中是很重要的。同样重要的是:

  • 该文件位于当前工作目录中。

  • 使用-c.时,当前工作目录在 Python 模块路径上

这将在后面关于寻找 WSGI 应用代码的微妙之处的讨论中变得很重要。

simple_server.make_server(

这是simple_server模块中的主要功能——创建一个简单的服务器。

"127.0.0.1",

很多例子(包括官方文档中的例子)在这里都会用到""。这将导致 WSGI 服务器绑定到0.0.0.0,即所谓的“any”接口。注意wsgiref不是生产服务器——但即使它是,我们在这里也用它来运行测试和示例代码。将它绑定到 any 接口意味着,根据防火墙的设置,外部人员可能会连接到代码。

相反,在这个例子中,我们绑定到本地接口"127.0.0.1,"。现在只有在同一台机器上运行的程序才能连接。这很有用——我们可以很容易地用浏览器测试正在运行的服务器,但是只能在与服务器相同的机器上运行一个浏览器。

8000,

按照 IANA 标准的定义,标准网络端口是80。但是,在 UNIX 系统上,1024 以下的端口是为管理员(root)用户帐户保留的。这可以防止非特权用户“劫持”系统端口。虽然导致这种需求的特定线程模型的重要性正在下降,现在许多无特权的用户直接登录到运行 web 服务器的系统并不常见,但它仍然是威胁缓解的一个组件,最重要的是,它仍然在现代的类 UNIX 系统(如 Linux)上得到实施。

绑定到“看起来相似”的端口,如808888,8080,已经成为开发中的一个传统。

wsgi_hello.application

这是实际的 WSGI 应用。正如我们提到的,WSGI 应用是一个可调用的 Python 对象

).serve_forever()

创建了服务器之后,我们无限循环地运行它。

这是一种快速运行 WSGI 应用进行测试的简单方法,除了 Python 的标准库之外,没有任何依赖。

WebOb 示例

WebOb包是一个低级 web 框架的例子。通常不直接使用它,尽管这样做肯定是可能的。

import webob
def application(environment, start_response):
    request = webob.Request(environment)
    response = webob.Response(
                     text='Hello world!')
    return response(environment, start_response)

以下是逐行解释:

import webob

图书馆足够小,所以我们需要的一切都是顶级的。

def application(environment, start_response):

在这种情况下,WSGI 应用本身只是一个普通的函数——就好像我们没有使用任何框架一样。

request = webob.Request(environment)

请求对象是从 WSGI 环境字典构建的。虽然这个应用不检查请求对象,但是它有许多参数的解析视图:URL 和查询参数,以及 cookies 等等。

response = webob.Response(

我们创建响应对象。创建响应对象将我们从处理一些底层细节中解放出来。

text='Hello world!')

例如,这里我们设置了 text 属性,而不必关心将它转换成一个字节字符串列表。

return response(environment, start_response)

响应对象知道如何调用start_response并写出它的主体。

金字塔示例

金字塔是一个框架,旨在施加最小的开销,但可以很好地扩展到大型项目。

from pyramid import config, response
def hello_world(request):
    return response.Response('Hello World!')
with config.Configurator() as conf:
    conf.add_route('hello',  '/')
    conf.add_view(hello_world, route_name="hello")
    application = conf.make_wsgi_app()

我们一行一行地检查申请。

from pyramid import config, response

金字塔有相当多的活动部件。对于这个例子,我们只需要这两个模块。

def hello_world(request):

注意hello_world是一个普通的 Python 函数。它没有任何包装。这使得重用它更容易:例如,我们可以为它编写测试,或者在不同的功能中使用它。

return response.Response('Hello World!')

我们创建一个响应对象,类似于使用WebObwerkzeug

with config.Configurator() as conf:

使用配置器作为上下文管理器意味着在程序块的末尾,假设没有出现异常,它将自动提交配置并结束它。

conf.add_route('hello', '/')

金字塔路由是一个两步过程。将 URL 映射到“逻辑名称”是第一种方法。

conf.add_view(hello_world, route_name="hello")

第二步是将逻辑名映射到一个view

application = conf.make_wsgi_app()

最后,我们要求配置将自己表示为一个 WSGI 应用。

入门指南

虽然通过 Twisted 运行 WSGI 应用的文档都是正确的,但它是通过一些文档分发的。这里我们将展示一个完整的运行 WSGI 应用的工作示例,一次构建一个块。

WSGI 服务器

Twisted WSGI 服务器是web tap 插件上的一个选项。在这里的演示中,我们将使用独特的调用插件的方式python -m twisted。虽然它有点啰嗦,但最终是一个在生产中有用的东西。

虽然它没有使用 WSGI,但是了解如何运行 web 插件是很有用的——许多选项最终都与操作 WSGI 服务器相关,并且能够单独操作“监听端”进行故障排除也是很有用的。

假设环境中安装了 Twisted,则可以运行:

$ python -m twisted web --port tcp:8000

并获得一个运行所谓“演示”的网络服务器演示 web 应用只是用一条 hello 消息来问候——在本例中,是在端口 8000 上。

运行一个 WSGI 应用很容易——我们上面有六个!

$ python -m twisted web --port tcp:8000 --wsgi wsgi_hello.application
$ python -m twisted web --port tcp:8000 --wsgi werkzeug_hello.application
$ python -m twisted web --port tcp:8000 --wsgi flask_hello.application
$ python -m twisted web --port tcp:8000 --wsgi webob_hello.application
$ python -m twisted web --port tcp:8000 --wsgi pyramid_hello.application
$ python -m twisted web --port tcp:8000 --wsgi django_hello.application

值得注意的是,这实际上比使用参考实现更容易。对于参考实现,我们必须编写一个小的 shell 脚本,其中包含一个 4 语句 Python blob 作为-c参数。虽然 Python 命令行和 UNIX shell 合作提供这些有用的工具很好,但是没有它们也很好。

*选项实际上比看起来更强大。

$ python -m twisted web --port tcp:8000:interface=127.0.0.1 \
                        --wsgi wsgi_hello.application

这将只在本地主机接口上运行 web 服务器,并使其无法从外部访问。在使用咖啡店的网络开发下一代 web 应用时,这可能是件好事!

端点的全部功能在--port命令行选项中可用,包括插件。一些端点插件非常重要,值得稍后特别提及。

注意,与其他全功能的 WSGI 服务器不同,Twisted 没有配置文件。命令行上有一些小的调整选项,但是很多事情都采用默认值——例如,WSGI 线程池的大小。

自定义这些是通过一个自定义插件来完成的。

# put in twisted/plugins/twisted_book_wsgi.py
from zope import interface
from twisted.python import usage, threadpool
from twisted import plugin
from twisted.application import service, strports
from twisted.web import wsgi, server
from twisted.internet import reactor
import wsgi_hello
@interface.implementer(service.IServiceMaker, plugin.IPlugin)
class ServiceMaker(object):
    tapname = "twisted_book_wsgi"
    description = "WSGI for book"
    class options(usage.Options): pass
    def makeService(self, options):
        pool = threadpool.ThreadPool(minthreads=1, maxthreads=100)
        reactor.callWhenRunning(pool.start)
        reactor.addSystemEventTrigger('after', 'shutdown', pool.stop)
        root = wsgi.WSGIResource(reactor, pool, wsgi_hello.application)
        site = server.Site(root)
        return strports.service('tcp:8000', site)
        serviceMaker = ServiceMaker()

我们逐一检查非进口产品:

@interface.implementer(service.IServiceMaker, plugin.IPlugin) 

这就是如何编写一个 Twisted 的 tap 插件。它将一个类标记为

  • 作为插件的东西;

  • 知道如何将命令行转换成服务的东西(service.IServiceMaker)。

它通过使用zope.interface框架来做到这一点,该框架允许显式标记接口及其实现——以及对该信息的编程访问。这个编程接口允许 Twisted 插件系统工作。

class ServiceMaker(object):

类的名字其实并不重要。唯一重要的是实例的名字是serviceMaker

tapname = "twisted_book_wsgi"

这是插件的名称,用作python -m twisted的第一个参数。

description = "WSGI for book"

通常描述应该更具信息性,因为在没有参数的情况下运行python -m twisted时,它会出现在帮助文本中。

class options(usage.Options): pass

由于这是一个最小的插件,我们“硬编码”一切。这并不是真正的硬编码——在某些时候,必须决定哪个端口和哪个应用。在插件编写时创建它通常是有意义的,特别是如果使用类似 12 因素和从环境变量中查询所有配置的话。

但是,至少从命令行提供端口选项通常是有用的。

def makeService(self, options):

该函数在解析命令行后接受 options 实例。

pool = threadpool.ThreadPool(minthreads=1, maxthreads=100)

这是而不是一个好配置的例子。事实上,这几乎肯定是一个糟糕的线程池配置。但是,通常对线程数量进行一些微调是有意义的。这显然取决于应用、机器和使用特性。

reactor.callWhenRunning(pool.start)

反应堆启动时启动水池。

reactor.addSystemEventTrigger('after', 'shutdown', pool.stop)

反应堆完成后关闭水池。

root = wsgi.WSGIResource(reactor, pool, wsgi_hello.application)

构建根资源。在这里,我们将特定的线程池与特定的 WSGI 应用结合起来。

site = server.Site(root)

从资源对象构建实际理解 HTTP 的Site对象。

return strports.service('tcp:8000', site)

构建一个端点并监听 HTTP 协议。

serviceMaker = ServiceMaker()

如上所述,实际的插件依赖于一个实例,而不是一个类。我们创建我们定义的类的一个实例。

这允许我们用一个更好(或者,在这种情况下,更差)的线程池运行相同的 hello world 应用。也有可能因为许多其他原因构建一个插件——其中一些我们将在本章的剩余部分讨论。

查找代码

Twisted WSGI 服务器需要做的最重要的事情是找到它需要运行的 WSGI 应用。然而,这在传统上是一件棘手的事情。

默认路径

当使用-c-m启动 Python 时,当前目录.位于导入路径上。上面,在使用参考实现时,我们使用了-c,在使用 Twisted WSGI 服务器时,我们使用了-m

然而,当直接用脚本运行 Python 时,script's目录,而不是当前目录,被添加到路径中。因为这就是控制台脚本入口点的工作方式,如果我们使用twist,而不是python -m twisted,当前目录将不再位于导入路径上。

依赖于路径中的当前目录是可行的——直到它不可行时,原因看起来很简单。虽然对于演示目的来说这很好,但是对于生产用途来说,我们需要更强大的东西。

皮顿路径

一种方法是将环境变量PYTHONPATH设置为一个值。第一个问题是哪一个值:一些值是 ??,而另一些值是 ??。第一种选择的优势是它可以跟随外壳——但这种优势同样是它的弱点,因为像cd这样简单的东西就可以打破它。

下一个有具体的优点——但是同样有远程操作的问题,在稍后运行 Python 时会突然导入一个旧的 WSGI 应用。这对于在 Python 路径上寻找东西的项目来说尤其是个问题——比如 Twisted 的插件实现。出现一个额外的插件可能会非常令人惊讶。

setup.py

最好的解决方案是写一个setup.py文件,把代码变成一个合适的包。必须选择一个名字,没错,但是通常最顶层模块的名字就足够了。必须选择一个版本,但是如果没有发布它的意图,那么0.0.0dev1是一个简单、安全的选择。

出于开发目的,通常最简单的方法是用pip install -e .将其安装到虚拟环境中。这将跟踪对源文件所做的更改,从而在与虚拟环境系统或任何其他类似 virtualenv 的系统(如 Nix 或 Conda)集成时最大限度地减少麻烦。

为什么 Twisted

Twisted 当然不是运行 WSGI 应用的唯一选择。Gunicorn、uwsgi 和 Apache 的 mod_wsgi 都可以做到这一点。然而,Twisted 有一些具体的好处。

生产与开发

大多数 web 框架自带内置服务器,通常基于wsgiref实现。毫无疑问,这些 web 服务器上会出现警告,比如“不要在生产环境中使用此服务器”。它没有经过安全审计或性能测试。”(这是从 Django 的文档中引用的。)在最糟糕的情况下,这些警告没有得到重视——出于无知或权宜之计——网站在开发服务器上运行。

在最好的情况下,这些警告得到了解决,开发人员使用开发服务器,而生产人员使用生产级服务器。这导致了环境漂移——例如,WSGI 实现中的一些细微差异意味着生产中的一些行为不会在开发中重现。最重要的是,开发人员不熟悉生产级 web 服务器的常规操作。日志、错误消息和故障模式都是独一无二的——通常会导致开发人员和操作人员之间的脱节。

最后,但同样重要的是,当使用两个 web 服务器时,需要一些逻辑来决定何时在何处运行。工具经常会混淆,并意外地在生产中运行开发服务器。由于开发服务器没有完全崩溃,这通常不会导致立即崩溃,而是一种奇怪的问题模式——可能是一些模糊的性能问题。

相反,Twisted 既可用于开发,也可用于生产。可以从命令行直接使用 Twisted,就像我们上面做的那样,只传递应用的名称。如果后来证明编写一个定制插件是有用的,那么这个插件通常也可以用于开发。这可以消除很多潜在的生产/开发偏差。

一些更高级的开发服务器支持一个有用的特性——代码的自动重载。然而,通过一点点的配置,这在 Twisted 上也是可能的。第一步是用pip install -e安装我们的代码,这样仅仅重启服务器就足够了。然后,我们不直接运行服务器,而是运行

$ watchmedo shell-command \
     --patterns="*.py" \
     --recursive \
     --command='python -m twisted web --wsgi=wsgi_hello.application' \
     .

每当文件改变时,这将自动重新启动服务器。它利用了watchdog PyPI 包。

坦克激光瞄准镜(Tank Laser-Sight 的缩写)

TLS(传输层安全性)是过去被称为 SSL(安全套接字层)的最新版本。TLS 是在 TCP 之上工作的加密和密钥交换协议。

TLS 做两件事:

  • 加密:使用 TLS 的通信可以抵抗窃听。

  • 端点身份验证:使用 TLS 时,可以验证我们正在与预期的端点进行对话。

虽然第一种解释在解释 TLS 的重要性时很流行,但第二种解释甚至更重要。一些 WSGI 应用可能没有什么敏感数据:但是,由于它们将 HTML、JavaScript 和 CSS 发送到潜在易受攻击的浏览器,因此确保没有恶意软件通过网络传递是非常重要的。

TLS 验证端点的方式是检查由证书颁发机构签名的证书。一般来说,让证书颁发机构签署证书的两种方法是说服它您是合法的端点,或者创建您自己的证书颁发机构。虽然创建一个真正的证书颁发机构几乎是不可能的,但这往往是数据中心内的首选解决方案,在数据中心内,同一个人或团体负责连接的两端。

假设密钥在key.pem中,证书在cert.pem中,

$ python -m twisted web \
            --port ssl:port=8443:privateKey=key.pem:certKey=cert.pem \
            --wsgi wsgi_hello.application

将与应用一起运行 TLS 服务器。注意,在这种情况下,environment字典会将wsgi.url_scheme设置为"https.",WSGI 应用可以检查这一点,看看它们是否在 TLS 之后。

这是在 WSGI 服务器中直接实现 TLS 的一个优点。否则,需要查询模糊和不标准的 HTTP 头来了解请求是否安全。

服务器名称指示

WSGI 应用可以访问头文件,包括Host头文件。这意味着 WSGI 应用可以使用客户端访问它的主机作为它的参数之一——比如说,在example.comm.example.com上提供不同的内容,作为支持移动浏览器的一种方式。

假设我们希望应用仍然有 TLS 来验证主机名,这意味着我们需要有针对m.example.comexample.com的证书,并知道服务哪一个。TLS 支持一个名为“服务器名称指示”的扩展,它允许客户端指示服务器应该证明它拥有哪个名称。

为了在 WSGI 中支持 SNI,我们需要做几件事:

  • 获取相关证书和密钥。

  • 对于每个主机名,证书和密钥连接到一个文件中(通常使用 UNIX 命令cat)。这个文件应该被命名为<host>.pem,例如m.example.com.pem

  • 把所有这些文件放在一个目录中,比如说/var/lib/keys

  • 从 PyPI 安装txsni包。

  • 快跑。

$ python -m twisted web \
            --port txsni:/var/lib/keys:tcp:8443 \
            --wsgi wsgi_hello.application

这个例子适用于我们希望从两个不同的域名(例如,example.comwww.example.com)提供相同内容(安全地)的情况。

如果我们想为不同的子域提供不同的内容,例如,app.example.com用于动态应用,static.example.com用于静态文件,我们可以对创建twisted.web.vhost.NameVirtualHost资源的自定义插件使用相同的port参数。

下面是一个插件例子:

from zope import interface

from twisted.python import usage, threadpool
from twisted import plugin
from twisted.application import service, strports
from twisted.web import wsgi, server, static, vhost
from twisted.internet import reactor

import wsgi_hello

@interface.implementer(service.IServiceMaker, plugin.IPlugin)
class ServiceMaker(object):
    tapname = "twisted_book_vhost"
    description = "Virtual hosting for book"
    class options(usage.Options):
        optParameters = [["port", "p", None,
                          "strports description of the port to "
                          "start the server on."]]
    def makeService(self, options):
        application = wsgi_hello.application
        pool = threadpool.ThreadPool(minthreads=1, maxthreads=100)
        reactor.callWhenRunning(pool.start)
        reactor.addSystemEventTrigger('after', 'shutdown', pool.stop)
        dynamic = wsgi.WSGIResource(reactor, pool, application)
        files = static.File('static')
        root = vhost.NameVirtualHost()
        root.addHost(b'app.example.org', dynamic)
        root.addHost(b'static.example.org', files)
        site = server.Site(root)
        return strports.service(options['port'], site)
serviceMaker = ServiceMaker()

有趣的线条是

root = vhost.NameVirtualHost()
root.addHost(b'app.example.org', dynamic)
root.addHost(b'static.example.org', files)

这创建了一个根资源,它将对app.example.org的所有请求重定向到动态资源,并将对static.example.org的所有请求重定向到静态资源。注意,因为我们选择了example.org,所以出于测试目的,将这些名称指向您的 hosts 文件中的127.0.0.1是安全的。

请注意,在这种情况下,我们没有选择默认值。通过不同的名称(如localhost)访问某个站点会导致 404 错误。可以在一个NameVirtualHost上设置default属性,为所有其他名字设置一个默认根。

静态文件

使用 Twisted 作为 WSGI 服务器可以让我们从同一个 web 服务器上服务静态资产和动态应用。这包括图像、JavaScript 文件、CSS 文件以及任何其他文件。

Twisted is 最初是为高性能网络应用而构建的,而 Twisted web 服务器在提供静态文件时,可以满足除了最繁重的需求之外的所有需求。然而,当满足这些需求时,大多数应用将在内容分发网络(CDN)的背后提供服务。

CDN 意味着静态文件服务速度的任何差异都是无关紧要的。然而,在这些情况下,能够从 Python 代码设置缓存控制头是很方便的。用 Python 编写 WSGI 应用的团队通常精通 Python,并且更喜欢用它来学习另一种高度特定的领域语言,比如大多数服务器的内置配置语言。

然而,要理解如何做到这一点,重要的是要更深入地研究 Twisted web 服务器 API 是如何——并且,作为一种副作用,要多理解一些早先几乎没有解释的东西。

资源模型

大多数现代 web 应用服务器,如果它们有路由模型的话,都有一个模式匹配路由模型。Flask、Django 和 Pyramid,正如我们前面看到的,都以某种方式将 URL 模式映射到代码。

Twisted 的网早于所有这些。在 URL 模式匹配变得流行之前,将 web 资源视为一个tree也是一种选择——这就是 Twisted web 采用的选择。因此,它有一个包含子资源的资源模型。

只要我们只使用了 WSGI,这并不太重要:WSGI 资源用isLeaf = True标记自己。这意味着它没有子节点,当到达时树遍历停止。这允许 WSGI 资源将路径传递给 web 应用框架,用于自己的路由。因为我们使用 WSGI 资源作为根资源——直接传递给Site构造函数的资源——这意味着资源树模型只是理论上的。

然而,当将不同的资源组合在一起时,这个模型的细节是至关重要的。

纯静态

为了理解如何在 Twisted web 上提供静态文件服务,有必要先编写一个插件来实现这一点——不使用动态资源。

from zope import interface

from twisted.python import usage, threadpool
from twisted import plugin
from twisted.application import service, strports
from twisted.web import static, server
from twisted.internet import reactor

@interface.implementer(service.IServiceMaker, plugin.IPlugin)
class ServiceMaker(object):
    tapname = "twisted_book_static"
    description = "Static for book"
    class options(usage.Options):
        pass
    def makeService(self, options):
        root = static.File('static')
        site = server.Site(root)
        return strports.service('tcp:8000', site)
serviceMaker = ServiceMaker()

这里唯一新的一行是

root = static.File('static')

这定义了一个File资源。File资源也是一个叶资源,它将 URL 的其余部分映射到磁盘上的一个路径。这使用了一个相对路径,static,指向当前工作目录。这对于演示来说非常有用,但是生产应用通常会使用完整的路径。

获得完整路径的一种方法是直接用 Python 代码打包文件。打包它以及在运行时找到它需要一些黑客技术。

下面是一个例子setup.py,以及使用它的插件:

import setuptools
setuptools.setup(
    name='static_server',
    license='MIT',
    description="Server: Static",
    long_description="Static, the web server",
    version="0.0.1",
    author="Moshe Zadka",
    author_email="zadka.moshe@gmail.com",
    packages=setuptools.find_packages(where='src') + ['twisted/plugins'],
    package_dir={"": "src"},
    include_package_data=True,
    install_requires=['twisted', 'setuptools'],
) 

最有趣的台词是include_package_data=True。为了获得一些有趣的数据,我们需要一个清单:在MANIFEST.in中,我们将

include src/static_server/a_file.html

为该文件提供服务的插件(在本例中是 on /)如下所示:

import pkg_resources

from zope import interface

from twisted.python import usage, threadpool
from twisted import plugin
from twisted.application import service, strports
from twisted.web import static, server, resource
from twisted.internet import reactor

@interface.implementer(service.IServiceMaker, plugin.IPlugin)
class ServiceMaker(object):
    tapname = "twisted_book_pkg_resources"
    description = "Static for book"
    class options(usage.Options):
        pass
    def makeService(self, options):
        root = resource.Resource()
        fname = pkg_resources.resource_filename("static_server",
                                                "a_file.html")
        static_resource = static.File(fname)
        root.putChild(“, static_resource)
        site = server.Site(root)
        return strports.service('tcp:8000', site)
serviceMaker = ServiceMaker()

这里有趣的新行是:

fname = pkg_resources.resource_filename("static_server",
                                        "a_file.html")
static_resource = static.File(fname)

这使用了pkg_resources包,它是setuptools的一部分,在运行时查找文件名。

请注意,即使我们的包使用类似于pex(或内置的zipapp)的工具直接部署为 zip 文件,这也是可行的:pkg_resources足够聪明,可以在给出文件名之前透明地解压文件。

当使用像Jinja2Chameleon这样的系统时,这种技术对于包含模板文件也很有用。

将静态文件与 WSGI 结合起来

我们还可以通过 Twisted 自己的 web 服务器为 WSGI 应用提供静态资源。

import os

from zope import interface
from twisted.python import usage, threadpool
from twisted import plugin
from twisted.application import service, strports
from twisted.web import wsgi, server, static, resource
from twisted.internet import reactor
import wsgi_hello
class DelegatingResource(resource.Resource):
    def __init__ (self, wsgi_resource):
        resource.Resource. __init__ (self)
        self._wsgi_resource = wsgi_resource
    def getChild(self, name, request):
        request.prepath = []
        request.postpath.insert(0, name)
        return self._wsgi_resource
@interface.implementer(service.IServiceMaker, plugin.IPlugin)
class ServiceMaker(object):
    tapname = "twisted_book_combined"
    description = "twisted_book_combined"
    class options(usage.Options): pass
    def makeService(self, options):
        application = wsgi_hello.application
        pool = threadpool.ThreadPool()
        reactor.callWhenRunning(pool.start)
        reactor.addSystemEventTrigger('after', 'shutdown', pool.stop)
        wsgi_resource = wsgi.WSGIResource(reactor, pool, application)
        static_resource = static.File('.')
        root = DelegatingResource(wsgi_resource)
        root.putChild('static', static_resource)
        site = server.Site(root)
        return strports.service('tcp:8000', site)
serviceMaker = ServiceMaker()

我们一行一行地研究新代码:

class DelegatingResource(resource.Resource):

我们定义了一个名为DelegatingResource的类。这将是我们的根。它继承自resource.Resource。注意,它是而不是一个叶资源——所以站点将遍历它。

def __init__ (self, wsgi_resource):

我们用 WSGI 资源初始化委托者。

resource.Resource. __init__ (self)

在适当的时候,我们调用超类构造函数。这一点至关重要——Resource没有它的构造函数就不能正常运行。

self.wsgi_resource = wsgi_resource

我们将 WSGI 资源保存在一个属性中。

def getChild(self, name, request):

getChild的名字有点混乱。语义是获得一个动态子节点。一个静态的子对象,也就是已经被手动添加到一个Resource中的子对象,将会阻止这个方法被调用。根永远不会被调用到render:即使像/这样的 URL 也会导致一个带有空字符串的子遍历name

request.prepath = []
request.postpath.insert(0, name)

我们将名称从prepath移动到postpath,从而欺骗作为根的委托资源。请注意,只有当这个资源是根资源时,这个技巧才有效。

return self.wsgi_resource

在欺骗路径假装少完成了一次遍历之后,我们返回 WSGI 资源。

static_resource = static.File('.')

我们创建静态资源。这与纯静态资源的情况没有什么不同。

root = DelegatingResource(wsgi_resource)

我们创建委托资源作为我们的根资源。

root.putChild('static', static_resource)

如前所述,手动引入的子元素将覆盖getChild方法。因此,对于任何以/static/开头的路径,都会提供一个静态资源。

内置计划任务

对于下面的例子,我们想要一个依赖于我们可以改变的参数的 WSGI 应用。

class _Application(object):
    def __init__ (self, greeting='hello world'):
        self.greeting = greeting
    def __call__ (self, environment, start_response):
        start_response('200 OK', [('Content-Type',
                                   'text/html; charset=utf-8')])
        return [self.greeting.encode('utf-8')]
application = _Application()

我们将逐行检查代码:

class _Application(object):

如前所述,关于 WSGI 应用的唯一假设是它们是可调用的对象。在这种情况下,我们通过用__call__方法定义一个类来创建一个可调用对象。

def __init__ (self, greeting='hello world'):

我们用一个问候进行初始化,使用标准的默认值。

self.greeting = greeting

在构造函数中,我们没有做任何比设置属性更有趣的事情。

def __call__ (self, environment, start_response):

因为这是一个 WSGI 应用,所以用标准参数调用它。

start_response('200 OK', [('Content-Type',
                           'text/html; charset=utf-8')])

这与之前的start_response调用相同,只是增加了一个显式字符集。由于创建者可以传递任意的 unicode 字符串,并且我们将它们编码为utf-8,我们需要让浏览器知道这是我们所做的。

return [self.greeting.encode('utf-8')]

我们希望能够将问候语设置为字符串。因此,这必须将它们编码成字节。

application = _Application()

我们不关心类,我们想要的是它作为应用的一个实例。

import time
from zope import interface
from twisted.python import usage, reflect, threadpool, filepath
from twisted import plugin
from twisted.application import service, strports, internet
from twisted.web import wsgi, server, static
from twisted.internet import reactor
import wsgi_param

def update(application, reactor):
    stamp = time.ctime(reactor.seconds())
    application.greeting = "hello world, it's {}".format(stamp)

@interface.implementer(service.IServiceMaker, plugin.IPlugin)
class ServiceMaker(object):
    tapname = "twisted_book_scheduled"
    description = "Changing application"
    class options(usage.Options): pass
    def makeService(self, options):
        s = service.MultiService()
        pool = threadpool.ThreadPool()
        reactor.callWhenRunning(pool.start)
        reactor.addSystemEventTrigger('after', 'shutdown', pool.stop)
        root = wsgi.WSGIResource(reactor, pool, wsgi_param.application)
        site = server.Site(root)
        strports.service('tcp:8000', site).setServiceParent(s)
        ts = internet.TimerService(1, update, wsgi_param.application, reactor)
        ts.setServiceParent(s)
        return s
serviceMaker = ServiceMaker()

def update(application, reactor):

这个函数将被定期调用来更新应用。

stamp = time.ctime(reactor.seconds())

我们在这里用reactor.seconds(),而不是time.time()。如果这段代码变得更大,这将有助于测试性。

application.greeting = "hello world, it's {}".format(stamp)

这将设置应用问候语属性。因为它是公共的,所以被认为是类的 API 的一部分。

注意:这是利用了可变的全局状态,这通常是一种危险的模式——在线程的情况下更是如此。虽然 Twisted 的主循环没有线程,但 WSGI 的工作都在 Twisted 的线程池中运行。

但是,在这种特定情况下,更改是安全的——线程将看到旧的问候语或新的问候语。这是因为 Python 的全局解释器锁,它确保 Python 线程看到一致的状态——并且因为这只是用一个字符串替换另一个字符串。

s = service.MultiService()

这将创建一个启动多个服务的服务。它允许我们从同一个服务中进行 web 服务和更新。

strports.service('tcp:8000', site).setServiceParent(s)

这一次,我们没有返回strports.service结果,而是将其父对象设置为MultiService。这会把它附在MultiService小时候。

ts = internet.TimerService(1, update, wsgi_param.application, reactor)

这里我们创建了一个每 1 秒触发一次的定时器,并用参数wsgi_param.applicationreactor调用函数update

ts.setServiceParent(s)

将计时器附加到返回值。

return s

并返回MultiService

虽然这肯定不是显示时钟的最佳方式,但在很多情况下,将检索值和显示值分开是有意义的。设想一个股票行情应用:最好每秒检索一次股票价格,并在 web 请求发生时从内存中显示一个值,而不是让每个 web 请求等待一个(可能很慢的)后端服务。

这显示了在进程中运行计划服务的好处。当然,即使没有要处理的事情也可以用这种方式来安排——例如,日志清理。这允许将应用配置保存在一个地方,而不是像cron那样添加对服务的依赖。

控制通道

通常,在运行时修改 web 应用的配置是很有用的,无需重新启动或重建。这方面的一些例子有:

  • 解决问题时修改调试级别。

  • 修改 A/B 测试中的质控/测试百分比。

  • 如果客户报告问题,则关闭“功能标志”。

这意味着,除了应用最终用户与应用交互的“应用通道”,我们还需要一个辅助通道,即“控制通道”,它将修改行为。通过不同的端口(可能是不同的协议)使用该通道要安全得多——未授权用户访问控制通道的攻击媒介可以通过传统的防火墙和网络配置来缓解,而不仅仅是通过应用级访问控制。

因为 Twisted 本质上是一个网络事件框架,所以它非常适合向 WSGI 应用添加控制通道。由于这种控制通道本质上是跨越线程边界的,所以有必要注意和考虑线程安全。

然而,它确实允许将有趣的行为添加到 WSGI 应用中。

下面的插件展示了一种使用网络控制问候的方法。

from zope import interface

from twisted.python import usage, reflect, threadpool, filepath
from twisted import plugin
from twisted.application import service, strports, internet
from twisted.web import wsgi, server, static
from twisted.internet import reactor, protocol
from twisted.protocols import basic

import wsgi_param

class UpdateMessage(basic.LineReceiver):

    def lineReceived(self, line):
        self.factory.application.greeting = line.decode('utf-8')
        self.transport.writeSequence([b"greeting is now: ", line, b"\r\n"])
        self.transport.loseConnection()

@interface.implementer(service.IServiceMaker, plugin.IPlugin)
class ServiceMaker(object):
    tapname = "twisted_book_control"
    description = "Changing application"
    class options(usage.Options): pass
    def makeService(self, options):
        s = service.MultiService()
        pool = threadpool.ThreadPool()
        reactor.callWhenRunning(pool.start)
        reactor.addSystemEventTrigger('after', 'shutdown', pool.stop)
        root = wsgi.WSGIResource(reactor, pool, wsgi_param.application)
        site = server.Site(root)
        strports.service('tcp:8000', site).setServiceParent(s)
        factory = protocol.Factory.forProtocol(UpdateMessage)
        factory.application = wsgi_param.application
        strports.service('tcp:8001',factory).setServiceParent(s)
        return s
serviceMaker = ServiceMaker()

我们一行一行地检查新代码:

class UpdateMessage(basic.LineReceiver):

这定义了协议basic.LineReceiver的一个子类。它将消息分成行,使我们能够轻松地划分消息。

def lineReceived(self, line):

这将在接收到一行时调用——注意,该行将而不是包含终止字符(默认情况下,回车后面跟一个换行符,\r\n)。

self.factory.application.greeting = line

我们将问候语设置到呼入线路。

factory = protocol.Factory.forProtocol(UpdateMessage)

我们创建了一个工厂,它将在客户端连接时产生UpdateMessage的实例。

factory.application = wsgi_param.application

我们将工厂中的应用设置为 WSGI 应用。这允许协议对象访问应用,以便改变问候语。

strports.service('tcp:8001',factory).setServiceParent(s)

我们将此协议绑定到一个更高的端口。

使用多核的策略

WSGI 服务器的一个限制是它只能运行一个进程。由于 Python 拥有全局解释器锁,这意味着在多核机器上,只有一个内核用于 WSGI。通常,这不是问题:在一些环境中,较低层将向应用呈现单核“机器”。例如,使用虚拟化平台或容器编排框架时就是这种情况。

然而,由于许多原因,有时需要在应用层解决正确的多进程解决方案。在这里,我们展示了其中的一些方法。

负载平衡

最简单的方法是启动多个 Twisted WSGI 进程,并在它们前面放置一个负载平衡器。一个流行的负载平衡器是 HAProxy。拥有一个完整的 HAProxy 教程超出了我们的范围,但是下面是一个 HAProxy 配置的例子。为了简化配置,配置是针对纯文本 HTTP 的——尽管 HAProxy 经常用于终止 SSL。

defaults
    log     global
    mode    http
frontend localnodes
    bind *:8080
    mode http
    default_backend nodes
backend nodes
    mode http
    balance roundrobin
    option forwardfor
    http-request set-header X-Forwarded-Port %[dst_port]
    http-request add-header X-Forwarded-Proto https if { ssl_fc }
    option httpchk HEAD / HTTP/1.1\r\nHost:localhost
    server web01 127.0.0.1:9000 check
    server web02 127.0.0.1:9001 check
    server web03 127.0.0.1:9002 check

最后三行是最重要的:它们转发到三个不同的本地 web 服务器。

现在,我们需要一些东西来运行所有四个进程——ha proxy 和三个 web 服务器。在这个例子中,我们将使用ncolony

$ alias add="python -m ncolony --messages /var/run/messages \
                               --config /var/run config add"
$ add --cmd haproxy --arg=-f --arg=/my/haproxy.cfg haproxy
$ add --cmd python --arg=-m --arg=twisted \
                   --arg=web --arg=--wsgi \
                   --arg=wsgi_hello.application \
                   --arg=--port --arg=tcp:9001 web1
$ add --cmd python --arg=-m --arg=twisted \
                   --arg=web --arg=--wsgi \
                   --arg=wsgi_hello.application \
                   --arg=--port --arg=tcp:9002 web2
$ add --cmd python --arg=-m --arg=twisted \
                   --arg=web --arg=--wsgi \
                   --arg=wsgi_hello.application \
                   --arg=--port --arg=tcp:9003 web3
$ python -m twisted ncolony --messages /var/run/messages \
                          --config /var/run config add

以共享模式打开套接字

Linux 内核最近的一个特性是SO_REUSEPORT套接字选项。这允许几个服务器监听同一个端口。然而,由于这个特性是最近才出现的,Twisted 不支持开箱即用。

为了利用它,我们将需要插入 Twisted 的更低的层。

import socket

import attr

from zope import interface

from twisted.python import usage, threadpool
from twisted import plugin
from twisted.application import service, internet as tainternet
from twisted.web import wsgi, server
from twisted.internet import reactor, tcp, interfaces as tiinterfaces, defer

import wsgi_hello

@interface.implementer(tiinterfaces.IStreamServerEndpoint)
@attr.s
class ListenerWithReuseEndPoint(object):
    port = attr.ib()
    reactor = attr.ib(default=None)
    backlog = attr.ib(default=50)
    interface = attr.ib(default=“)

    def listen(self, protocolFactory):
        p = tcp.Port(self.port, protocolFactory, self.backlog, self.interface,
                     self.reactor)
        self._sock = sock = p.createInternetSocket()
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
        sock.bind((self.interface, self.port))
        sock.listen(self.backlog)
        return defer.succeed(reactor.adoptStreamPort(sock.fileno(),
                                                     p.addressFamily,
                                                     protocolFactory))

@interface.implementer(service.IServiceMaker, plugin.IPlugin)
class ServiceMaker(object):
    tapname = "twisted_book_reuseport"
    description = "Reuse port"
    class options(usage.Options): pass
    def makeService(self, options):
        application = wsgi_hello.application
        pool = threadpool.ThreadPool(minthreads=1, maxthreads=100)
        reactor.callWhenRunning(pool.start)
        reactor.addSystemEventTrigger('after', 'shutdown', pool.stop)
        root = wsgi.WSGIResource(reactor, pool, application)
        site = server.Site(root)
        endpoint = ListenerWithReuseEndPoint(8000)
        service = tainternet.StreamServerEndpointService(endpoint, site)
        return service
serviceMaker = ServiceMaker()

这无疑是我们迄今为止编写的最复杂的插件。在生产代码中,这对一个插件来说太大了——当然大部分逻辑应该被分离出来。

然而,为了便于说明,将所有代码放在一起显示可以使其更加清晰。

@interface.implementer(tiinterfaces.IStreamServerEndpoint)

模块名似乎很奇怪。Twisted 的深度模块层次结构意味着一些名称在层次结构中的不同点重复出现。一个有用的惯例是在导入模块时仍然保留层次结构中的一些字母,以使目的更加清晰。在这种情况下,tiinterfaces代表twisted.internet.interfaces

我们实现了IStreamServerEndpoint接口,因为我们需要实现一种新的端点——以REUSEPORT模式打开套接字的端点。

@attr.s

由于这个类有很多数据成员,我们使用attrs包来简化代码。

class ListenerWithReuseEndPoint(object):
    port = attr.ib()
    reactor = attr.ib(default=None)
    backlog = attr.ib(default=50)
    interface = attr.ib(default=")

我们接受与reactor.listenTCP调用完全相同的参数。这是故意的。

def listen(self, protocolFactory):

这是IStreamServerEndpoint接口中唯一的方法。

p = tcp.Port(self.port, protocolFactory, self.backlog, self.interface,
             self.reactor)
self._sock = sock = p.createInternetSocket()

Twisted 的底层 TCP 设施,在tcp.Port中,确保非阻塞的正确选项会设置在套接字上。我们保留了对 socket 对象的引用,以防止它被收集。这很重要,因为我们将从同一个文件描述符创建一个新的 Python 级别的套接字对象。

sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)

这是所有这些繁琐的事情的真正原因——设置SO_REUSEPORT选项。

sock.bind((self.interface, self.port))

我们绑定到接口。

sock.listen(self.backlog)

我们开始倾听。

return defer.succeed(reactor.adoptStreamPort(sock.fileno(),
                                             p.addressFamily,
                                             protocolFactory))

我们从 socket 对象中获取文件描述符,并允许 Twisted“采用”它。这将返回一个IListeningPort。由于listen的合同是返回延期的,我们将其包装在defer.succeed中。

为了将这个投入生产,我们可以再次使用ncolony

$ alias add="python -m ncolony --messages /var/run/messages \
                               --config /var/run config add"
$ add --cmd python --arg=-m --arg=twisteded \
                   --arg=twisted_book_reuseport web1
$ add --cmd python --arg=-m --arg=twisteded \
                   --arg=twisted_book_reuseport web2
$ add --cmd python --arg=-m --arg=twisteded \
                   --arg=twisted_book_reuseport web3
$ python -m twist ncolony --messages /var/run/messages \
                          --config /var/run config add

和上一个例子一样,我们运行三个 web workers。请注意,这一次,所有三者的命令行都是相同的——不再需要负载平衡器。

其他选项

一般来说,Twisted 中还有一些其他的多处理选项。可以创建一个套接字,然后产生监听它的进程。这意味着以有些笨拙的方式捆绑流程管理和监听代码。例如,不再可能使用ncolony来监控流程,也不再可能使用twisted.runner.procmon来监控流程。如果“父”进程死亡,我们将面临一个两难的选择:是重启它,杀死所有现有的子进程,还是等待所有子进程先死亡。

另一种选择是在一个进程中监听,然后通过 UNIX 域套接字传递文件描述符。这对于移植来说并不简单,并且需要深入研究套接字系统调用的奥秘。

一般来说,端口重用或负载平衡的选项更好。请注意,与任何性能改进一样,特定选择的效果(例如端口重用与负载平衡)应该在尽可能接近生产环境的环境中进行测量。

动态配置

如前所述,使用 Twisted 作为 WSGI 服务器允许向应用添加控制通道,允许在运行时重新配置。这里我们展示了这种控制的完整示例,使用异步消息协议(AMP)作为我们的控制协议。该示例包括应用和控件应用。

A/B 可测试金字塔应用

A/B 测试意味着向一些用户展示 web 应用的一个版本,而向其他用户展示不同的版本——并检查各种指标的效果。例如,一个电子商务应用可能会尝试放置“Checkout”按钮,并测试它对多少客户结帐的影响。

Python web 框架有许多全功能的 A/B 测试选项。这里我们没有足够的篇幅来编写一个全功能的替代方案,但是我们将展示其中的一个基本部分:改变输出。一般来说,向给定用户显示时,输出应该是恒定的,但是这需要一致的会话构造,这也超出了我们的范围。

我们的“测试”将只是每个请求,决定显示哪个版本。我们将在随机选择的基础上这样做。然而,我们将采用 A/B 测试框架的一个重要特征——偏向选择。如果我们认为测试可能会对用户产生不利影响,我们通常会在很小的比例上运行它。

我们的默认设置是对 0%的用户运行测试。我们将依靠外部机制来提高这些百分比。

import random

from pyramid import config, response

FEATURES = dict(capitalize=0.0, exclaim=0.0)

def hello_world(request):
    if random.random() < FEATURES['capitalize']:
         message = 'Hello world'
     else:
         message = 'hello world'
     if random.random() < FEATURES['exclaim']:
        message += '!'
    return response.Response(message)

with config.Configurator() as conf:
    conf.add_route('hello', '/')
    conf.add_view(hello_world, route_name="hello")
    application = conf.make_wsgi_app()

我们逐行检查新代码:

FEATURES = dict(capitalize=0.0, exclaim=0.0)

我们允许两个“特征”——capitalize,是否大写我们的问候;还有exclaim,是否加感叹号。注意,在这个例子中,这些特性是独立的:用户可以看到四种不同的问候。

在小范围内,这是对进行 A/B 测试的实际环境的一个很好的模拟——从理论上讲,当运行n实验时,用户可以经常接触到任何2**n可能的选项。

if random.random() < FEATURES['capitalize']:

这就是 Python 中一个所谓的“有偏向的抛硬币”的基本逻辑。会导致True平均约FEATURES['capitalize']

message = 'Hello world'

大写消息。

else:
    message = 'hello world'

小写消息。

if random.random() < FEATURES['exclaim']:
    message += '!'

如果感叹号打开,请添加感叹号。

带 AMP 的自定义插件

为了能够调整百分比,我们使用 AMP 协议。有许多可供选择的选项,但是这个平衡了灵活性和可论证性。一个好处是对 AMP 的支持内置在 Twisted 中,所以不需要第三方包。

from zope import interface

from twisted.python import usage, threadpool
from twisted import plugin
from twisted.application import service, strports
from twisted.web import wsgi, server
from twisted.internet import reactor, protocol
from twisted.protocols import amp

import pyramid_dynamic

class GetCapitalize(amp.Command):
    arguments = []
    response = [(b'value', amp.Float())]

class GetExclaim(amp.Command):
    arguments = []
    response = [(b'value',  amp.Float())]

class SetCapitalize(amp.Command):
    arguments = [(b'value', amp.Float())]
    response = []

class SetExclaim(amp.Command):
    arguments = [(b'value',  amp.Float())]
    response = []

class AppConfiguration(amp.CommandLocator):

    @GetCapitalize.responder
    def get_capitalize(self):
        return {'value':  pyramid_dynamic.FEATURES['capitalize']} 

    @GetExclaim.responder
    def get_exclaim(self):
        return {'value':  pyramid_dynamic.FEATURES['exclaim']}

    @SetCapitalize.responder
    def set_capitalize(self, value):
        pyramid_dynamic.FEATURES['capitalize'] = value
        return {}

    @SetExclaim.responder
    def set_exclaim(self, value):
        pyramid_dynamic.FEATURES['exclaim'] = value
        return {}

@interface.implementer(service.IServiceMaker, plugin.IPlugin)
class ServiceMaker(object):
    tapname = "twisted_book_configure"
    description = "WSGI for book"
    class options(usage.Options):
        pass
    def makeService(self, options):
        application = pyramid_dynamic.application
        pool = threadpool.ThreadPool(minthreads=1, maxthreads=100)
        reactor.callWhenRunning(pool.start)
        reactor.addSystemEventTrigger('after', 'shutdown', pool.stop)
        root = wsgi.WSGIResource(reactor, pool, application)
        site = server.Site(root)
        control = protocol.Factory()
        control.protocol = lambda: amp.AMP(locator=AppConfiguration())
        ret = service.MultiService()
        strports.service('tcp:8000', site).setServiceParent(ret)
        strports.service('tcp:8001', control).setServiceParent(ret) 

        return ret
serviceMaker = ServiceMaker()

我们将复习新代码:

class GetCapitalize(amp.Command):
    arguments = []
    response = [(b'value',  amp.Float())]

class GetExclaim(amp.Command):
    arguments = []
    response = [(b'value',  amp.Float())]

class SetCapitalize(amp.Command):
    arguments = [(b'value',  amp.Float())]
    response = []

class SetExclaim(amp.Command):
    arguments = [(b'value',  amp.Float())]
    response = []

这些定义了 AMP 命令。命令是 AMP 中的基本信息。虽然理论上,命令可以双向发送,但在大多数情况下,它们将从客户机发送到服务器。

我们有意让 get/set 命令一次只允许一个字段,以便清楚地表明不保证原子性。事实上,由于在没有更多机制的情况下很难保证字典访问的原子性,所以在 API 中指出不可能同时将大写设置为 1 和保证为 0 是有用的。

我们可以制作一个声明原子性的 API:例如,同时设置两个属性。我们甚至可以用原子的方式来实现它:例如,大规模替换FEATURES字典,这样就可以访问旧字典或新字典,并且没有中间步骤。然而,线程切换可能发生在线之间

if random.random() < FEATURES['capitalize']:

那条线呢

if random.random() < FEATURES['exclaim']:

这将使原子性的借口成为谎言。相反,我们选择明确表示更新不是原子性的,

class AppConfiguration(amp.CommandLocator):

    @GetCapitalize.responder
    def get_capitalize(self):
        return {'value': pyramid_dynamic.FEATURES['capitalize']}

    @GetExclaim.responder
    def get_exclaim(self):
        return {'value': pyramid_dynamic.FEATURES['exclaim']}

    @SetCapitalize.responder
    def set_capitalize(self, value):
        pyramid_dynamic.FEATURES['capitalize'] = value
        return {}

    @SetExclaim.responder
    def set_exclaim(self, value):
        pyramid_dynamic.FEATURES['exclaim'] = value
        return {}

我们编写一个简单的类,将命令连接到pyramid_dynamic.FEATURES字典,适当地设置和获取字段。

control = protocol.Factory()
control.protocol = lambda: amp.AMP(locator=AppConfiguration())

控制工厂将协议设置为使用自定义定位器创建新的amp.AMP的函数。还有其他方法将 AMP 协议绑定到特定的定位器,但这将尽可能多的权力交给了集成者——编写插件的程序员,而不是编写命令处理本身的程序员。

控制程序

也许在其他地方,控制代码本身会使用同步风格并阻塞网络调用。然而,在本书中,这是一个展示如何使用 Twisted 编写客户端的机会。我们选择以一种与 Python 2 和 Python 3 都兼容的方式编写这段代码。

from twisted.internet import task, defer, endpoints, protocol
from twisted.protocols import amp

from twisted.plugins import twisted_book_configure

@task.react
@defer.inlineCallbacks
def main(reactor):
    endpoint = endpoints.TCP4ClientEndpoint(reactor, "127.0.0.1", 8001)
    prot = yield endpoint.connect(protocol.Factory.forProtocol(amp.AMP))
    res1 = yield prot.callRemote(twisted_book_configure.GetCapitalize)
    res2 = yield prot.callRemote(twisted_book_configure.GetExclaim)
    print(res1['value'],  res2['value'])
    yield prot.callRemote(twisted_book_configure.SetCapitalize, value=0.5)
    yield prot.callRemote(twisted_book_configure.SetExclaim, value=0.5)
    res1 = yield prot.callRemote(twisted_book_configure.GetCapitalize)
    res2 = yield prot.callRemote(twisted_book_configure.GetExclaim)
    print(res1['value'],  res2['value'])
@task.react

装饰器将立即运行主函数,并带有一个反应器参数。

@defer.inlineCallbacks

我们使用一个inlineCallbacks装饰器来允许代码更好地流动。

def main(reactor):

注意,这里我们接受reactor作为参数,而不是导入它。

endpoint = endpoints.TCP4ClientEndpoint(reactor, "127.0.0.1", 8001)

创建客户端端点。

prot = yield endpoint.connect(protocol.Factory.forProtocol(amp.AMP)) 

创建客户端工厂,并连接。

res1 = yield prot.callRemote(twisted_book_configure.GetCapitalize)
res2 = yield prot.callRemote(twisted_book_configure.GetExclaim)

检索值。请注意,我们使用的是之前定义的命令类。

print(res1['value'], res2['value'])

显示更改前的值

yield prot.callRemote(twisted_book_configure.SetCapitalize, value=0.5)
yield prot.callRemote(twisted_book_configure.SetExclaim, value=0.5)

设置值

res1 = yield prot.callRemote(twisted_book_configure.GetCapitalize)
res2 = yield prot.callRemote(twisted_book_configure.GetExclaim)
print(res1['value'],  res2['value'])

再去拿。这证明它们已经改变。

这三个部分——应用、插件和控制程序——为我们提供了一个 web 服务器,我们可以动态配置其内部参数。

摘要

Twisted WSGI 服务器在开发中易于安装和使用——事实上,甚至比参考实现更容易。尽管使用方便,但它完全适合在生产中使用。为了避免开发环境和生产环境之间的差异——这种差异常常使生产问题难以重现,这种方法非常方便。

由于它基于 Twisted Web 服务器,因此继承了生产级 TLS 实现等功能,这些功能支持 SNI 和 Let's Encrypt 等功能,以及 HTTP/2 协议支持。它还可以配置为静态文件 web 服务器,允许它从与动态应用相同的进程中提供静态资产,如图像、JavaScript 和 CSS 文件,从而避免静态资产与应用接受的内容不匹配。

它没有定义任何配置文件格式。相反,对于任何比设置监听端口或命名 WSGI 应用更深层次的配置,都可以编写一个 Twisted 插件——它允许用一种语言进行最终配置,不管是什么 web 框架,所有从事应用工作的工程师都知道并使用这种语言。

Twisted 作为 WSGI 容器的最大缺点是利用了多核机器。为此,可以通过几种不同的配置来建立多个 WSGI 流程。一般来说,将“如何监听套接字”与“如何管理多个进程”分开,可以为每个进程找到好的解决方案,而不是将进程管理和套接字代码绑定在一起。*

六、Tahoe-LAFS:最低权限文件系统

是一个分布式存储系统,于 2006 年开始作为一家名为 AllMyData(早已倒闭)的个人备份公司的强大后端。在关闭之前,该公司开源了代码,现在一个黑客社区改进和维护这个项目。

该系统允许您将数据从您的计算机上传到称为“网格”的服务器网络中,然后再从网格中检索您的数据。除了提供备份(例如,万一您的笔记本电脑硬盘出现故障),它还提供了与同一网格上的其他用户共享特定文件或目录的灵活方式。这样,它的行为有点像“网络驱动器”(SMB 或 NFS),或者文件传输协议(FTP 或 HTTP)。

Tahoe 的特色是“独立于提供商的安全性”所有文件在离开您的电脑之前都经过加密和加密哈希处理。存储服务器永远看不到明文(因为加密),也无法进行未被发现的更改(因为散列)。此外,密文被擦除编码成冗余部分,并上传到多个独立的服务器。这意味着您的数据可以在失去几台服务器的情况下继续存在,从而提高耐用性和可用性。

因此,您可以纯粹根据性能、成本和正常运行时间来选择存储服务器,而不需要依赖它们的安全性。大多数其他网络驱动器完全容易受到服务器的攻击:危害主机提供商的攻击者可以看到或修改您的数据,或者完全删除它。Tahoe 的机密性和完整性完全独立于存储提供商,可用性也得到提高。

太浩湖-LAFS 如何运作

一个 Tahoe“网格”由一个或多个介绍器、一些服务器和一些客户机组成。

  • 客户端知道如何上传和下载数据。

  • 服务器持有加密的共享。

  • 介绍器帮助客户机和服务器找到并相互连接。

这三种节点类型使用名为“Foolscap”的特殊协议进行通信,该协议源于 Twisted 的“透视代理”,但增加了安全性和灵活性。

Tahoe 使用“能力字符串”来识别和访问所有文件和目录。这些看起来随机的 base32 数据块包含加密密钥、完整性保护散列和共享位置信息。当它们指代文件时,我们将其缩写为“filecaps ”,对于目录,我们将其缩写为“dircaps”。

img/455189_1_En_6_Fig1_HTML.jpg

图 6-1

太浩湖-LAFS 网格图

(为了可读性,本章中的例子被缩短了,但是 filecaps 通常大约有 100 个字符长。)

它们有时有多种形式:“writecap”赋予知道它的人修改文件的能力,而“readcap”只让他们读取内容。甚至还有一个“验证上限”,它允许持有者验证加密的服务器端共享(如果一些已经丢失,还可以生成新的),但不能读取或修改明文。当您自己的计算机脱机时,您可以放心地将这些文件交给授权的维修代理来维护您的文件。

Tahoe 最简单的 API 调用是一个命令行PUT,它接受明文数据,将其上传到一个全新的不可变文件中,并返回生成的 filecap:

$ tahoe put kittens.jpg
200 OK
URI:CHK:bz3lwnno6stuspjq5a:mwmb5vaecnd3jz3qc:2:3:3545

这个 filecap 是世界上检索文件的唯一方法。您可以将它写下来,或者存储在另一个文件中,或者存储在一个 Tahoe 目录中,但是这个字符串对于恢复文件来说既必要又充分。下载如下所示(tahoe get命令将下载的数据写入 stdout,因此我们使用"> " shell 语法将其重定向到一个文件中):

$ tahoe get URI:CHK:bz3lwnno6stuspjq5a:mwmb5vaecnd3jz3qc:2:3:3545 >downloaded.jpg

我们经常(也许是错误地)在许多地方将 filecaps 称为 URIs,包括 filecaps 字符串本身。“CHK”代表“内容散列键”,它描述了我们使用的不可变文件编码的具体种类:其他种类的 cap 有不同的标识符。不可变的文件上限总是 readcaps:一旦文件上传,世界上没有人可以修改它,即使是最初的上传者。

Tahoe 还提供了可变的文件,这意味着我们可以在以后更改内容。这有三个 API 调用:create生成一个可变槽,publish向槽中写入新数据(覆盖以前的内容),然后retrieve返回槽的当前内容。

可变槽有写上限和读上限。给你 writecap,但是任何知道 writecap 的人都可以把它“简化”成 readcap。这允许您与其他人共享 readcap,但为您自己保留 write 权限。

在 Tahoe 中,目录只是包含特殊编码的表的文件,该表将孩子的名字映射到孩子的 filecap 或 dircap。可以把这些目录想象成有向图中的中间节点。

img/455189_1_En_6_Fig2_HTML.jpg

图 6-2

Rootcap、目录和文件的图表

我们可以用mkdir命令创建一个。这默认创建一个可变目录(但是如果我们愿意,我们也可以创建完全填充的不可变目录)。Tahoe 有cpls命令来复制文件和列出目录,这些命令知道如何像往常一样处理斜杠分隔的文件路径。

CLI 工具还提供了“别名”,它只是在本地文件(~/.tahoe/private/aliases)中存储一个“rootcap”目录,允许其他命令使用看起来很像网络驱动器指示符的前缀来缩写 dircap(例如,Windows E:驱动器)。这减少了输入,使命令更容易使用:

$ tahoe mkdir
URI:DIR2:ro76sdlt25ywixu25:lgxvueurtm3
$ tahoe add-alias mydrive URI:DIR2:ro76sdlt25ywixu25:lgxvueurtm3
Alias 'mydrive' added
$ tahoe cp kittens.jpg dogs.jpg mydrive:
Success: files copied
$ tahoe ls URI:DIR2:ro76sdlt25ywixu25:lgxvueurtm3
kittens.jpg
dogs.jpg
$ tahoe mkdir mydrive:music
$ tahoe cp piano.mp3 mydrive:music
$ tahoe ls mydrive:
kittens.jpg
music
dogs.jpg
$ tahoe ls mydrive:music
piano.mp3
$ tahoe cp mydrive:dogs.jpg /tmp/newdogs.jpg
$ ls /tmp
newdogs.jpg

命令行工具构建在 HTTP API 之上,我们将在后面探讨。

系统结构

客户机节点是一个长期存在的网关守护进程,它接受来自“前端”协议的上传和下载请求。最基本的前端是一个 HTTP 服务器,它监听环回接口(127.0.0.1)。

HTTP GET用于检索数据,这涉及多个步骤:

  • 解析 filecap 以提取解密密钥和存储索引;

  • 确定我们需要每个共享的哪些部分来满足客户端请求,包括共享数据和中间散列树节点;

  • 使用存储索引来确定哪些服务器可以共享此文件;

  • 向这些服务器发送下载请求;

  • 跟踪我们已发送的请求和已完成的请求,以避免重复请求,除非必要;

  • 跟踪服务器响应时间,选择速度更快的服务器;

  • 核实股份,拒绝腐败的股份;

  • 当可用或失去连接时,切换到更快的服务器;

  • 将份额重组为密文;

  • 解密密文,将明文传递给前端客户端。

这由一个事件循环来管理,该事件循环随时准备接受来自前端管理器的新的read()请求,或者来自服务器的响应,或者指示是时候放弃一个服务器并尝试另一个服务器的定时器到期。这个循环将同时处理几十个甚至几百个连接和计时器,其中任何一个上的活动都会导致其他的事情发生。Twisted 的事件循环非常适合这种设计。

在另一个方向,HTTP PUTPOST动作导致数据被上传,这执行许多相同的步骤,但是向后:

  • 客户端节点接受来自前端协议的数据,并将其缓存在临时文件中;

  • 对文件进行哈希处理以构建“聚合加密密钥”,该密钥也用于对文件进行重复数据消除;

  • 加密密钥被散列以形成存储索引;

  • 存储索引标识我们应该尝试使用哪些服务器(服务器列表对于每个存储索引以不同的方式排序,并且该列表提供了优先级排序);

  • 向这些服务器发送上传请求;

  • 如果文件上传得更早,服务器会告诉我们他们已经有一个共享,在这种情况下,我们不需要再次存储那个;

  • 如果一个服务器拒绝我们的请求(没有足够的磁盘空间),或者回答速度不够快,请尝试另一个服务器;

  • 收集响应,直到每个共享映射到一个服务器;

  • 对每一段明文进行加密编码,会占用大量的 CPU(至少相对于网络活动来说是这样的),所以我们把它推给一个单独的线程来利用多核的优势;

  • 编码完成后,将共享上传到之前映射的服务器;

  • 当所有服务器确认收到时,构建最终的散列树;

  • 从哈希树的根和加密密钥构建 filecap

  • 在 HTTP 响应体中返回 filecap。

客户端还实现其他(非 HTTP)前端协议:

  • FTP:通过提供用户名、密码和 rootcaps 的配置文件,Tahoe 客户机节点可以假装成一个 FTP 服务器,为每个用户提供一个单独的虚拟目录;

  • SFTP:像 FTP 一样,但是分层在 SSH 之上;

  • Magic-Folder:一个类似 Dropbox 的双向目录同步工具。

客户对介绍人说废话,以了解服务器。他们也对服务器本身说废话。

太浩-LAFS 存储服务器可以将共享存储在本地磁盘上,也可以将它们发送到远程商品存储服务,如 S3 或 Azure。例如,服务器在前端使用 Foolscap,在后端使用基于 HTTP 的 S3 命令。

在存储服务器上,节点必须接受来自任意数量客户端的连接,每个客户端都将发送重叠的共享上传/下载请求。对于像 S3 这样的远程后端,每个客户端请求都可能引发多个 S3 端 API 调用,每个调用都可能失败或超时(并且需要重试)。

所有节点类型还运行 HTTP 服务来进行状态和管理。这目前使用 Nevow 渲染,但是我们打算切换到 Twisted 的内置 HTTP 模板工具(twisted.web.template)。

它如何使用 Twisted 的

塔霍-LAFS 大量使用了 Twisted:我们很难想象我们可以用其他方式来写它。

该应用是围绕一个 Twisted 的MultiService层次结构构建的,它控制上传者、下载者、引入者客户端等的启动和关闭。这让我们可以在单元测试期间启动单独的服务,而不需要每次都启动整个节点。

最大的服务是Node,它代表整个客户机、服务器或介绍者。这是其他所有东西的父级MultiService。关闭服务(并等待所有网络活动停止)就像调用stopService()并等待延迟启动一样简单。默认情况下,节点监听临时分配的端口,并向引入者宣布它们的位置。所有状态仅限于节点的“基本目录”这使得在单个过程中启动多个客户机/服务器变得很容易,以便一次测试整个网格。这与早期的体系结构形成对比,在早期的体系结构中,每个存储服务器都需要一个单独的 MySQL 数据库,并使用固定的 TCP 端口。在该系统中,如果没有至少 5 台不同的计算机,就不可能进行真实的测试。在 Tahoe 中,集成测试套件将启动一个包含 10 台服务器的网格,所有这些都在一个进程中,执行一些功能,然后在几秒钟内再次关闭所有的功能。每当您运行tox来运行测试套件时,这种情况都会发生几十次。

Twisted 强大的集成协议实现套件支持各种前端接口。我们不需要编写 HTTP 客户端,或者服务器,或者 FTP 服务器,或者 SSH/SFTP 服务器:这些都是 Twisted 附带的“电池”。

我们遇到的问题

我们对 Twisted 的使用相当顺利。如果我们今天重新开始,我们还是会从 Twisted 开始。我们的遗憾是微不足道的:

  • 依赖负载:一些用户(通常是打包者)觉得 Tahoe 依赖于太多的库。多年来,我们试图避免添加依赖项,因为 Python 的打包工具不成熟,但现在pip让这变得容易多了;

  • 打包/分发:很难从 Python 应用中构建一个单文件的可执行文件,所以目前用户必须了解 Python 特有的工具,如pipvirtualenv,以便在他们的家庭计算机上安装 Tahoe

  • Python 3: Twisted 现在对 Python 3 有很好的支持,但这需要很多年的努力。在此期间,我们变得自满,代码自由地将机器可读的字节与人类可读的字符串混杂在一起。既然 py3 是首选实现(py2 的 2020 年寿终正寝的最后期限即将到来),我们正在努力更新我们的代码以在 py3 下工作。

虚拟化工具

Twisted 提供了一个名为twistd的便利工具,它允许将长时间运行的应用编写为插件,使 Twisted 负责特定于平台的后台化细节(例如从控制 tty 中分离,记录到文件而不是 stdout,以及在打开特权侦听 TCP 端口后可能切换到非 root 用户)。当 Tahoe 开始的时候,“pip”和“virtualenv”都还不存在,所以我们建造了类似的东西。为了将虚拟化与这个定制的依赖安装程序/管理器结合起来,Tahoe 命令行工具包括了tahoe starttahoe stop子命令。

如今,我们可能会省略这些子命令,让用户运行twistdtwist(非守护进程形式)。我们还会寻找根本不需要守护进程的方法。

开始的时候,twistd没有那么容易管理,所以 Tahoe 用了”。点击“文件”来控制它。这是我在 Buildbot 中使用的模式的延续,遗憾的是第一个版本使用了”。点击“文件”来记录状态(一种应用的“冻干”副本,下次你想启动它时可以再次解冻)。Tahoe 从未将动态状态放入其中,但是tahoe create-node进程会创建一个带有正确初始化代码的.tap文件来实例化和启动新节点。然后tahoe start是围绕twistd -y node.tap的一个简单包装。

不同种类的。tap文件用于启动不同类型的节点(客户端、服务器、引入器等)。).这是一个错误的决定。那个。tap 文件只包含几行:一个 import 语句和实例化应用对象的代码。两者最终都限制了我们重新安排代码库或改变其行为的能力:简单地重命名Client类会破坏所有现有的部署。我们无意中创建了一个公共 API(包含所有的兼容性问题),其中的“公共”是早期 Tahoe 安装使用的所有旧的.tap文件。

我们通过让tahoe start忽略.tap文件的内容,只关注它的文件名来解决这个问题。节点的大部分配置已经存储在一个名为tahoe.cfg的单独的 INI 风格文件中,所以转换非常容易。当tahoe start看到client.tap时,它创建一个客户机实例(相对于介绍者/etc。),用配置文件初始化它,并设置守护进程运行。

内部文件节点接口

在内部,Tahoe 定义了FileNode对象,可以从现有文件的 filecap 字符串创建,也可以通过第一次上传一些数据从头开始创建。这些提供了一些简单的方法,隐藏了加密、擦除编码、服务器选择和完整性检查的所有细节。下载方法在名为IReadable的接口中定义:

class IReadable(Interface):

    def get_size():
        """Return the length (in bytes) of this readable object."""

    def read(consumer, offset=0, size=None):
        """Download a portion (possibly all) of the file's contents, making them available to the given IConsumer. Return a Deferred that fires (with the consumer) when the consumer is unregistered (either because the last byte has been given to it, or because the consumer threw an exception during write(), possibly because it no longer wants to receive data). The portion downloaded will start at 'offset' and contain 'size' bytes (or the remainder of the file if size==None). """

Twisted 将zope.interface用于支持接口定义的类(即Interface实际上是zope.interface.Interface)。我们使用这些作为类型检查的一种形式:前端可以断言被读取的对象是IReadable的提供者。FileNode有多种,但都实现了IReadable接口,前端代码只使用那个接口上定义的方法。

read()接口不直接返回数据:相反,它接受一个“消费者”,当数据到达时,它可以将数据提供给这个消费者。它使用 Twisted 的生产者/消费者系统(在第一章中描述)来传输数据,而没有不必要的缓冲。这使得 Tahoe 能够在不使用千兆字节内存的情况下传送数千兆字节的文件。

类似地,也可以创建对象。这些节点也有方法(在IDirectoryNode中定义)来列出它们的子节点,或者跟随子节点链接(通过名称)到其他节点。可变目录包括通过名称添加或替换子目录的方法。

class IDirectoryNode(IFilesystemNode):
    """I represent a filesystem node that is a container, with a name-to-child mapping, holding the tahoe equivalent of a directory. All child names are unicode strings, and all children are some sort of IFilesystemNode (a file, subdirectory, or unknown node).
    """
    def list():
        """I return a Deferred that fires with a dictionary mapping child name (a unicode string) to (node, metadata_dict) tuples, in which 'node' is an IFilesystemNode and 'metadata_dict' is a dictionary of metadata."""
    def get(name):
        """I return a Deferred  that fires with a specific named child node, which is an IFilesystemNode. The child name must be a unicode string. I raise NoSuchChildError if I do not have a child by that name."""

请注意,这些方法返回延迟。目录存储在文件中,文件存储在共享中,共享存储在服务器上。我们不知道这些服务器何时会响应我们的下载请求,所以我们使用一个延迟来“等待”数据可用。

每个前端协议都使用这个节点对象图。

前端协议集成

为了探索 Tahoe 如何利用 Twisted 的多种协议支持,我们将研究几个“前端协议”这些提供了外部程序和内部IFileNode / IDirectoryNode / IReadable接口之间的桥梁。

所有的协议处理器都使用一个名为Client的内部对象,其最重要的方法是create_node_from_uri。这需要一个 filecap 或 directorycap(作为一个字符串),并返回相应的FileNodeDirectoryNode对象。从这里,调用者可以使用它的方法来读取或修改底层的分布式文件。

Web 前端

Tahoe-LAFS 客户端守护程序提供了一个本地 HTTP 服务来控制其大部分操作。这包括一个以人为本的浏览文件和文件夹的网络应用(“WUI”:网络用户界面)和一个以机器为本的控制界面(“WAPI”:网络应用编程界面),我们亲切地称之为“哇”和“哇”

两者都是通过 Twisted 内置的twisted.web服务器实现的。“资源”对象的层次结构将请求路由到某个叶子,该叶子实现类似于render_GET的方法来处理请求细节并提供响应。默认情况下,它监听端口 3456,但是这可以在tahoe.cfg文件中通过提供不同的端点描述符来配置。

Tahoe 实际上使用了“Nevow”项目,该项目在 raw twisted.web之上提供了一个层,但这些天 Twisted 的内置功能本身就足够强大,所以我们正在慢慢地从代码库中删除 Nevow。

最简单的 WAPI 调用是获取文件。HTTP 客户端提交一个 filecap,Tahoe 将其转换成一个FileNode,下载内容,并在 HTTP 响应中返回数据。该请求看起来像是:

curl -X GET http://127.0.0.1:3456/uri/URI:CHK:bz3lwnno6stus:mwmb5vae...

这会产生一个带有“path”数组的twisted.web.http.Request,该数组有两个元素:文字字符串“uri,”和 filecap。Twisted 的 web 服务器从一个根资源开始,在这个资源上可以附加不同名称的处理程序。我们的Root资源用上面描述的Client对象实例化,并配置了一个用于uri名称的处理程序:

from twisted.web.resource import Resource
class Root(Resource):
    def __init__(self, client):
        ...
        self.putChild("uri", URIHandler(client))

所有以uri/开始的请求都将被路由到这个URIHandler资源。当这些请求有额外的路径组件(例如,我们的 filecap)时,它们将导致调用getChild方法,该方法负责找到正确的资源来处理请求。我们将从给定的 filecap/dircap 创建一个 FileNode 或 DirectoryNode,然后将它包装在一个特定于 web 的 handler 对象中,该对象知道如何处理 HTTP 请求:

class URIHandler(Resource):
    def __init__ (self, client):
        self.client = client
    def getChild(self, path, request):
        # 'path' is expected to be a filecap or dircap
        try:
            node = self.client.create_node_from_uri(path)
            return directory.make_handler_for(node,self.client)
        except (TypeError,AssertionError):
            raise WebError("'%s' is not a valid file- or directory- cap" %name)

node是包装来自 GET 请求的 filecap 的FileNode对象。该处理程序来自一个 helper 函数,它检查节点的可用接口并决定创建哪种包装器:

def make_handler_for(node, client, parentnode=None, name=None):
    if parentnode:
       assert IDirectoryNode.providedBy(parentnode)
    if IFileNode.providedBy(node):
       return FileNodeHandler(client, node, parentnode, name)
    if IDirectoryNode.providedBy(node):
       return DirectoryNodeHandler(client, node, parentnode, name)
    return UnknownNodeHandler(client, node, parentnode, name)

对于我们的例子,这将返回FileNodeHandler。这个处理程序有很多选项,并且web/filenode.py中的实际代码看起来非常不同,但是一个简化的形式应该是这样的:

class FileNodeHandler(Resource):
    def __init__ (self, client, node, parentnode=None, name=None):
        self.node = node
        ...
    @inlineCallbacks
    def render_GET(self, request):
        version = yield self.node.get_best_readable_version()
        filesize = version.get_size()
        first, size, contentsize = 0, None, filesize
        ... # these will be modified by a Range header, if present
        request.setHeader("content-length", b"%d" % contentsize)
        yield version.read(request, first, size)

Twisted 的原生 web 服务器不允许Resource对象返回 Deferreds,但是 Nevow 的允许,这很方便。基本上是这样的:

  • 首先,我们向 FileNode 询问它的最佳可读版本。不可变文件不需要这样做(反正只有一个版本),但是可变文件在网格上可能有多个版本。“最佳”是指最新的。我们得到一个提供了IReadable接口的“版本”对象。

  • 接下来,我们计算文件的大小。对于不可变文件,大小嵌入在 filecap 中,所以get_size()方法让我们可以立即计算出来。对于可变文件,大小是在我们检索版本对象时确定的。

  • 我们使用文件的大小和范围头(如果提供的话)来计算要读取多少数据,以及从什么偏移量开始。

  • 我们设置 Content-Length 头来告诉 HTTP 客户机预期有多少数据。

  • 调用IReadableread()方法开始下载。请求对象也是一个 IConsumer,下载代码构建一个 IProducer 来附加到它。这将返回一个延迟,当文件的最后一个字节已传递给使用者时,将触发该延迟。

  • 当最后一个延迟触发时,服务器知道它可以关闭 TCP 连接,或者为下一个请求重置它。

我们省略了许多细节,在下面展开。

文件类型,内容类型,/name/

Tahoe 的存储模型将文件上限映射到字节串,没有名称、日期或其他元数据。目录包含名字和日期,在指向它们孩子的表条目中,但是一个基本的 filecap 只给你一堆字节。

然而,HTTP 协议为每次下载包含一个Content-Type,它允许浏览器决定如何呈现页面(HTML、JPG 或 PNG),或者在将页面保存到磁盘时记录什么操作系统元数据。此外,大多数浏览器假定 URL 路径的最后一部分是文件名,并且“保存到磁盘”功能将使用它作为默认文件名。

为了处理这种不匹配,Tahoe 的 WAPI 有一个特性,让你下载一个在路径的最后一个元素有任意名称的 filecap。WUI 目录浏览器将这些特殊的 URL 放在目录页面的 HTML 中,因此“将链接另存为..”工作正常。完整的 URL 如下所示:

http://127.0.0.1:3456/named/URI:CHK:bz3lwnno6stus:mwmb5vae../kittens.jpg

这看起来很像一个目录和里面的一个孩子。为了避免视觉上的混乱,我们通常会在这样的 URL 中插入一个看起来特别有趣的字符串:

http://127.0.0.1:3456/named/URI:CHK:bz3lwn../@@named=/kittens.jpg

这是用一个创建了一个FileNodeHandlerNamed资源实现的,但是它还会记住self.filename中 URL 路径的最后一个组件(忽略任何中间组件,比如@@ named=字符串)。然后,当我们运行render_GET时,我们将这个文件名传递给一个 Twisted 实用程序,该实用程序使用相当于/etc/mime.types的代码将文件名后缀映射到一个类型字符串。由此,我们可以设置Content-TypeContent-Encoding标题。

# from twisted.web import static

ctype, encoding = static.getTypeAndEncoding(
    self.filename,
    static.File.contentTypes,
    static.File.contentEncodings,
    defaultType="text/plain")
request.setHeader("content-type", ctype)
if encoding:
    request.setHeader("content-encoding", encoding)

保存到磁盘

当你点击一个链接时,浏览器将试图呈现返回的文档:HTML 经过布局,图像被绘制在窗口中,音频文件被播放,等等。如果它不能识别文件类型,它会将文件保存到磁盘。Tahoe 的“WUI”HTML 前端提供了一种强制执行这种保存到磁盘行为的方式:对于任何指向文件的 URL,只需在 URL 后面附加一个?save=True查询参数。web 服务器通过添加一个Content-Disposition头来处理这个问题,这个头指示浏览器总是保存响应,而不是试图呈现它:

if boolean_of_arg(get_arg(request,"save","False")):
    request.setHeader("content-disposition",
                      'attachment; filename="%s"' % self.filename)

范围标题

web 前端允许 HTTP 客户端通过提供一个范围头来请求文件的一个子集。当“搓擦”控制用于在影片或音频文件中跳转时,流媒体播放器(如 VLC 或 iTunes)经常使用这种方法。通过使用 Merkle 散列树,Tahoe 的编码方案被特别设计来有效地支持这种随机存取。

Merkle 哈希树首先将数据分割成段,然后对每个段应用加密哈希函数(SHA256)。然后,我们将每对段散列放入第二层(长度是第一层的一半)。重复这个减少过程,直到我们在中间散列节点的二进制树的顶部有单个“根散列”,而段在底部。根散列存储在 filecap 中,我们将其他所有东西(数据段和中间散列)发送到服务器。在检索期间,通过要求服务器提供从该段到根的路径的伴随散列节点,可以对照存储的根来验证任何单个段,而无需下载所有其他段。这使得能够以最少的数据传输快速验证任意数据段。

web 前端通过解析请求的 Range 头、设置响应的 Content-Range 和 Content-Length 头,以及修改我们传递给read()方法的firstsize值来处理这个问题。

解析范围头并不简单,因为它可以包含一个(可能重叠的)范围列表,其中可能包含文件的开头或结尾,并且可以用不同的单位(不仅仅是字节)来表示。幸运的是,允许服务器忽略不可解析的范围规范:这样效率不高,但是它们可以返回整个文件,就好像范围头不存在一样。然后,客户端有义务忽略他们不想要的数据部分。

first, size, contentsize = 0,None, filesize
request.setHeader("accept-ranges","bytes")

rangeheader = request.getHeader('range')
if rangeheader:
    ranges = self.parse_range_header(rangeheader)

    # ranges = None means the header didn't parse, so ignore
    # the header as if it didn't exist. If is more than one
    # range, then just return the first for now, until we can
    # generate multipart/byteranges.
    if ranges is not None:
        first, last = ranges[0]

        if first >= filesize:
            raise WebError('First beyond end of file',
                           http.REQUESTED_RANGE_NOT_SATISFIABLE)
        else:
            first = max(0, first)
            last = min(filesize-1, last)

            request.setResponseCode(http.PARTIAL_CONTENT)
            request.setHeader('content-range',"bytes %s-%s/%s" %
                              (str(first), str(last),
                              str(filesize)))
            contentsize = last – first + 1
            size = contentsize

request.setHeader("content-length", b"%d" % contentsize) 

返回端的错误转换

当出现问题时,Tahoe 的内部 API 会抛出各种异常。例如,如果太多的服务器出现故障,文件可能无法恢复(至少在一些服务器重新联机之前无法恢复)。我们试图用一个运行在 HTTP 处理链末端的异常处理程序将这些异常映射成合理的 HTTP 错误代码。这个处理程序的核心被命名为humanize_failure(),并查看twisted.python.failure.Failure对象,该对象封装了在延迟处理期间引发的所有异常:

def humanize_failure(f):
    # return text, responsecode
    if f.check(EmptyPathnameComponentError):
        return ("The webapi does not allow empty pathname components, "
                "i.e. a double slash" , http.BAD_REQUEST)
    if f.check(ExistingChildError):
      return ("There was already a child by that name, and you asked me "
              "to not replace it." , http.CONFLICT)
    if f.check(NoSuchChildError):
        quoted_name = quote_output(f.value.args[0], encoding="utf-8")
        return ("No such child: %s" % quoted_name, http.NOT_FOUND)
    if f.check(NotEnoughSharesError):
        t = ("NotEnoughSharesError: This indicates that some "
             "servers were unavailable, or that shares have been "
             "lost to server departure, hard drive failure, or disk "
             "corruption. You should perform a filecheck on "
             "this object to learn more.\n\nThe full error message is:\n"
             "%s" ) % str(f.value)
        return (t, http.GONE)
    ...

返回值的前半部分是要放入 HTTP 响应正文的字符串;第二个是 HTTP 错误码本身。

呈现 UI 元素:Nevow 模板

Tahoe 的 WUI 提供了一个文件浏览器界面:目录面板、文件列表、上传/下载选择器、删除按钮等。这些由 HTML 组成,在服务器端由 Nevow 模板呈现。

web/目录包含每个页面的 XHTML 文件,占位符由DirectoryNodeHandler类的变量填充。每个占位符都是一个命名空间 XML 元素,用来命名一个“槽”目录列表模板如下所示:

<table class="tahoe-directory"n:render="sequence"n:data="children" >
  <tr n:pattern="header">
    <th>Type</th>
    <th>Filename</th>
    <th>Size</th>
  </tr>
  <tr n:pattern="item"n:render="row" >
    <td><n:slot name="type"/></td>
    <td><n:slot name="filename"/></td>
    <td align="right"><n:slot name="size"/></td>
  </tr>

directory.py中,填充该表单的代码循环遍历正在呈现的目录的所有子目录,检查其类型,并使用ctx“context”对象按名称填充每个槽。对于文件,T.a Nevow 标签产生一个超链接,其中href=属性指向一个使用前面描述的/named/前缀的下载 URL:

...
elif IImmutableFileNode.providedBy(target):
    dlurl = "%s/named/%s/@@named=/%s"%(root, quoted_uri, nameurl)
    ctx.fillSlots("filename", T.a(href=dlurl, rel="noreferrer")[name])
    ctx.fillSlots("type","FILE")
    ctx.fillSlots("size", target.get_size())

Nevow 还提供了构建 HTML 输入表单的工具。这些用于构建上传文件选择器表单和“制作目录”名称输入元素。

FTP 前端

前端协议允许其他应用以某种与其现有数据模型相匹配的形式访问这个内部文件图。例如,FTP 前端将每个“帐户”(用户名/密码对)分配给一个根目录。当 FTP 客户端连接到该帐户时,他们会看到一个文件系统,该文件系统从该目录节点开始,并且只向下扩展(到子文件和子目录中)。在一个普通的 FTP 服务器中,所有的帐户都看到相同的文件系统,但是有不同的权限(Alice 不能读取 Bob 的文件),以及不同的开始目录(Alice 在/home/alice开始,Bob 在/home/bob开始)。在 Tahoe FTP 服务器中,Alice 和 Bob 将拥有完全不同的文件系统视图,这些视图可能根本不会重叠(除非他们已经安排共享他们空间的某个部分)。

Tahoe 的 FTP 前端建立在 Twisted 的 FTP 服务器上(twisted.protocols.ftp)。FTP 服务器使用 Twisted 的“Cred”框架进行帐户管理(包括“门户”、“领域”和“头像”)。因此,服务器由几个组件组成:

  • 端点:这定义了服务器将监听哪个 TCP 端口,以及使用哪些网络接口之类的选项(例如,服务器可以被限制为只监听 127.0.0.1,即环回接口)。

  • FTPFactory (twisted.protocols.ftp.FTPFactory):这提供了整个 FTP 服务器。它是一个“协议工厂”,所以每次新客户端连接时都会调用它,它负责构建管理特定连接的Protocol实例。当您告诉端点开始监听时,您给了它一个工厂对象。

  • Checker:这是一个实现ICredentialsChecker并处理认证的对象,通过检查一些凭证并(如果成功)返回一个“化身 ID”在 FTP 协议中,凭证是用户提供的用户名和密码。在 SFTP,它们包括 SSH 公钥。“头像 ID”只是一个用户名。Tahoe FTP 前端可以配置为使用一个AccountFileChecker(在 auth.py 中),它将用户名/密码/rootcap 映射存储在一个本地文件中。它还可以使用一个AccountURLChecker,查询一个 HTTP 服务器(它发布用户名和密码,并在响应中获取 rootcap)。AccountURLChecker用于 AllMyData 的集中账户管理。

  • Avatar:这是处理特定用户体验的服务器端对象。它还特定于一个服务类型,因此它必须实现一些特定的Interface,在本例中是一个名为IFTPShell的 Twisted 接口(它有像makeDirectorystatlistopenForReading这样的方法)。

  • Realm:这是任何实现 Twisted 的IRealm接口的对象,负责把一个头像 ID 变成头像。Realm API 还处理多个接口:需要特定类型访问的客户端可以请求特定的Interface,Realm 可能会根据他们的请求返回不同的虚拟角色。在 Tahoe FTP 前端,realm 是一个名为Dispatcher的类,它知道如何从帐户信息创建一个根目录节点,并将其包装在一个处理程序中。

  • Portal (twisted.cred.portal.Portal):这是一个管理跳棋和领域的 Twisted 对象。在构建时,FTPFactory配置有一个Portal实例,所有涉及授权的事情都委托给门户。

  • Handler ( allmydata.frontends.ftpd.Handler):这是一个 Tahoe 对象,实现了 Twisted 的IFTPShell,并将 FTP 概念翻译成 Tahoe 概念。

Tahoe FTP 服务器代码执行以下操作:

  • 创建一个挂在顶层节点 multiservice 上的MultiService

  • 挂一个strports.service下来,监听 FTP 服务器端口;

  • FTPFactory;配置监听器

  • Portal;配置工厂

  • 创建一个Dispatcher作为门户的“领域”;

  • 向门户添加一个AccountFileChecker和/或一个 Acc ountURLChecker

当 FTP 客户端连接时,用户名和密码被提交给AccountFileChecker,它之前已经将帐户文件解析到内存中。帐户查找非常简单:

class FTPAvatarID:
    def __init__ (self, username, rootcap):
        self.username = username
        self.rootcap = rootcap
@implementer(checkers.ICredentialsChecker)
class AccountFileChecker(object):
    def requestAvatarId(self, creds):
        if credentials.IUsernamePassword.providedBy(creds):
            return self._checkPassword(creds)
        ...
    def _checkPassword(self, creds):
        try:
            correct = self.passwords[creds.username]
        except KeyError:
            return defer.fail(error.UnauthorizedLogin())

        d = defer.maybeDeferred(creds.checkPassword, correct)
        d.addCallback(self._cbPasswordMatch, str(creds.username))
        return d

    def _cbPasswordMatch(self, matched, username):
        if matched:
            return self._avatarId(username)
        raise error.UnauthorizedLogin

    def _avatarId(self, username):
        return FTPAvatarID(username,self.rootcaps[username])

如果用户名不在列表中,或者如果密码不匹配,requestAvatarId将返回一个延迟的 errbacks 和UnauthorizedLogin,FTPFactory 将返回适当的 FTP 错误代码。如果两者都是好的,那么它返回一个FTPAvatarID对象,该对象封装了用户名和账户的 rootcap URI(只是一个字符串)。

当这成功时,门户要求其领域(即,我们的 Dispatcher 对象)将化身 ID 转换成处理程序。我们的领域也很简单:

@implementer(portal.IRealm)
class Dispatcher(object):
    def __init__ (self, client):
        self.client = client

    def requestAvatar(self, avatarID, mind, interface):
        assert interface == ftp.IFTPShell
        rootnode = self.client.create_node_from_uri(avatarID.rootcap)
        convergence = self.client.convergence
        s = Handler(self.client, rootnode, avatarID.username, convergence)
        def logout(): pass
        return (interface, s,None)

首先,我们断言我们被请求的是一个IFTPShell,而不是一些其他的接口(我们不知道如何处理)。然后,我们使用 Tahoe 文件图 API 将 rootcap URI 转换成一个目录节点。“融合秘密”不在本章讨论范围内,但它的存在是为了提供安全的重复数据删除,提供给处理程序是为了让我们扩展接口,为每个帐户使用不同的融合秘密。

然后,我们围绕客户机(提供创建全新 filenodes 的方法)和 rootnode(提供对用户“主目录”及其下所有内容的访问)构建一个处理程序,并将其返回给门户。这足够连接 FTP 服务器了。

稍后,当客户端执行一个“ls”命令时,我们的处理程序的list()方法将被调用。我们的实现负责将列出目录的 FTP 概念(它获得相对于根目录的路径名组件的列表)转换为 Tahoe 的概念(它从根目录节点到其他目录节点进行逐步遍历)。

def list(self, path, keys=()):
    d = self._get_node_and_metadata_for_path(path)
    def _list((node, metadata)):
        if IDirectoryNode.providedBy(node):
            return node.list()
        return { path[-1]: (node, metadata) }
    d.addCallback(_list)
    def _render(children):
        results = []
        for (name, childnode) in children.iteritems():
            results.append( (name.encode("utf-8"),
                             self._populate_row(keys, childnode) ) )
        return results
    d.addCallback(_render)
    d.addErrback(self._convert_error)
    return d

我们从一个常见的“从根开始跟踪路径”助手方法开始,该方法返回一个延迟的,最终用路径命名的文件或目录的节点和元数据触发(如果路径是foo/bar,那么我们将向我们的根目录节点请求它的foo子节点,期望那个子节点是一个目录,然后向那个子目录请求它的bar子节点)。如果路径指向一个目录,我们使用 Tahoe IDirectoryNode 的node.list()方法来获取它的子节点:这将返回一个字典,该字典将子名称映射到(子节点,元数据)元组。如果路径指向一个文件,我们假设它指向一个只有一个文件的目录。

然后我们需要把这个孩子的字典变成 FTP 服务器可以接受的东西。在 FTP 协议中,LIST命令可以要求不同的属性:有时客户端需要所有者/组名,有时需要权限,有时它只关心子名称列表。Twisted 的IFTPShell接口通过给list()方法一系列“键”(字符串)来表示它想要的值。我们的_populate_row()方法将一个子元素+元数据对转换成正确的值列表。

def _populate_row(self, keys, (childnode, metadata)):
    values = []
    isdir = bool(IDirectoryNode.providedBy(childnode))
    for key in keys:
        if key == "size":
            if isdir:
                value = 0
            else:
                value = childnode.get_size() or 0
        elif key == "directory":
            value = isdir
        elif key == "permissions":
            value = IntishPermissions(0600)
        elif key == "hardlinks":
            value = 1
        elif key == "modified":
            if "linkmotime" in metadata.get("tahoe", {}):
                value = metadata["tahoe"]["linkmotime"]
            else:
                value = metadata.get("mtime",0)
        elif key == "owner":
            value = self.username
        elif key == "group":
            value = self.username
        else:
            value = "??"
        values.append(value)
    return values

对于 Twisted 想要的每一个键,我们都将其转换成可以从 Tahoe 的IFileNodeIDirectoryNode接口获得的东西。其中大多数是在元数据中的简单查找,或者通过调用节点对象上的方法来获得。一个不寻常的案例是permissions:详见下文。

最后一步是附加_convert_error作为 errback 处理程序。这将一些特定于 Tahoe 的错误转换为最接近的 FTP 等价错误,这比客户端在没有转换的情况下会得到的“内部服务器错误”更有用。

def _convert_error(self, f):
    if f.check(NoSuchChildError):
        childname = f.value.args[0].encode("utf-8")
        msg = "'%s' doesn't exist" % childname
        raise ftp.FileNotFoundError(msg)
    if f.check(ExistingChildError):
        msg = f.value.args[0].encode("utf-8")
        raise ftp.FileExistsError(msg)
    return f

SFTP 前端

SFTP 是建立在 SSH 安全外壳加密层上的文件传输协议。它向远程客户端公开了一个非常类似 POSIX 的 API:打开、查找、读取和写入,都在同一个 filehandle 上进行。另一方面,FTP 只提供单个文件的全有或全无传输。FTP 更适合 Tahoe 的文件模型,但 SFTP 在与远程服务器通信时更安全。

使用Cred的好处是相同的认证机制可以被其他协议重用。尽管 FTP 和 SFTP 有所不同,但它们使用相同的基本访问模型:客户端通过一些凭证来标识,这提供了对特定主目录的访问。在 Tahoe,FTP 和 SFTP 都使用上面相同的FTPAvatarIDAccountFileChecker类。AccountFileChecker定义了credentialInterfaces,以涵盖所有可能出现的认证类型:IUsernamePasswordIUsernameHashedPasswordISSHPrivateKey(这是特定于 SFTP 的,允许用户通过他们的 SSH 公钥而不是密码来识别)。

它们只是在领域(我们的Dispatcher类)上有所不同,后者为两种协议返回了不同种类的处理程序。

向后不兼容的 Twisted API

Tahoe 没有访问控制列表(ACL)、用户名或读/写/执行权限位的概念:它遵循“如果可以引用一个对象,就可以使用它”的对象能力原则。filecap 是不可访问的,因此引用文件的唯一方法是知道 file cap,它只能来自最初上传文件的人,或者来自从上传者那里了解到它的其他人。

大多数文件存储在目录中,因此访问控制是通过目录遍历来管理的,这是安全的,因为 Tahoe 目录没有“父”链接。你可以通过给别人一个链接来和别人分享你自己的一个目录:他们不能用这个来达到你给他们的那个目录之上的任何东西。

因此,FTP 服务器总是为“权限”字段返回“0600”,这意味着“仅由当前用户读写”这个值主要是装饰性的:FTP 客户端只使用它来填充长格式(ls -l)目录列表的“mode”列。我们在这里可以更准确,为不可变对象返回“0400 ”,但是我们并没有真的关心到做出改变。

然而,当 Twisted 的一个 API 发生意外变化时,即使是静态值也会引起问题。在早期,Twisted 使用整数来表示文件模式/权限(就像 Unix 内核和大多数 C 程序一样)。最终人们意识到这是非常以 unix 为中心的,所以在 Twisted-11.1.0 中,创建了一个漂亮、干净的filepath.Permissions类来保存这类信息作为布尔集合。

但是 FTP 服务器直到很久以后才更新使用它。在 Twisted-14.0.2 之前,list()的“权限”值应该返回一个整数。从 Twisted-15.0.0 开始,它应该返回一个Permissions实例。此外,它接受了一个Permissions实例:返回一个整数会导致异常。

实际上,IFTPShell接口在 14.0.2 和 15.0.0 之间突然发生了变化,这是当我们开始收到关于 FTP ls命令失败的错误报告时发现的(我们没有对这个前端进行端到端的测试,我们的个人手动测试仍然使用 Twisted-14.0.2,所以我们自己没有注意到这个问题)。

Twisted 通常会在做出不兼容的更改之前对几个版本的 API 进行出色的改进,但这次却没有成功,这可能是因为最常见的IFTPShell实现是 Twisted 的内置FTPShell类,该类同时被更新。所以,另一种描述问题的方式是IFTPShell被修改了,没有折旧期,好像是私有的内部 API,但实际上是公共的。

解决这个问题最简单的方法是让 Tahoe 的setup.py需要Twisted >= 15.0.0,并修改代码返回一个Permissions对象。但是对于那些在 Linux 发行版上构建 Tahoe 的人来说,这将会使生活变得更加困难,因为 Linux 发行版包含了一个已经过时了几年的 Twisted 版本。(Debian 8.0“Jessie”是 2015 年和 Twisted-14.0.2 一起发布的,直到 2017 年才被取代。)当时,Tahoe 试图兼容各种 Twisted 的版本。让用户升级他们的系统只是为了满足 Tahoe 对现代时尚的热情,这让我们感觉很糟糕。

因此,为了让 Tahoe 既能处理新旧 Twisteds,我们需要在必要时返回类似整数的行为,但也可以类似于Permissions的行为。当我们检查 Twisted-14.0.2 使用该值的方式时,我们发现它总是在格式化过程中对该值进行按位 AND 运算:

# twisted-14.0.2: twisted/protocols/ftp.py line 428

def formatMode(mode):
    return ''.join([mode&(256>>n) and 'rwx'[n % 3] or '-' for n in range(9)])

这让我们可以构建一个 helper 类,它从 Permissions 继承而来,但是如果旧的 Twisted 使用了二进制文件和方法,那么它会重写二进制文件和方法以返回一个整数:

# filepath.Permissions was added in Twisted-11.1.0, which we require.

# Twisted <15.0.0 expected an int, and only does '&' on it. Twisted

# >=15.0.0 expects a filepath.Permissions. This satisfies both.

class IntishPermissions(filepath.Permissions):
    def __init__ (self, statModeInt):
        self._tahoe_statModeInt = statModeInt
        filepath.Permissions.__init__(self, statModeInt)
    def __and__ (self, other):
        return self._tahoe_statModeInt&other

如今,情况有所不同。我们不再建议用户将 Tahoe(或任何 Python 应用)安装到像/usr/local/bin这样的系统级位置,也不建议针对系统提供的 Python 库运行 Tahoe。相反,从源代码构建的用户应该将 Tahoe 安装到一个新的 virtualenv 中,在这里可以很容易地安装所有依赖项的最新版本,并且它们可以安全地与系统 python 隔离。

pipsi工具使这变得非常容易:pipsi install tahoe-lafs将创建一个 Tahoe 特定的 virtualenv,将 Tahoe 及其所有依赖项安装到其中,然后将tahoe可执行文件符号链接到~/.local/bin/tahoe中,它可能在您的 PATH 中。pipsi是现在推荐的从源码树安装 Tahoe 的方法。

系统范围的安装应该通过操作系统软件包管理器来完成。例如,apt install tahoe-lafs将在现代 Debian 和 Ubuntu 版本上获得一个工作的/usr/bin/tahoe,他们将使用来自/usr/lib/python2.7/dist-packages的全系统依赖(如 Twisted)。Debian 开发人员(和其他打包人员)负责确保系统范围的库与所有打包的应用兼容:Tahoe、Magic-Wormhole、Buildbot、Mercurial、Trac 等。当 Tahoe 摆脱对 Twisted 的依赖时,包装商必须解决这个问题。如果系统升级像 Twisted 这样的库,并且它包含意外的不兼容性,那么升级可以被恢复,直到 Tahoe 可以被修补来解决这个问题。

摘要

太浩湖-LAFS 是一个大型项目,始于 2006 年,当时 Twisted 还不是很老。它包含不再存在的错误的解决方法,以及已经被新的 Twisted 特性取代的技术。有时,代码似乎更好地反映了开发人员的历史恐惧和个人特质,而不是作为一个很好的教学示例。

但它也嵌入了多年来“愤怒地”(不是随便地)与 Twisted 的代码库一起工作的经验。尽管 Tahoe-LAFS 可能不是一个家喻户晓的名字,但它的核心思想已经影响并融入了许多其他分散存储系统(用 Go、Node.js、Rust 等编写)。

Twisted 的中央事件循环和大量现成的协议实现对我们的特性集至关重要。如果您真的不喜欢事件驱动的系统,您可以尝试用线程和锁来实现类似的东西(在客户端,您需要一个单独的线程来写入每个服务器,第二个线程用于从每个服务器接收,第三个线程用于每个前端请求,所有这些都必须小心地使用锁来防止并发访问)。这种方法安全工作的可能性很低。

Python 标准库包括一些很好的协议实现,但它们几乎都是以模块化的方式编写的,将它们限制为一次只能做一件事的程序。希望随着 Python 3 和asyncio的发展势头,这种情况会有所改变。同时,Twisted 是这样一个项目的最佳工具。

参考

太浩-LAFS 首页: https://tahoe-lafs.org

七、魔法虫洞

魔法虫洞是一个安全的文件传输工具,它的座右铭是“安全地将东西从一台电脑转移到另一台电脑”这对于特定的一次性传输情况最为有用,例如:

  • 在一次会议上,您刚刚坐在某人旁边,您想从您的笔记本电脑上给他们一个您最喜欢的项目的 tarball。

  • 你正在和某人打电话,需要给他们一张你正在电脑上看的照片。

  • 您刚刚为一位同事建立了一个新帐户,需要从他们的计算机上安全地获取他们的 SSH 公钥。

  • 您希望将旧计算机中的 GPG 私钥复制到新的便携式计算机中。

  • IRC 上的一位同事希望您从您的计算机上给他们发送一个日志文件。

这个工具的一个与众不同的特点是使用了一个虫洞 代码:一个像“4-虚张声势-胡说八道”这样的短短语,它能够实现传输,并且必须从发送客户端传送到接收客户端。当爱丽丝给鲍勃发送一个文件时,爱丽丝的电脑会显示这个短语。Alice 必须设法让 Bob 知道这个短语:通常,她会在电话里对他说,或者通过 SMS 或 IRC 键入。该代码由一个数字和几个单词组成,旨在方便准确地转录,即使在嘈杂的环境中。

这些代码是一次性使用的。安全属性很简单:第一个正确声明代码的接收者将得到文件,其他人没有。这些属性是的:没有其他人可以得到文件,因为它是加密的,只有第一个正确的声明可以计算出解密密钥。它们只依赖于客户端软件的行为:没有服务器或互联网窃听者可以违反它们。魔法虫洞是独一无二的结合了强大的保密性和简单的工作流程。

它看起来像什么

Magic Wormhole 目前只能作为基于 Python 的命令行工具使用,但是移植到其他语言和运行时环境的工作正在进行中。最重要的项目是开发一个 GUI 应用(可以拖放要传输的文件)和一个移动应用。

img/455189_1_En_7_Fig3_HTML.jpg

图 7-3

魔法虫洞工作流程图

img/455189_1_En_7_Fig2_HTML.jpg

图 7-2

接收者截图

img/455189_1_En_7_Fig1_HTML.jpg

图 7-1

发送器屏幕截图

  • 1:爱丽丝在她的电脑上运行wormhole send FILENAME,它告诉她虫洞代码(“4-虚张声势-华夫”)。

  • 2:然后,她在电话中向 Bob 口述。

  • 3:鲍勃将虫洞代码输入他的电脑。

  • 4:两台计算机连接,然后加密并传输文件。

它是如何工作的

魔法虫洞客户端(发送者和接收者)连接到同一个会合服务器并交换少量短消息。这些消息用于运行一个名为 SPAKE2 的特殊加密密钥协商协议,这是一个基本 Diffie-Hellman 密钥交换协议的认证版本(参见下面的参考资料了解更多详细信息)。

每一方通过输入一个密码来启动他们的 SPAKE2 协议状态机:随机生成的虫洞代码。他们的一半产生一个信息传递到另一边。当该消息被传递时,另一方将其与他们自己的内部状态相结合,以产生会话密钥。当双方使用相同的虫洞代码时,他们的两个会话密钥将是相同的。每次协议运行时,他们都会得到一个新的随机会话密钥。他们使用这个会话密钥来加密所有后续的消息,提供一个安全的连接来找出文件传输细节的其余部分。

img/455189_1_En_7_Fig4_HTML.jpg

图 7-4

空间 2 图

任何试图拦截连接的攻击者都只有一次机会猜对密码。如果他们错了,两个会话密钥将完全不同,攻击者将无法解密其余的消息。真正的客户端会注意到不匹配,并在尝试发送任何文件数据之前退出,并显示一条错误消息。

一旦他们建立了安全连接,魔法虫洞客户端就交换他们想要传输的信息,然后他们一起工作建立一个传输连接,批量数据传输将通过该连接进行。这从双方打开一个侦听 TCP 网络套接字开始。他们找出所有可能引用这个套接字的 IP 地址(可能有多个)并建立一个连接提示列表,他们用会话密钥对其加密并通过 rendezvous 服务器发送到另一端。

每一端都尝试与其收到的每个连接提示建立直接连接。第一次成功的尝试用于文件传输。如果双方都在同一个本地网络上(例如,当两台计算机都在同一个会议 WiFi 上时),这是可行的。由于它们都试图相互连接(不管哪一方发送文件),如果至少有一台机器是具有公共 IP 地址的服务器,这也是可行的。实际上,这似乎在三分之二的情况下建立了直接联系。

如果两台机器位于不同的 NAT 防火墙之后,所有的直接连接都将失败。在这种情况下,他们退回到使用中央中转中继服务器,该服务器基本上将两个入站 TCP 连接粘合在一起。

在所有情况下,文件数据都是用会话密钥加密的,所以无论是会合服务器还是中转中继都不能看到文件的内容。

通过导入wormhole库和进行 API 调用,同样的协议可以在其他应用中使用。例如,像 Signal 或 Wire 这样的加密即时消息应用可以利用这一点将朋友的公钥安全地添加到您的地址簿中:您可以告诉朋友一个虫洞代码,而不是复制一个大的密钥字符串。

网络协议、传输延迟、客户端兼容性

从发送方启动工具到最后一个字节到达接收方的总传输时间大致是三个阶段的总和:

  • 等待接收者输入完虫洞代码;

  • 执行密钥协商并协商中转连接;

  • 通过加密通道传输文件。

第一阶段取决于人类:程序会愉快地等待几天,让接收者最终输入虫洞代码。最后一个阶段取决于文件的大小和网络的速度。只有中间阶段真正在协议的控制之下,所以我们想让它尽可能快。我们尽量减少必须交换的消息数量,并使用低延迟实时协议来加速这一阶段。

集合服务器有效地为每对客户端提供了持久的广播信道(即“发布订阅”服务器)。发送方首先连接,给接收方留言,然后等待响应。稍后,当接收方的人最终启动他们的wormhole程序时,接收者将连接并收集该消息,并发送一些它自己的消息。如果任一客户端出现网络问题,它们的连接可能会断开,必须重新建立连接。

网络协议和客户端兼容性

正如本书第一章所见,Twisted 使得在 TCP 或 UDP 上构建定制协议变得非常容易。我们可以为会合连接建立一个简单的基于 TCP 的协议。但是当我们考虑未来时,我们希望看到其他语言和运行时环境中的魔法虫洞客户端,比如网页或移动操作系统。我们为命令行 Twisted 应用构建的协议可能不容易在其他语言中实现,或者它可能需要禁止这些程序访问的网络:

  • Web 浏览器可以执行 WebSockets 和 WebRTC,但不能执行原始 TCP 连接。

  • 浏览器扩展可以做网页能做的一切,甚至更多,但必须用专门的 JavaScript 实现,因为二进制协议不是很自然。

  • iOS/Android 可以进行 HTTP,但是电源管理可能会禁止长时间的连接,并且非 HTTP 请求可能不会激活无线电。

因此,为了跨运行时的兼容性,我们必须坚持 web 浏览器可以做的事情。

最简单的协议是使用优秀的treq包进行简单的 HTTP GETs 和 POSTs,它为基于 Twisted 的程序提供了一个类似于requests的 API。然而,不清楚客户端应该多久轮询一次服务器:我们可能每秒轮询一次,浪费了大量带宽来检查一个小时内不会发生的响应。或者,我们可以通过每分钟只检查一次来节省带宽,代价是给本应只需一两秒钟的实用程序增加 60 秒的延迟。即使每秒轮询一次也会增加不必要的延迟。对于实时连接,连接的完成速度与网络传送消息的速度一样快。

减少这种延迟的一个技巧是“HTTP 长轮询”(有时称为 COMET)。在这种方法中,magic wormhole 客户端将像往常一样进行 GET 或 POST,但是中继服务器将假装花费很长时间来传递响应(实际上,服务器将只是停止响应,直到另一个客户端连接接收文件)。一个限制是,服务器通常必须在 30-60 秒内以某种方式响应,通常是“请重试”错误,否则客户端 HTTP 库可能会放弃。此外,连续的消息(如客户端发送的第二条和第三条消息)不会立即交付:发送请求所花费的时间必须添加到每条消息的延迟中。

*另一种 web 兼容的实时技术称为“服务器发送事件”,它作为EventSource JavaScript 对象暴露给 web 内容。这是进行长轮询的一种更有原则的方式:客户端进行常规的 GET,但是将Accept请求头设置为特殊值text/event-stream,以告诉服务器连接应该保持打开。响应应该包含一个编码事件流,每个事件占一行。这在服务器上很容易实现;但是,Twisted 没有现成的库。消息只在一个方向上传播(服务器到客户端),但这是我们的协议所需要的,因为我们可以在上游方向使用 POSTs。最大的缺点是一些网络浏览器(特别是 IE 和 Edge)不支持它。

我们的解决方案是使用 WebSockets 。这是一个很好的标准化协议,在大多数浏览器中实现,并作为库在许多编程语言中可用。得益于优秀的高速公路库(将在下一章描述),从 Python 和 Twisted 中使用它很容易。这个连接看起来就像一个长期的 HTTP 会话,这使得它更容易与现有的 HTTP 栈集成(并且使得它更有可能通过代理和 TLS 终结器工作)。Keepalives 是自动处理的。而且它是一个快速、实时的协议,所以消息被尽可能快地传递。

如果我们没有高速公路,我们可能会重新考虑。WebSockets 实现起来有些复杂,因为它们使用了一种特殊的帧(以防止困惑的服务器将流量误解为其他协议:您不希望攻击者的网页使您的浏览器向您公司的内部 FTP 服务器发送删除命令)。

将来,rendezvous 服务器可能会使用多种协议,而不仅仅是 WebSockets。WebRTC 是最引人注目的,因为它包括对 ICE 和 STUN 的支持。这些是执行“NAT 打洞”的协议,因此两个客户端可以建立直接的传输连接,尽管它们都在防火墙后面。WebRTC 主要用于音频/视频聊天,但它包括专门用于普通数据传输的 API。大多数浏览器都很好地支持 WebRTC。浏览器到浏览器的魔法虫洞很容易构建,并且可能比当前的 CLI 工具执行得更好。

问题是浏览器环境之外的支持很少,部分是因为音频/视频的焦点。大多数库似乎把所有的精力都花在支持音频编解码器和视频压缩算法上,留给基本连接层的时间很少。我见过的最有前途的都是用 C++写的,对它来说 Python 绑定是二等的,使得构建和打包很困难。

另一个竞争者是为 IPFS 开发的 libp2p 协议。这依赖于大型分布式哈希表(DHT)中的大量节点,而不是中央服务器,但已经过良好的测试,至少在 Go 和 JavaScript 中有很好的实现。libp2p 的 Python 版本很有前途。

服务器架构

Rendezvous 服务器被写成一个twisted.application.service.MultiService,带有一个用于主 WebSocket 连接的监听端口。

WebSockets 基本上是 HTTP,Autobahn 库使得两者可以使用同一个端口。在未来,这将让我们托管的网页和其他资产的一个基于网络的版本的魔法虫洞从同一来源的汇合服务。为了进行设置,Rendezvous 服务器看起来像这样:

from twisted.application import service
from twisted.web import static, resource
from autobahn.twisted.resource import WebSocketResource
from .rendezvous_websocket import WebSocketRendezvousFactory

class Root(resource.Resource):
    def __init__ (self):
        resource.Resource. __init__ (self)
        self.putChild(b"", static.Data(b"Wormhole Relay\n", "text/plain"))

class RelayServer(service.MultiService):
    def __init__ (self, rendezvous_web_port):
        service.MultiService. __init__ (self)
        ...
        root = Root()
        wsrf = WebSocketRendezvousFactory(None,self._rendezvous)
        root.putChild(b"v1", WebSocketResource(wsrf))

self._rendezvous是我们的Rendezvous对象,它为 Rendezvous 服务器动作提供内部 API:向通道添加消息、订阅通道等。当我们添加附加协议时,它们都将使用同一个对象。

WebSocketResource是 Autobahn 的类,用于在任何 HTTP 端点添加 WebSocket 处理程序。我们将它附加为 Root 的“v1”子节点,因此如果我们的服务器在magic-wormhole.io上,那么 Rendezvous 服务将位于 URLws://magic-wormhole.io/v1上。我们为协议的未来版本保留v2/之类的。

必须给WebSocketResource一个工厂:我们使用来自相邻模块的WebSocketRendezvousFactory。该工厂生成我们的WebSocketRendezvous类的协议实例,该类有一个onMessage方法,该方法检查每个消息的有效载荷,解析内容,并调用适当的动作:

def onMessage(self, payload, isBinary):
    msg = bytes_to_dict(payload)
    try:
        if "type" not in msg:
            raise Error("missing 'type'")
        self.send("ack", id=msg.get("id"))

        mtype = msg["type"]
        if mtype == "ping":
            return self.handle_ping(msg)
        if mtype == "bind":
            return self.handle_bind(msg)
        ...

持久数据库

当两个客户端同时连接时,rendezvous 服务器会立即将消息从一个客户端传递到另一个客户端。但是在等待第二个客户机连接时,至少必须缓冲初始消息:有时只缓冲几秒钟,但有时要缓冲几个小时或几天。

早期版本的 rendezvous 服务器将这些消息保存在内存中。但是每次主机重新启动(例如,升级操作系统)时,这些消息都会丢失,此时任何等待的客户端都会失败。

为了解决这个问题,服务器被重写以将所有消息存储在 SQLite 数据库中。每次消息到达时,服务器做的第一件事就是将它附加到一个表中。一旦消息被安全地存储,副本就被转发给另一个客户端。Rendezvous对象包装了一个数据库连接,每个方法执行选择和插入。

正如下一节所描述的,客户端也被重新编写以允许丢失连接,状态机重新传输服务器没有确认的任何消息。

这项工作的一个有趣的副作用是它启用了一种“离线模式”:两个客户端可以交换消息,而不必同时连接。虽然这不能实现直接的文件交换操作,但它允许像为消息应用交换公钥这样的用例。

运输客户:可取消的延期

在计算出一个会话密钥后,虫洞客户端可以安全地通信,但是它们所有的数据仍然由会合服务器转发。这对于批量文件传输阶段来说太慢了:每个字节都必须上传到服务器,然后再返回到另一个客户机。使用直接连接会更快(也更便宜)。然而,有时客户端不能进行直接连接(例如,它们都在 NAT 盒后面),在这种情况下,它们必须使用“中转中继”服务器。中转客户端负责尽可能实现最佳连接。

如前所述,每个客户端打开一个侦听 TCP 端口,计算出它们的 IP 地址,然后将地址+端口发送到另一端(通过加密的集合通道)。为了适应未来的连接机制(可能是 WebRTC),这被概括为一组各种类型的“连接提示”。当前客户端识别三种提示:直接 TCP、中转 TCP 和 Tor 隐藏服务 TCP。每个提示都包含一个优先级,因此客户可以鼓励使用更便宜的连接。

双方都从高优先级提示开始,开始连接到它们能够识别的每个提示。任何使用中转中继的提示都会被延迟几秒钟,以支持直接连接。

完成协商过程的第一个连接将赢得比赛,此时我们使用defer.cancel()放弃所有失败者。这些可能仍在等待开始(处于强加于中继连接的两秒钟延迟中),或试图完成 DNS 解析,或已连接但等待协商完成。

延迟取消巧妙地处理了所有这些情况,因为它给了延迟的最初创建者一个机会来避免做一些现在无论如何都会被忽略的工作。如果这个延迟已经链接到另一个延迟,那么cancel()调用将遵循这个链,并被传递到尚未触发的第一个延迟。对我们来说,这意味着取消一个正在等待套接字连接的竞争者将会取消连接尝试。或者取消一个已连接但仍在等待连接握手的连接将会关闭该连接。

通过将过程的每一步都构建为另一个延迟,我们不需要跟踪这些步骤:一个简单的cancel()就可以做正确的事情。

我们用 src/wormhole/transit.py 中的实用函数来管理这种竞争:

class _ThereCanBeOnlyOne:
    """Accept a list of contender Deferreds, and return a summary Deferred. When the first contender fires successfully, cancel the rest and fire the summary with the winning contender's result. If all error, errback the summary.
    """
    def __init__ (self, contenders):
        self._remaining = set(contenders)
        self._winner_d = defer.Deferred(self._cancel)
        self._first_success = None
        self._first_failure = None
        self._have_winner = False
        self._fired = False

def _cancel(self, _):
    for d in list(self._remaining):
        d.cancel()
    # since that will errback everything in _remaining, we'll have
    # hit _maybe_done() and fired self._winner_d by this point

    def run(self):
        for d in list(self._remaining):
            d.addBoth(self._remove, d)
            d.addCallbacks(self._succeeded,self._failed)
            d.addCallback(self._maybe_done)
        return self._winner_d

    def _remove(self, res, d):
        self._remaining.remove(d)
        return res

    def _succeeded(self, res):
        self._have_winner = True
        self._first_success = res
        for d in list(self._remaining):
            d.cancel()

    def _failed(self, f):
        if self._first_failure is None:
            self._first_failure = f

    def _maybe_done(self, _):
        if self._remaining:
            return
        if self._fired:
            return self._fired = True
        if self._have_winner:
            self._winner_d.callback(self._first_success)
        else:
            self._winner_d.errback(self._first_failure)

def there_can_be_only_one(contenders):
    return _ThereCanBeOnlyOne(contenders).run()

这是作为函数而不是类公开的。我们需要将一个 Deferred 集合变成一个新的 Deferred,一个类构造函数只能返回新的实例(不是 Deferred)。如果我们将_ThereCanBeOnlyOne作为主 API 公开,调用者将被迫使用笨拙的d = ClassXYZ(args).run()语法(确切地说是我们隐藏在函数中的语法)。这将增加犯错误的机会:

  • 如果他们叫两次run()呢?

  • 如果他们子类化它呢?我们承诺的兼容性是什么样的?

注意,如果所有的竞争者延迟失败,那么延迟的摘要也将失败。在这种情况下,errback 函数将接收与第一个竞争者失败一起交付的任何失败实例。这里的想法是有效地报告共模故障。每个目标可能会表现为以下三种方式之一:

  • 成功连接(可能快也可能慢);

  • 失败是因为特定于目标的原因:它使用了我们无法到达的 IP 地址,或者网络过滤器阻止了数据包;

  • 失败的原因不是特定的目标,例如,我们甚至没有连接到互联网;

如果我们是后一种情况,所有的连接失败都是一样的,所以报告哪一个并不重要。记录第一次应该足以让用户找出哪里出错了。

中转中继服务器

中转继电器的代码在magic-wormhole-transit-relay包中。它目前使用自定义的 TCP 协议,但我希望添加一个 WebSockets 接口,使基于浏览器的客户端也能使用它。

中继的核心是一个协议,它将成对的实例(每个客户端一个)链接在一起。每个实例都有一个“伙伴”,每次数据到达时,都将相同的数据写到伙伴中:

class TransitConnection(protocol.Protocol):
    def dataReceived(self, data):
        if self._sent_ok:
            self._total_sent += len(data)
            self._buddy.transport.write(data)
            return
        ...

    def buddy_connected(self, them):
        self._buddy = them
        ...
        # Connect the two as a producer/consumer pair. We use streaming=True,
        # so this expects the IPushProducer interface, and uses
        # pauseProducing() to throttle, and resumeProducing() to unthrottle.
        self._buddy.transport.registerProducer(self.transport,True)
        # The Transit object calls buddy_connected() on both protocols, so
        # there will be two producer/consumer pairs.

    def buddy_disconnected(self):
        self._buddy = None
        self.transport.loseConnection()

    def connectionLost(self, reason):
        if self._buddy:
            self._buddy.buddy_disconnected()
        ...

代码的其余部分与准确识别哪些连接应该配对在一起有关。传输客户端一连接就写一个握手字符串,中继寻找写了相同握手的两个客户端。dataReceived方法的其余部分实现了一个状态机,它等待握手到达,然后将其与其他连接进行比较以找到匹配。

当好友被链接时,我们在他们之间建立了生产者/消费者关系:Alice 的 TCP 传输被注册为 Bob 的生产者,反之亦然。当 Alice 的上行链路比 Bob 的下行链路快时,连接到 Bob 的TransitConnection的 TCP Transport就会填满。然后它将调用爱丽丝的Transport上的pauseProducing(),这将从反应器的可读列表中移除她的 TCP 套接字(直到resumeProducing()被调用)。这意味着中继在一段时间内不会从该套接字读取数据,导致内核的入站缓冲区填满,此时内核的 TCP 堆栈会缩小 TCP 窗口广告,通知 Alice 的计算机停止发送数据,直到它赶上。

最终结果是 Alice 观察到的传输速率没有超过 Bob 所能处理的。如果没有这种生产者/消费者链接,Alice 将以她连接允许的最快速度向中继写入数据,中继必须缓冲所有数据,直到 Bob 赶上。在我们添加这个之前,当人们向非常慢的接收者发送非常大的文件时,中继偶尔会耗尽内存。

虫洞客户端架构

在客户端,wormhole包提供了一个Wormhole库来通过服务器建立虫洞式连接,一个Transit库来建立加密的直接 TCP 连接(可能通过中继),以及一个命令行工具来驱动文件传输请求。大部分代码都在Wormhole库中。

Wormhole对象是用一个简单的工厂函数构建的,并且有一个基于延迟的 API 来分配一个虫洞代码,发现选择了什么代码,然后发送/接收消息:

import wormhole

@inlineCallbacks
def run():
    w = wormhole.create(appid, relay_url, reactor)
    w.allocate_code()
    code = yield w.get_code()
    print "wormhole code:", code
    w.send_message(b"outbound message")
    inbound = yield w.get_message()
    yield w.close()

我们使用一个create工厂函数,而不是类构造函数,来构建我们的虫洞对象。这让我们可以将实际的类保持私有,因此我们可以更改实现细节,而不会在将来导致兼容性的破坏。例如,实际上有两种虫洞物体。默认有一个基于延迟的接口,但是如果你传递一个可选的delegate=参数到create,你会得到一个替代的调用委托对象的接口,而不是触发一个延迟的。

使用一个反应器,而不是在内部导入一个,以允许调用应用控制使用哪种类型的反应器。这也使得单元测试更容易编写,因为我们可以传入一个假的反应器,例如,网络套接字被切断,或者我们可以显式控制时钟。

在内部,我们的Wormhole对象使用了十几个小型状态机,每个状态机负责连接和密钥协商过程的一小部分。例如,虫洞代码开头的短整数(4-bravado-waffle中的“4 ”)被称为铭牌,这些都是由一个单独的专用状态机分配、使用和释放的。同样,服务器托管着一个邮箱,两个客户端可以在这里交换消息:每个客户端都有一个状态机来管理这个邮箱的视图,知道何时打开或关闭邮箱,并确保所有消息都在正确的时间发送。

延迟 vs 状态机,一次性观察器

虽然基本的消息流非常简单,但是完整的协议相当复杂。这种复杂性源于容忍连接失败(和随后的重新连接)以及服务器关闭(和随后的重新启动)的设计目标。

客户端可能分配或保留的每个资源都必须在正确的时间释放。因此,认领名牌和邮箱的过程是经过精心设计的,尽管连接来来往往,但总是向前推进。

另一个设计目标使它变得更加复杂:使用该库的应用可以将它们的状态保存到磁盘,完全关闭,然后在稍后的时间重新启动,并从它们停止的地方继续。这是为一直在启动和关闭的消息应用设计的。为此,应用需要知道虫洞消息何时到达,以及如何序列化协议的状态(以及应用中的其他一切)。这样的应用必须使用委托 API。

对于数据流驱动的系统来说,延迟是一个很好的选择,在这种系统中,任何给定的动作都只能发生一次,但是它们很难序列化。对于可能前滚后滚的状态,或者可能发生多次的事件(更像是“流”接口),状态机可能更好。虫洞代码的早期版本使用了更多的延迟,并且更难处理连接丢失和重启。在当前版本中,延迟仅用于顶级 API。其他的都是状态机。

Wormhole对象使用了十几个互锁的状态机,所有这些都是用自动机实现的。Automat 本身不是 Twisted 的一部分,但它是由 Twisted 社区的成员编写的,它的第一个用例是 Twisted 的ClientService(这是一个维护到给定端点的连接的实用程序,在连接丢失时或连接过程失败时重新连接;魔法虫洞使用ClientService连接到会合服务器)。

作为一个具体的例子,图 7-5 显示了管理铭牌分配的分配器状态机。这些是由 rendezvous 服务器根据发送方的请求分配的(除非发送方和接收方已经离线决定了代码,在这种情况下,双方都直接将代码键入他们的客户端)。

在任何给定的时刻,到 rendezvous 服务器的连接要么建立,要么不建立,这两种状态之间的转换导致一个connectedlost消息被分派给大多数状态机,包括分配器。分配器保持在两种“空闲”状态之一(S0A空闲+断开,或S0B空闲+连接),直到/除非需要它。如果上级代码决定需要一个铭牌,它发送allocate事件。如果此时分配器已经连接,它会告诉 Rendezvous 连接器发送一个allocate消息(标有RC.tx_allocate的框),然后进入状态S1B,等待响应。当响应到达时(rx_allocated,它将选择组成剩余代码的随机字,通知Code状态机已经分配了一个(C.allocated()),并移动到终端S2: done状态。

在收到rx_allocated响应之前,我们无法知道请求是否被成功传递。因此,我们必须 1:确保在每次重新建立连接时重新传输请求;第二:确保请求是等幂的,这样服务器对两个或更多请求的反应就像对单个请求的反应一样。这确保了服务器在这两种情况下都能正常运行。

img/455189_1_En_7_Fig5_HTML.jpg

图 7-5

分配器状态机

在建立连接之前,我们可能会被要求分配一个铭牌。从S1AS1B的路径是在两种情况下allocate请求被传输的地方:在发现分配需求之前连接,在发送分配请求但是还没有听到响应之后重新连接。

这种模式出现在我们的大多数状态机中。对于更复杂的例子,查看铭牌或邮箱机器,它们在 rendezvous 服务器上创建或订阅一个命名通道。在这两种情况下,状态排成两列:左边是“断开”,右边是“连接”。列中的垂直位置表示我们到目前为止已经完成的工作(或者我们还需要做的工作)。失去联系让我们从右向左移动。建立连接使我们从左向右移动,通常发送一个新的请求消息(或重新发送一个较早的消息)。收到一个响应使我们向下移动,就像从一个更高层次的状态机得到完成某件事的指令一样。

顶层的 Boss 机器是状态机让位于延期者的地方。导入 magic wormhole 库的应用可以请求一个延迟,当一个重要事件发生时,这个延迟将被触发。例如,应用可以创建一个虫洞对象,并像这样分配代码:

from twisted.internet import reactor
from wormhole.cli.public_relay import RENDEZVOUS_RELAY
import wormhole

# set APPID to something application-specific

w = wormhole.create(APPID, RENDEZVOUS_RELAY, reactor)
w.allocate_code()
d = w.get_code()
def allocated_code(code):
    print("the wormhole code is:{}".format(code))
d.addCallback(allocated_code)

分配器状态机将allocated消息传递给代码机(C.allocated)。代码机会把代码交付给 Boss ( B.got_code),Boss 机会把代码交付给虫洞对象(W.got_code),虫洞对象会把代码交付给任何等待的 Deferreds(通过调用get_code())构造的)。

一次性观察者

以下摘自src/wormhole/wormhole.py的摘录显示了用于管理虫洞代码交付的“一次性观察者”模式,包括分配(如上所述)和交互输入:

@implementer(IWormhole, IDeferredWormhole)
class _DeferredWormhole(object):
    def __init__ (self):
        self._code = None
        self._code_observers = []
        self._observer_result = None
        ...

    def get_code(self):
        if self._observer_result is not None:
            return defer.fail(self._observer_result)
        if self._code is not None:
            return defer.succeed(self._code)
        d=defer.Deferred()
        self._code_observers.append(d)
        return d

    def got_code(self, code):
        self._code = code
        for d in self._code_observers:
            d.callback(code)
        self._code_observers[:] = []

    def closed(self, result):
        if isinstance(result,Exception):
            self._observer_result = failure.Failure(result)
        else:
            # pending Deferreds get an error
            self._observer_result = WormholeClosed(result)
        ...
        for d in self._code_observers:
            d.errback(self._observer_result)

可以被调用任意次。对于标准 CLI filetransfer 工具,发送客户端分配代码,并等待get_code()触发,以便它可以向用户显示代码(用户必须将代码口述给接收者)。接收客户端被告知代码(或者作为调用参数,或者通过交互式输入,在单词上用制表符补全),所以它不需要调用get_code()。其他应用可能有理由多次调用它。

我们希望所有这些查询得到相同的答案(或错误)。我们希望他们的回调链是独立的。

承诺/未来与延期

未来来自卡尔·休伊特的演员模型,Joule 和 E 等语言,以及其他早期的对象能力系统(在这些系统中,它们被称为承诺)。它们代表了一个不可用的值,但是(可能)最终会解决某个问题,或者可能会“中断”并且永远不会引用任何东西。

这让程序谈论尚不存在的事物。这可能看起来没有帮助,但是有很多有用的东西可以用尚不存在的东西来做。您可以安排工作在它们变得可用时发生,并且您可以将它们传递给能够自己安排这项工作的函数。在更高级的系统中,承诺管道让你发送消息一个承诺,如果那个承诺实际上完全存在于不同的计算机上,消息将追逐承诺到目标系统,这可以省去几次往返。一般来说,它们帮助程序员向编译器或解释器描述他们未来的意图,因此它可以更好地计划做什么。

deferred密切相关,但为 Twisted 所独有。它们更多的是作为回拨管理工具,而不是完全成熟的承诺。为了探究它们之间的区别,我们应该首先解释真正的承诺是如何工作的。

在 E 语言中,对象能力语言最充分地探索了承诺,有一个名为makePromiseResolverPair() ,的函数返回两个独立的对象:一个承诺和一个解析器。解决承诺的唯一方法是和解决者在一起,学习解决的唯一方法是和承诺在一起。该语言提供了一种特殊的语法,即“when”块,它允许程序员编写只有在承诺被解析为某个具体值后才会执行的代码。如果魔法虫洞是用 E 写的,get_code()方法会返回一个承诺,它会这样显示给用户:

p = w.get_code();
when (p) {
    writeln("The code is:", p);
}

由于对象能力社区和 TC39 标准组织之间相当大的重叠,现代 JavaScript (ES6)中的承诺是可用的。这些承诺没有任何等待解决的特殊语法,而是依赖于 JavaScript 方便的匿名函数(包括 ES6 中引入的箭头函数语法)。相应的 JavaScript 代码如下所示:

p=w.get_code();
p.then(code=>{console.log("The code is:",code);});

E 的承诺、JS 的承诺和 Twisted 的延期承诺之间的一个显著区别在于你如何将它们链接在一起。Javascript then()方法返回一个新的承诺,当回调函数结束时触发(如果回调返回一个中间承诺,那么在中间承诺触发之前then()承诺不会触发)。因此,给定一个“父”承诺,您可以像这样构建两个独立的处理链:

p=w.get_code();
function format_code(code){
    return slow_formatter_that_returns_a_promise(code);
}
p.then(format_code).then(formatted => {console.log(formatted);});
function notify_user(code){
    return display_box_and_wait_for_approval(code);
}
p.then(notify_user).then(approved => {console.log("code delivered!");});

在 JavaScript 中,这两个动作将“并行”运行,或者至少不会互相干扰。

另一方面,Twisted 的延迟创建了一个回调链,而没有创建额外的延迟。

d1=w.get_code()
d=d1.addCallback(format_code)
assert d1 is d # addCallback returns the same Deferred!

这看起来有点像 JavaScript 的“属性构造”模式,在 web 框架(例如 d3.js、jQuery)中很常见,它通过许多属性调用来构建对象:

s = d3.scale()
      .linear()
      .domain([0,100])
      .range([2,40]);

延迟的这种链接行为可能会导致意外,尤其是在尝试创建并行执行行时:

d1 = w.get_code()
d1.addCallback(format_code).addCallback(print_formatted)

# wrong!

d1.addCallback(notify_user).addCallback(log_delivery)

在那个例子中,notify_user只在 print_formatted结束后被调用,它不会被代码调用:取而代之的是print_formatted 返回的任何值。我们的编码模式(两行,每行以d1.addCallback开头)是骗人的。事实上,上面的代码完全等同于:

d1 = w.get_code()
d1.addCallback(format_code)
d1.addCallback(print_formatted)
d1.addCallback(notify_user) # even more obviously wrong!
d1.addCallback(log_delivery)

相反,我们需要一个新的延迟,它将以相同的值触发,但让我们建立一个新的执行链:

def fanout(parent_deferred, count):
    child_deferreds = [Deferred() for i in range(count)]
    def fire(result):
        for d in child_deferreds:
            d.callback(result)
    parent_deferred.addBoth(fire)
    return child_deferreds
d1 = w.get_code()
d2, d3 = fanout(d1,2)
d2.addCallback(format_code)
d2.addCallback(print_formatted)
d3.addCallback(notify_user)
d3.addCallback(log_delivery)

这已经够麻烦的了,在我的项目中,我通常会创建一个名为OneShotObserverList的实用程序类。这个“观察者”有一个when_fired()方法(返回一个新的、独立的延迟)和一个fire()方法(触发所有方法)。when_fired()可在fire()之前或之后调用。

上面引用的魔法虫洞代码(get_code() / got_code())是完整OneShotObserverList的子集。连接过程有几种可能失败的方式,但是它们都用一个失败实例调用closed()(一个成功的/有意的关闭将调用没有失败的closed(),然后它被包装在一个WormholeClosed异常中)。这段代码确保了由get_code()返回的每个延迟将被触发一次,要么成功(和代码),要么失败。

最终发送,同步测试

来自 E 和对象能力社区的承诺的另一个方面是最终发送。这是一个为事件循环的后续循环排队方法调用的工具。在 Twisted 中,这基本上是一个reactor.callLater(0, callable, argument)。在 E 和 JavaScript 中,承诺自动为它们的回调提供这种保证。

最终发送是避免大量排序风险的简单而可靠的方法。例如,想象一个通用的观察者模式(比上面描述的简单的OneShotObserverList拥有更多的功能):

class Observer:
    def __init__ (self):
        self.observers = set()
    def subscribe(self, callback):
        self.observers.add(callback)
    def unsubscribe(self, callback):
        self.observers.remove(callback)
    def publish(self, data):
        for ob in self.observers:
            ob(data)

现在,如果回调函数之一调用subscribeunsubscribe,在循环中间修改观察器列表,会发生什么?根据迭代的工作方式,新添加的回调可能会接收到当前事件,也可能不会。在 Java 中,迭代器甚至可能抛出一个ConcurrentModificationException

可重入性是另一个潜在的意外:如果某个回调向同一个观察者发布了一条新消息,那么在第一次调用仍在运行时,将会第二次调用publish函数,这可能违反了程序员可能做出的许多常见假设(尤其是如果函数在实例变量中保存状态)。最后,如果回调引发了一个异常,剩下的观察者会看到这个事件吗,还是会被绕过?

这些意外的相互作用被统称为“计划协调风险”,其后果包括事件丢失、事件重复、不确定的排序和无限循环。

精心的编程可以避免许多这样的失败模式:我们可以在迭代之前复制观察器列表,捕捉/丢弃回调中的异常,并使用标志来检测可重入调用。但是在每个调用中使用最终发送要简单得多,也更健壮:

def publish(self, data):
    for ob in self.observers:
        reactor.callLater(0, ob, data)

我已经在许多项目中成功地使用了这种方法(Foolscap,Tahoe-LAFS ),它清除了整类的错误。缺点是测试变得更加困难,因为最终发送的效果无法同步检查。此外,缺乏因果堆栈跟踪使得调试变得棘手:如果回调引发异常,回溯并不能弄清楚为什么调用那个函数。延期者也有类似的担忧,对此defer.setDebugging(True)函数可以有所帮助。

对于 Magic Wormhole,我一直在尝试使用同步单元测试来代替最终发送。

延迟异步测试

Twisted 有一个名为试验的单元测试系统,它通过提供处理延迟的专门方法来构建在 stdlib unittest包之上。最明显的特性是,测试用例可以返回一个 Deferred,测试运行程序将等待它触发,然后才宣布成功(或者允许下一个测试运行)。当与inlineCallbacks结合使用时,这使得测试某些事情以特定的顺序发生变得容易:

@inlineCallbacks
def test_allocate_default(self):
    w = wormhole.create(APPID,self.relayurl, reactor)
    w.allocate_code()
    code = yield w.get_code()
    mo = re.search(r"^\d+-\w+-\w+$", code)
    self.assert_(mo, code)
    # w.close() fails because we closed before connecting
    yield self.assertFailure(w.close(), LonelyError)

在该测试中,w.allocate_code()启动代码的分配,而w.get_code()返回一个延迟,它将最终触发完整的代码。在这之间,虫洞对象必须联系服务器并分配一个铭牌(测试在setUp()启动一个本地会合服务器,而不是依赖真实的服务器)。yield w.get_code()接受这个延迟,等待它完成,然后将结果分配给code,这样我们可以稍后测试它的结构。

当然,真正发生的是测试函数返回一个 Deferred 并返回到事件循环,然后在将来的某个时候,服务器的响应到达并导致函数从它停止的地方恢复。如果一个 bug 阻止了get_code()延迟被触发,测试将安静地等待两分钟(默认超时),然后声明一个错误。

self.assertFailure()子句接受一个延迟和一个异常类型列表(*args)。它等待延迟解决,然后要求它返回一个异常:如果延迟的.callback()被调用(即不是错误),assertFailure测试失败。如果延迟的.errback()被调用时出现错误类型的错误,那么它也不能通过测试。

对我们来说,这有三个目的。虫洞 API 要求你在完成后调用w.close(),而close返回一个延迟,当一切都完全关闭时触发。我们使用这个来避免移动到下一个测试,直到所有的东西都停止从上一个测试移动(所有的网络套接字都关闭,所有的定时器都已经退休),这也避免了从试验中触发“不干净的反应堆”错误。

这种延迟也为应用提供了一种发现连接错误的方法。在这个测试中,我们只运行一个客户端,所以没有人让它连接,延迟的close将被错误恢复为LonelyError。我们使用assertFailure来确保没有错误发生,这捕获了所有我们的单元测试被设计来寻找的常见编码错误,比如可能因为我们在某个地方拼错了一个方法而产生的NameError

第三个目的是防止整体考试不及格。在其他测试中,当虫洞成功连接时,我们在测试结束时使用一个简单的yield w.close()。但是在这种情况下,LonelyError errback 看起来像是 Trial 的问题,它会将测试标记为失败。使用assertFailure告诉 Trial 这个延迟失败是可以的,只要它以一种非常特殊的方式失败。

延迟同步测试

test_allocate_default实际上是一个集成测试,一次测试系统的多个部分(包括会合服务器和环回网络接口)。这些测试往往是彻底的,但有点慢。他们也不提供可预测的覆盖面。

等待延迟发生的测试(要么从测试中返回一个,要么在@inlineCallbacks函数中调用一个,要么调用assertFailure)意味着你不能完全确定那个事件什么时候会发生。当应用等待库做一些事情时,这种关注点的分离是很好的:触发回调的细节是库的工作,而不是应用。但是在单元测试期间,你应该确切地知道会发生什么。

Trial 提供了三个延迟管理工具,它们不会而不是等待延迟的触发:successResultOffailureResultOfassertNoResult。这些断言表明被延迟的当前处于特定状态,而不是等待转换发生。

它们通常与Mock类一起使用,以“进入”一些测试中的代码,在已知的时间引发特定的内部转换。

作为一个例子,我们将看看魔法虫洞的tor支持的测试。这个特性向命令行工具添加了一个参数,这使得所有连接都通过 Tor 守护进程进行路由,因此wormhole send --tor不会向 rendezvous 服务器(或接收方)泄露您的 IP 地址。寻找(或启动)一个合适的 Tor 守护进程的细节被封装在一个TorManager类中,并且依赖于外部的txtorcon库。我们可以用一个Mock来代替txtorcon,然后我们测试它上面的所有东西,以确保我们的TorManager代码像预期的那样运行。

这些测试测试了我们所有的 Tor 代码,而没有真正与一个真正的 Tor 守护进程对话(这显然是缓慢的、不可靠的和不可移植的)。他们通过假设txtorcon像宣传的那样工作来实现这一点。我们不断言txtorcon实际上做了什么:相反,我们记录并检查我们告诉txtorcon做的一切,然后我们模拟正确的txtorcon响应,并检查我们自己的代码对这些响应所做的一切。

最简单的测试是查看没有安装txtorcon时会发生什么:正常操作应该不会受到影响,但是试图使用--tor应该会导致错误消息。为了更容易模拟,tor_manager。py 模块通过将txtorcon变量设置为 None 来处理导入错误:

# tor_manager.py

try:
    import txtorcon
except ImportError:
    txtorcon = None

这个模块有一个get_tor()函数,它被定义为返回一个延迟,该延迟或者由一个TorManager对象触发,或者由一个NoTorError失败触发。它返回一个 Deferred,因为在正常使用中,它必须在发生任何事情之前建立到 Tor 控制端口的连接,这需要时间。但是在这个特定的例子中,我们知道它应该立即解决(用NoTorError,因为我们发现了ImportError,而没有等待任何东西。所以,测试看起来像这样:

from ..tor_manager import get_tor
class Tor(unittest.TestCase):
    def test_no_txtorcon(self):
        with mock.patch("wormhole.tor_manager.txtorcon",None):
            d = get_tor(None)
        self.failureResultOf(d, NoTorError)

mock.patch确保txtorcon变量为 None,即使txtorcon包在测试期间总是可导入的(我们的setup.pytxtorcon标记为[dev] extra 中的依赖项)。当我们的测试重新获得控制权时,get_tor()返回的 Deferred 已经处于 errback 状态。self.failureResultOf(d, *errortypes)断言给定的延迟已经失败,具有给定的错误类别之一。因为failureResultOf会立即测试延迟的,所以它会立即返回。我们的test_no_txtorcon不返回延期,也不使用@inlineCallbacks

一个类似的测试在get_tor()中进行前提条件检查。对于这个函数所做的每一个类型检查,我们通过一个调用来练习它。例如,launch_tor=参数是一个布尔标志,表示tor_manager是否应该生成 Tor 的一个新副本,或者尝试使用一个已存在的副本。如果我们传入一个不是TrueFalse的值,我们应该期待 Deferred 以TypeError触发:

def test_bad_args(self):
    d = get_tor(None, launch_tor="not boolean")
    f = self.failureResultOf(d,TypeError)
    self.assertEqual(str(f.value), "launch_tor= must be boolean")

整个测试同步运行,不需要等待任何延迟。像这样的测试集合在 11 毫秒内练习了tor_manager模块中的每一行和每一个分支。

另一个常见的测试是确保 Deferred 还没有被触发,因为我们还没有触发允许它触发的条件。这之后通常是一行触发事件的代码,然后是一个断言,表明被延迟的问题要么成功解决(具有某个特定的值),要么失败(具有某个特定的异常)。

magic wormhole Transit类管理用于批量数据传输的(希望是直接的)客户端到客户端 TCP 连接。每一端监听一个端口,并根据它可能拥有的每个 IP 地址(包括几个不太可能到达的本地地址)建立一个“连接提示”列表。然后,每一方同时启动与所有对等方提示的连接。第一个成功连接并执行正确握手的人被宣布为获胜者,所有其他人都被取消。

一个名为there_can_be_only_one()(前面描述过)的实用函数用于管理这种竞争。它接受许多单独的延迟,并返回一个在第一个延迟成功时触发的延迟。Twisted 有一些效用函数做类似的事情(DeferredList一直存在),但是我们需要一些东西来抵消所有失败的竞争者。

为了测试这一点,我们使用试验的assertNoResult(d)value = successResultOf(d)

特点:

class Highlander(unittest.TestCase):
    def test_one_winner(self):
        cancelled = set()
        contenders = [Deferred(lambda d, i=i: cancelled.add(i))
                      for i in range(5)]
        d = transit.there_can_be_only_one(contenders)
        self.assertNoResult(d)
        contenders[0].errback(ValueError())
        self.assertNoResult(d)
        contenders[1].errback(TypeError())
        self.assertNoResult(d)
        contenders[2].callback("yay")
        self.assertEqual(self.successResultOf(d),"yay")
        self.assertEqual(cancelled, set([3,4]))

在这个测试中,我们确保组合延迟没有立即触发,并且即使一些组件延迟失败,它也不会触发。当一个组件成员成功时,我们检查组合延迟是否以正确的值触发,以及剩余的竞争者是否已被取消。

successResultOf()failureResultOf()有一个问题:不能在同一个 Deferred 上多次调用它们,因为它们在内部给 Deferred 添加了一个回调,这会干扰任何后续的回调(包括对successResultOf的额外调用)。没有很好的理由这样做,但是如果您有一个检查延迟的状态的子例程,并且您多次使用该子例程,这可能会给您带来一些混乱。但是,assertNoResult可以随便叫多少次。

同步测试和最终发送

Twisted 的社区几年来一直朝着这种直接/嘲笑的风格发展。我最近才开始使用它,但是我对结果很满意:我的测试更快、更彻底、更有确定性。然而,我仍然感到困惑:使用最终发送有很多价值。在there_can_be_only_one()中,竞争者延迟大多独立于附加到结果的回调,但是我仍然担心错误,如果回调在事件循环的不同回合执行,我会感觉更舒服。

但是,如果不等待延期点火,任何涉及实际反应堆的东西都很难测试。因此,我正在寻找将这种即时测试风格与最终发送实用程序相结合的方法。

当我第一次开始使用 finally send 时,Glyph 看到了我用reactor.callLater(0, f)做的事情,他给我写了一个更好的版本,我们在 Foolscap 和 Tahoe-LAFS 都使用。它维护一个单独的回调队列,并且在任何给定时刻只有一个callLater未完成:如果有成千上万个活动调用,这将更有效,并且避免依赖reactor.callLater维护等值计时器的激活顺序。

他的eventually()的一个很好的特性是它带有一个名为flushEventualQueue()的特殊函数,这个函数反复循环队列,直到队列为空。这应该允许像这样编写测试:

class Highlander(unittest.TestCase):
    def test_one_winner(self):
        cancelled = set()
        contenders = [Deferred(lambda d, i=i: cancelled.add(i))
                      for i in range(5)]
        d = transit.there_can_be_only_one(contenders)
        flushEventualQueue()
        self.assertNoResult(d)
        contenders[0].errback(ValueError())
        flushEventualQueue()
        self.assertNoResult(d)
        contenders[1].errback(TypeError())
        flushEventualQueue()
        self.assertNoResult(d)
        contenders[2].callback("yay")
        flushEventualQueue()
        self.assertEqual(self.successResultOf(d),"yay")
        self.assertEqual(cancelled, set([3,4]))

缺点是flushEventualQueue依赖于最终发送管理器的单例实例,这存在使用环境反应器的所有问题。为了干净利落地处理这个问题,there_can_be_only_one()应该给这个管理器一个参数,就像现代 Twisted 的代码将反应器传递给需要它的函数,而不是直接导入一个。事实上,如果我们依赖于reactor.callLater(0),我们可以用一个Clock()实例测试这段代码,并手动循环时间来刷新队列。未来版本的代码可能会使用这种模式。

摘要

Magic Wormhole 是一个文件传输应用,具有强大的安全属性,其核心源于 SPAKE2 加密算法,并具有一个用于嵌入到其他应用中的库 API。它使用 Twisted 来管理多个并发的 TCP 连接,这通常可以在两个客户端之间实现快速的直接传输。Autobahn 库提供 WebSocket 连接,这将支持与未来基于浏览器的客户端兼容。测试套件使用 Twisted 实用函数来检查每个延迟的状态,因为它们在它们的操作阶段循环,允许快速同步测试。

参考

八、使用 WebSocket 将数据推送到浏览器和微服务

为什么选择 WebSocket?

WebSocket 一开始是 HTTP AJAX 请求的竞争对手。当我们需要来自浏览器的实时通信或来自服务器的数据推送时,它们成为长轮询或 comet 等遗留解决方案的良好替代方案。因为它们使用的是持久连接,没有报头,所以如果有大量小消息要交换,它们是最快、最轻的选择。

然而今天,HTTP2 正被越来越多的人采用,并且确实有持久的连接和数据推送。

那么为什么选择 WebSocket 呢?

首先,WebSocket APIs 的目标是应用代码,而不仅仅是服务器代码。因此,在所有实现中,您都可以挂钩连接生命周期、对断开连接做出反应、将数据附加到会话等。一个非常方便的功能,可以创建健壮的交互和愉快的用户体验。

然后,虽然 HTTP2 确实有压缩的头,但 WebSocket 根本没有头,这使得整个占用空间更低。事实上,HTTP2 实现甚至强制加密非敏感数据,而在 WebSocket 中,您可以选择何时何地使用机器资源,以及是否激活 SSL。

更有甚者,HTTP2 服务器倾向于使用 push 发送静态资源(CSS、images、JS 等。)到浏览器,但它通常不用于推送应用数据。这就是 WebSocket 大放异彩的地方:向用户推送通知、传播事件、发出变更信号。。。

然而,WebSocket 有一个奇怪的地方:它不与域名绑定,浏览器不需要任何特殊的设置来做 CORS。实际上,您可以在没有任何警告的情况下从网页连接到您计算机上的本地 WebSocket 服务器。它可以被视为一个优点,也可以被视为一个缺点,这取决于你需要做什么。

所有这些特性使 WebSocket 成为你的网站通知、聊天、交易、多人游戏或实时图表的绝佳工具。不用多说,您不必局限于此,因为您可以利用它作为所有组件之间的链接,并使它成为协调整个系统的通信层。

这意味着您的 web 服务器可以通过 WebSocket 与您的缓存进程或身份验证平台对话。或者您可以管理一群物联网 1 设备。毕竟,Raspberry Pi 拥有事实上的 Python 支持。

总的来说,WebSocket 现在是一个安全的赌注,因为它可以在大多数主流浏览器中使用,包括 IE10。据 caniuse.com 称,这大约占了 94%的市场份额。最坏的情况是,你可以为剩下的几个浏览器找到垫片。由于 WebSocket 和 HTTP 握手是兼容的,它可能在任何允许通过 HTTP 的网络上工作。您甚至可以在两种协议之间共享 80 和 443 端口。

网络插座和 Twisted

在服务器端,WebSocket 现在得到了流行语言的广泛支持,但是由于持久连接,它确实需要异步编程。由于您最终可能会同时连接许多客户端,线程可能不是编写 WebSocket 服务器的最佳解决方案。然而,异步 IO 是一个完美的选择;在这方面,Twisted 是一个受欢迎的平台。

更好的消息是,您可以在浏览器之外使用 WebSocket,这样您的服务器上的所有组件都可以实时地相互对话。这将允许您创建自己的微服务架构,将特性解耦以分布在更小的组件上,或者传播信息而不是查询中央数据库中的所有信息。

为了演示如何在 Twisted 的环境中受益于 WebSocket,我们将使用 Autobahn 生态系统。Autobahn 是 MIT 许可下的库集合,用不同的语言编写,允许你创建 WebSocket 客户端和服务器。它还附带了一个测试套件,用于检查任何 WebSocket 系统的标准遵从性水平。

还有更多。

当然,您可以使用 WebSocket 构建自己的通信约定;高速公路肯定会帮助你做到这一点。但是最终你会像其他人一样,重新发明一个(很可能是方形的)轮子。

事实上,WebSocket 用例可以大致分为两类:

  • 调用远程代码并获得结果。比如更好、更快、更轻便的 AJAX。嗯,这已经做了几十年了,它被称为“RPC”,用于远程过程调用。

  • 发送信息给系统的其他部分,有事情发生了。这里也一样,它实际上是一种非常常见的模式,通常称为“发布/订阅”,用于发布/订阅。

稍后,我们将详细介绍这对您意味着什么。但是现在,重要的部分是,正确地做到这一点需要大量设计良好的代码来处理序列化、身份验证、路由、错误处理和边缘情况。

了解到这一点,Autobahn 的作者决定为“WebSocket 应用消息传递协议” 2 创建一个更高级的协议,称为 WAMP。这是一个由 IANA[]注册的文档化的开放标准,如果您愿意,它基本上可以为您完成所有繁重的工作。

最棒的是,你可以在任何支持 WebSocket 的地方使用 WAMP,这意味着几乎可以在任何地方,做任何事情。不需要这里用 HTTP,那里用 MQTT,剩下的用 AMQP。一个协议来统治他们。更少的麻烦。

幸运的是,Python Autobahn 库使用 Twisted 同时支持原始 WebSocket 和 WAMP。这就是我们在这一章将要经历的。因此,在我们开始之前,安装 autobahn 包,例如使用 pip:

pip install autobahn[twisted]

像往常一样,建议您为此创建一个 Python 3 virtualenv。我们将在本章使用的 Autobahn 版本——17 . 10 . 1——无论如何都将与 Python 2.7 和 3.3+一起工作。它甚至可以在 PyPy 和 Jython 上运行,并支持 asyncio,以防您不想只使用 Twisted。当然,对于这一章,我们将坚持使用 Twisted,用 Python 3 举例。

由于 WebSocket 是一种有趣的网站前端技术,我们稍后将使用一点 JavaScript。然而,WebSocket 并不要求 web 是有用的,因为 web 本身就是一个在服务器进程之间进行通信的良好协议。

原始 WebSocket,从 Python 到 Python

来自网络世界的“hello world”,作为一个 echo 服务器,是我们首先要做的。虽然 Twisted 现在支持async / await构造,但我们将坚持使用协程来允许更广泛的 Python 3 支持。

下面是使用 autobahn 的 WebSocket echo 服务器的样子:

import uuid

from autobahn.twisted.websocket import (
    WebSocketServerProtocol,
    WebSocketServerFactory
)

class EchoServerProtocol(WebSocketServerProtocol):

    def onConnect(self, request):
        """Called when a client is connecting to us"""
        # Print the IP address of the client this protocol instance is serving
        print(u"Client connecting:{0}".format(request.peer))

    def onOpen(self):
        """Called when the WebSocket connection has been opened"""
        print(u"WebSocket connection open.")

    def onMessage(self, payload, isBinary):
        """Called for each WebSocket message received from this client

            Params:

               payload (str|bytes): the content of the message
               isBinary (bool): whether the message contains (False) encoded text
                              or non-textual data (True). Default is False.
        """
        # Simply prints any message we receive
        if isBinary:
            # This is a binary message and can contain pretty much anything.
            # Here we recreate the UUID from the bytes the client sent us.
            uid=uuid.UUID(bytes=payload)
            print(u"UUID received:{}".format(uid))
        else:
            # This is encoded text. Please note that it is NOT decoded for you,
            # isBinary is merely a courtesy flag manually set by the client
            # on each message. You must know the charset used (here utf8),
            # and call ".decode()" on the bytes object to get a string object.
           print(u"Text message received:{}".format(payload.decode( 'utf8')))

        # It's an echo server, so let's send back everything it receives
        self.sendMessage(payload, isBinary)

    def onClose(self, wasClean, code, reason):
        """Called when the WebSocket connection for this client closes

            Params:

                wasClean (bool): whether we were told the connection was going
                                to be closed or if it just happened.
                code (int): any code among WebSocketClientProtocol.CLOSE_*
                reason (str): a message stating the reason the connection
                              was closed, in plain English.
        """
        print(u"WebSocket connection closed:{0}".format(reason))

if __name__ == '__main__':

    from twisted.internet import reactor

    # The WebSocket protocol netloc is WS. So WebSocket URLs look exactly
    # like HTTP URLs, but replacing HTTP with WS.
    factory=WebSocketServerFactory(u"ws://127.0.0.1:9000")
    factory.protocol=EchoServerProtocol

    print(u"Listening on ws://127.0.0.1:9000")
    reactor.listenTCP(9000,  factory)
    reactor.run()

在终端中运行它,只需执行以下操作:

$ python echo_websocket_server.py
Listening on ws://127.0.0.1:9000

显然,假设“echo_websocket_server.py”是您给脚本起的名字。

下面是使用 autobahn 的 WebSocket echo 客户端的样子:

# coding: utf8

import uuid

from autobahn.twisted.util import sleep
from autobahn.twisted.websocket import (
    WebSocketClientProtocol,
    WebSocketClientFactory
)

from twisted.internet.defer import Deferred, inlineCallbacks

class EchoClientProtocol(WebSocketClientProtocol):

    def onConnect(self, response):
        # Print the server ip address we are connected to
        print(u"Server connected:{0}".format(response.peer))

    @inlineCallbacks
    def onOpen(self):

        print("WebSocket connection open.")

        # Send messages every second
        i=0
        while True:

            # Send a text message. You MUST encode it manually.
            self.sendMessage(u"© Hellø wørld{}!".format(i).encode('utf8'))
            # If you send non-text data, signal it by setting "isBinary". Here
            # we create a unique random ID, and send it as bytes.
            self.sendMessage(uuid.uuid4().bytes, isBinary=True)
            i+=1
            yield sleep(1)

    def onMessage(self, payload, isBinary):
        # Let's not convert the messages so you can see their raw form
        if isBinary:
            print(u"Binary message received:{!r}bytes".format(payload))
        else:
            print(u"Encoded text received:{!r}".format(payload))

    def onClose(self, wasClean, code, reason):
        print(u"WebSocket connection closed:{0}".format(reason))

if __name__ == '__main__':

    from twisted.internet import reactor

    factory=WebSocketClientFactory(u"ws://127.0.0.1:9000")
    factory.protocol=EchoClientProtocol

    reactor.connectTCP(u"127.0.0.1",9000, factory)
    reactor.run()

通过执行以下操作在第二个终端中运行代码:

python echo_websocket_client.py

在启动服务器之后运行客户机是很重要的,因为这些简单的例子不会实现复杂的连接检测或重新连接。

紧接着,您将在客户端控制台上看到类似这样的内容:

WebSocket connection open.
Encoded text received: b'\xc2\xa9 Hell\xc3\xb8 w\xc3\xb8rld 0 !'
Binary message received: b'\xecA\xd9u\xa3\xa1K\xc3\x95\xd5\xba~\x11ss\xa6' bytes
Encoded text received: b'\xc2\xa9 Hell\xc3\xb8 w\xc3\xb8rld 1 !'
Binary message received: b'\xb3NAv\xb3OOo\x97\xaf\xde\xeaD\xc8\x92F' bytes
Encoded text received: b'\xc2\xa9 Hell\xc3\xb8 w\xc3\xb8rld 2 !'
Binary message received: b'\xc7\xda\xb6h\xbd\xbaC\xe8\x84\x7f\xce:,\x15\xc4$' bytes
Encoded text received: b'\xc2\xa9 Hell\xc3\xb8 w\xc3\xb8rld 3 !'
Binary message received: b'qw\x8c@\xd3\x18D\xb7\xb90;\xee9Y\x91z' bytes

在服务器控制台上:

WebSocket connection open.
Text message received: © Hellø wørld 0 !
UUID received: d5b48566-4b20-4167-8c18-3c5b7199860b
Text message received: © Hellø wørld 1 !
UUID received: 3e1c0fe6-ba73-4cd4-b7ea-3288eab5d9f6
Text message received: © Hellø wørld 2 !
UUID received: 40c3678a-e5e4-4fce-9be8-6c354ded9cbc
Text message received: © Hellø wørld 3 !
UUID received: eda0c047-468b-464e-aa02-1242e99a1b57

这意味着服务器和客户端正在交换消息。

还请注意,在服务器示例中,我们只回答了消息。尽管如此,即使我们没有收到任何消息,也可以调用“self.sendMessage()”,从而将数据推送到客户端。

让我们确实这样做,但是用一个 web 例子。

原始 WebSocket,介于 Python 和 JavaScript 之间

将数据推送到浏览器是 WebSocket 的一个经典用例。我们有限的页面不允许我们展示传统的聊天例子。然而,任何聊天都需要标明有多少人在线。下面是一个简单的实现可能的样子。

首先,让我们创建一个 Python 服务器。

from autobahn.twisted.websocket import (
    WebSocketServerProtocol,
    WebSocketServerFactory
)

class SignalingServerProtocol(WebSocketServerProtocol):

    connected_clients=[]

    def onOpen(self):
        # Every time we receive a WebSocket connection, we store the
        # reference to the connected client in a class attribute
        # shared among all Protocol instances. It’s a naive implementation
        # but perfect as a simple example.
        self.connected_clients.append(self)
        self.broadcast(str(len(self.connected_clients)))

    def broadcast(self, message):
        """ Send a message to all connected clients

            Params:
                message (str): the message to send
        """
        for client in self.connected_clients:
            client.sendMessage(message.encode('utf8'))

    def onClose(self, wasClean, code, reason):
        # If a client disconnect, we remove the reference from the class
        # attribute.
        self.connected_clients.remove(self)
        self.broadcast(str(len(self.connected_clients)))

if __name__ == '__main__':

    from twisted.internet import reactor

    factory = WebSocketServerFactory(u"ws://127.0.0.1:9000")
    factory.protocol = SignalingServerProtocol

    print(u"Listening on ws://127.0.0.1:9000")
    reactor.listenTCP(9000, factory)
    reactor.run()

再次运行它:

python signaling_websocket_server.py

现在是 HTML + JS 部分:

<!DOCTYPEhtml> <html><head></head><body>

<h1>Connected users: <span id="count">...</span></h1>

// Short url to a CDN version of the autobahn.js lib
// Visit https://github.com/crossbario/autobahn-js
// for the real deal
<script src="http://goo.gl/1pfDD1"></script>

<script>

  /* If you are using an old browser, this part of the code may look
    different. This will work starting from IE11
    and will require vendor prefixes or shims in other cases.*/
  var sock = new WebSocket("ws://127.0.0.1:9000");

  /* Like with the Python version, you can then hook on sock.onopen() or
    sock.onclose() if you wish. But for this example with only need
    to react to receiving messages: */

 sock.onmessage = function(e){
   var span = document.getElementById('count');
   span.innerHTML=e.data;
  }

</script>
</body></html>

您所要做的就是在您的 web 浏览器中用这个 HTML 代码打开文件。

如果您在浏览器中打开这个文件,您将会看到一个页面,上面写着“连接的用户:x”,每当您打开一个新的标签页或者关闭一个标签页时,x 都会进行调整。

您会注意到,即使有严格的 CORS 策略的浏览器,比如 Google Chrome,也不会像对待 AJAX 请求那样阻止来自“file://”协议的连接。WebSocket 可以在任何具有远程或本地域名的上下文中工作,即使文件不是由 web 服务器提供的。

WAMP 提供更强大的 WebSocket

WebSocket 是一个简单而强大的工具;不过,水平还是挺低的。如果您使用 WebSocket 创建一个成熟的系统,您最终会编写:

  • 一种将两个消息配对的方法,模拟 HTTP 请求/响应循环。

  • 一些可交换的后端用于序列化,使用 JSON 或 msgpack,或者其他。

  • 管理错误的约定和调试错误的工作流。

  • 样板文件,用于广播消息,仅向一部分客户端广播。

  • 身份验证,以及将您的会话 ID 从 HTTP cookie/token 连接到 WebSocket 的东西。

  • 一个许可系统,让所有的客户不能做或看到一切。

你可能已经重写了一个非标准的、较少记载的、未经测试的 WAMP 的替代品。

WAMP 是所有这一切的答案,以一种干净的和被证明的方式。它运行在 WebSocket 之上,所以它共享了它的所有特性,继承了它的所有优点。它还增加了许多好处:

  • 您可以定义函数并在网络上公开声明它们。然后,任何客户端都可以从任何地方(是的,远程)调用这些函数并获得结果。这是 WAMP 的 RPC 部分,你可以把它看作是 AJAX 请求的替代品,或者更简单的 CORBA/XMLRPC/SOAP。

  • 您可以定义事件。一些代码可以从任何地方(再次,是的,远程)说“嘿,我对那个事件感兴趣”。现在,任何地方的另一个代码都可以说“嘿,它发生了”,所有感兴趣的客户端都会得到通知。这是 WAMP 的酒吧/酒馆部分,你可以像使用兔子一样更容易地使用它。

  • 所有错误都会自动通过网络传播。因此,如果您的客户机 X 调用客户机 Y 上的一个函数失败,您将在客户机 X 上得到错误。

  • 标识和认证是规范的一部分,可以融入您自己的 HTTP 会话机制。

  • 一切都有命名空间。您可以对它们进行过滤,使用通配符,设置权限,甚至添加负载平衡。

在这短短的一章中,我们不会看到大部分内容,但至少我会尝试让您体验一下 RPC 和 PUB/SUB 能为您做些什么。

WAMP 是一种路由协议,这意味着每次您进行 WAMP 调用时,它不会直接进入将处理它的代码。取而代之的是,它通过一个 WAMP 兼容的路由器,然后确保消息在适当的代码段之间来回分发。

从这个意义上说,WAMP 不是一个客户端-服务器架构:任何进行 WAMP 调用的代码都是客户端。因此,你的所有代码,包括网页、服务器上的进程、外部服务、任何说 WAMP 语的东西,都将成为客户端——或 WAMP 路由器——相互通信。

这使得 WAMP 路由器成为单点故障和潜在的性能瓶颈。幸运的是,参考实现 Crossbar.io 是一个健壮、快速的 Twisted 软件。这也意味着您可以用一个简单的 pip 命令来安装它,要运行我们的下一个示例,您需要这样做:

pip install crossbar

如果您使用的是 Windows,可能需要 win32api 依赖项。在这种情况下,在启动之前安装它。 3

命令栏现在应该对你可用 4 :

$ crossbar version
     __  __  __  __  __  __      __    __
    /  `|__)/  \/__`/__`|__) /\ |__) |/  \
    \__,|  \\__/.__/.__/|__)/~~\|  \.|\__/

 Crossbar.io      : 17.11.1 (Crossbar.io COMMUNITY)
   Autobahn       : 17.10.1 (with JSON, MessagePack, CBOR, UBJSON)
   Twisted        : 17.9.0-EPollReactor
   LMDB           : 0.93/lmdb-0.9.18
   Python         : 3.6.2/CPython
 OS               : Linux-4.4.0-98-generic-x86_64-with-Ubuntu-16.04-xenial
Machine           : x86_64
Release key       : RWT/n6IQ4dKesCP8YwwJiWH30ST8eq5D21ih4EFbJZazzsqEX6CmaT3k

Crossbar.io 戴着很多帽子,可以做这么多事情,所以需要一个配置文件来告诉你想做什么。谢天谢地,它可以自动生成一个基本的:

crossbar init

这将创建一个web.crossbar目录,以及一个README文件。你可以忽略,甚至删除webREADME。我们感兴趣的是已经为我们创造的.crossbar / config.json。您不需要修改它来运行这个例子,因为默认情况下它只是“允许一切”如果你打开它,你会发现大量的设置,如果没有上下文,将很难理解。要了解 WAMP 的基本情况,你不需要挖那么深,所以我们将继续。

我们清单上的下一步就是运行纵横制路由器。您需要在包含。纵横制目录:

$ crossbar start
2017-11-23T19:06:43+0200 [Controller  11424] New node key pair generated!
2017-11-23T19:06:43+0200 [Controller  11424] File permissions on node public key fixed!
2017-11-23T19:06:43+0200 [Controller  11424] File permissions on node private key fixed!
2017-11-23T19:06:43+0200 [Controller  11424]     __  __  __  __  __  __      __    __

2017-11-23T19:06:43+0200 [Controller  11424]    /  `|__)/  \/__`/__`|__) /\ |__) |/  \
2017-11-23T19:06:43+0200 [Controller  11424]    \__,|  \\__/.__/.__/|__)/~~\|  \.|\__/
2017-11-23T19:06:43+0200 [Controller  11424]
2017-11-23T19:06:43+0200 [Controller  11424] Version:   Crossbar.io COMMUNITY 17.11.1
2017-11-23T19:06:43+0200 [Controller  11424] Public Key: 81da0aa76f36d4de2abcd1ce5b238d00a
...

你可以把 Crossbar.io 想象成 Apache 或 Nginx:它是一个你配置然后运行的软件,其余的代码围绕它运行。Crossbar.io 实际上完全有能力成为静态 web 服务器、WSGI 服务器,甚至是进程管理器。但我们只是要利用它的 WAMP 功能。为此,您不需要做任何其他事情。让它在后台运行,专注于你客户的代码。

WAMP 的美妙之处在于客户们不需要互相认识。他们只需要知道路由器。默认情况下,它监听localhost:8080并定义一个名为realm1的“领域”(一组可以互相看到的客户端)。因此,我们使用路由器所要做的就是使用该信息连接到它。

为了说明 WAMP 客户不需要互相认识,或者说明您不再处于客户机/服务器体系结构中,在我们的第一个例子中,我将使用两个网页。

一页将有一个输入框和一个“总和”按钮。另一个是另一个输入字段,它声明一个sum()函数可用于远程调用。当您单击“sum”按钮时,它会将第一个输入的值发送到第二个页面,第二个页面会在收到的值和本地值上调用sum(),然后发送回结果。

无需编写任何服务器端代码

第一页,第一个客户:

<!DOCTYPEhtml> <html><head></head><body>

   <form name="sumForm"><input type="text"name="number"value="3"></form>

    <script src="http://goo.gl/1pfDD1"></script>

    <script>

    // Connection to the WAMP router
    var connection = new autobahn.Connection({
      url:"ws://127.0.0.1:8080/ws",
      realm:"realm1"
    });

    // Callback for when the connection is established
    connection.onopen = function (session,details){
      // We register a function under the name "sum", so that any WAMP
      // client on "realm1" can call it remotly. This is RPC.
      session.register('sum', function(a){
        // It's just a regular function, really. But the parameters and
        // return value must be serializable. By default to JSON.
        return parseInt(a) + parseInt(document.sumForm.number.value);
      });
    }

    // Start the connection
    connection.open();

  </script>
</body></html>

如果在 web 浏览器中打开包含此代码的文件,您会注意到 Crossbar.io 控制台记录了一些关于新连接的客户端的信息:

2017-11-23T20:11:41+0200 [Router 13613] session "5770155719510781" joined realm "realm1"

现在是第二个页面,另一个 JS 客户端:

<!DOCTYPEhtml> <html><head></head><body>

<form name="sumForm"method="post" >
  <input type="text"name="number"value="5">
  <button name="sumButton">Sum!</button>
  <span id="sumResult">...</span>

</form>

<script src="http://goo.gl/1pfDD1"></script>

<script>

  var connection = new autobahn.Connection({
    url:"ws://127.0.0.1:8080/ws",
    realm:"realm1"
  });

  connection.onopen = function (session,details){
    // When we submit the form (e.g: click on the button), call "sum()"
    // We don't need to know where "sum()" is declared or how it will run,
    // just that something exists under this name.
    document.sumForm.addEventListener('submit', function(e){
      e.preventDefault();
      // The first parameter is the namespace of the function. The second is
      // the arguments passed to the function. This returns a promise which
      // we use to set the value of our span when the results comes back
      session.call('sum',[document.sumForm.number.value]).then(
        function(result){
          document.getElementById('sumResult').innerHTML = result;
      });
    })
  }
  connection.open();

</script>

</body></html>

路由器再次做出反应。

你现在可以按“总和!”按钮,它将愉快地调用第二页中的代码,并几乎立即得到结果。当然,这也适用于 Python 和其他语言。显然,这个例子是一个基本的例子,没有考虑健壮性或安全性。但我希望你能了解大致情况。您可以使用这种机制(路由 RPC)在连接到路由器的任何服务器上的任何浏览器或任何进程上定义和调用代码。

现在 RPC 本身是有用的,但是它的小兄弟 PUB/SUB 本身是另一个很好的工具。为了演示它,我将添加一个 Python 客户机(它实际上位于 Crossbar 服务器上)。

这个 Python 客户机调查一个目录,每秒扫描其中的所有文件。对于在目录中找到的每个文件扩展名,它都会发送一个事件,其中包含所有匹配文件的列表。没用?也许吧。很酷?当然可以!

import os

from twisted.internet.defer import inlineCallbacks
from twisted.logger import Logger

from autobahn.twisted.util import sleep
from autobahn.twisted.wamp import ApplicationSession
from autobahn.twisted.wamp import ApplicationRunner

class DirectoryLister(ApplicationSession):

    log = Logger()

    @inlineCallbacks
    def onJoin(self, details):
        while True:

            # List files and group them by extension
            files = {}
            for f in os.listdir('.'):
                file, ext = os.path.splitext(f)
                if ext.strip():
                    files.setdefault(ext, []).append(f)

            # Send one event named "filewithext.xxx" for each file extension
            # with "xxx" being the extension. We attach the list of files
            # to the events so that every clients interested in the event
            # can get the file list.
            # This is the "publish" part of "PUB/SUB".
            for ext, names in files.items():
                # Note that there is no need to declare the event before
                # using it. You can publish events as you go.
                yield self.publish('filewithext' +ext , names)

            yield sleep(1)

# The ApplicationRunner will take care starting everything for us.

if __name__ == '__main__':
    runner=ApplicationRunner(url=u"ws://localhost:8080/ws", realm=u"realm1")
    print(u"Connecting to ws://localhost:8080/ws")
    runner.run(DirectoryLister)

像以前一样运行代码,使用:

python directory_lister.py

它将开始列出当前目录中的所有内容,并发布关于它找到的文件的事件。

现在我们需要一个客户来表明它对这些事件感兴趣。我们可以创建一个 Python 或者 JS。因为在 WAMP 一切都是客户端,所以让我们创建一个 JS 客户端来查看两种语言的客户端。

<!DOCTYPEhtml> <html><head></head><body>

  <div id="files">...</div>

  <script src="http://goo.gl/1pfDD1"></script>

  <script>

    // Connection to the WAMP router
    var connection = new autobahn.Connection({
      url:"ws://127.0.0.1:8080/ws",
      realm:"realm1"
    });

    connection.onopen = function (session,details){

      // Populate the HTML page with a list of files
      var div=document.getElementById('files');
      div.innerHTML="";
      function listFile(params,meta,event){
        var ul=document.getElementById(event.topic);
        if (!ul){
          div.innerHTML += "<ul id='" + event.topic + "'></ul>";
          ul=document.getElementById(event.topic);
        }
        ul.innerHTML="";
        params[0].forEach(function(f){
          ul.innerHTML += "<li>" + f + "</li>";
        })
      }

      // We tell the router we are interested in events with this name.
      // This is the "subscribe" part of "PUB/SUB".
      session.subscribe('filewithext.py',listFile);
      // Any client, like this Web page, can subscribe to an arbitrary number
      // of events. So here we say we are interested in events about files
      // with the ".py" extension and the ".txt" extension.
      session.subscribe('filewithext.txt',listFile);
    }

    connection.open();

  </script>
</body></html>

在我的目录中,我至少有一个扩展名为. py 的文件和一个扩展名为.html的文件:我的两个客户机。为了便于演示,我将在它们旁边创建一个名为empty.txt的空文本文件。这样我们每秒钟至少应该有三个事件。

如果您以网页形式打开它,您会注意到它会开始列出如下文件:

  • empty.txt

  • directory_lister.py

如果你添加或删除文件,你会看到实时的变化。如果您使用不同的订阅集创建一个新的 JS 客户机,它将显示不同的文件列表。

摘要

如你所料,我们只触及了 WebSocket、Twisted、Autobahn 和 WAMP 的皮毛。

试着编辑给定的例子,让它们做更多的事情,或者把它们组合起来,以了解正在发生的事情。为了更好地使用这段代码,您应该在其中添加一些日志记录。

对于 WebSocket 示例,在if __name__ == "__main__"部分,添加:

import sys
from twisted.python import log

log.startLogging(sys.stdout)
...

对于 WAMP 的例子,在应用会话类的主体中:

from twisted.logger import Logger
...

class TheAppClass(ApplicationSession):

    log=Logger()
    ...

如果你想进一步探索,这里有一些想法:

  • 将示例转换为使用async / await构造,以获得更现代的体验。

  • 尝试其他形式的消息,如流。

  • 通过利用自动连接或负载平衡为您的代码提供更高的可靠性(仅限 Twisted/ WAMP)。

  • 用另一种语言编写客户机:Java、C#、PHP。你有很多流行平台的 WebSocket 和 WAMP 客户端。

  • 寻找安全特性:SSL、认证、权限。。。他们很难建立,但相当坚实。

  • 了解更多 Crossbar.io(也是 Twisted 的):进程管理,WSGI 服务器,静态文件处理。你会对它能做的所有事情感到惊讶。

九、使用异步和 Twisted 的应用

从版本 3.4 开始,Python 实现中包含的asyncio包为异步、事件驱动的网络程序标准化了一套 API。除了提供自己的并发和网络原语之外,asyncio还指定了一个事件循环接口,为异步库和框架提供了一个公共标准。这个共享的衬底允许应用在同一个进程中同时使用 Twisted 和asyncio

在这一章中,我们将学习如何通过用treq编写一个简单的 HTTP 代理来用asyncio编写 Twisted 的 API,这是一个构建在 Twisted 之上的高级 HTTP 客户端;还有aiohttp,一个构建在asyncio之上的 HTTP 客户端和服务器库。

它的生态系统仍在进化。随着越来越多的人在更多的情况下使用asyncio,新的 API 被开发出来,习惯用法也被采用。因此,我们的 HTTP 代理是一个案例研究,而不是集成 Twisted 和asyncio的方法。我们将从介绍基本的和稳定的概念开始,这些概念支持两者之间的交叉兼容性,为将来集成asyncio及其库和 Twisted 铺平了道路。

核心概念

asyncio和 Twisted 共享许多设计和实现细节,部分原因是 Twisted 的社区参与了asyncio的开发。描述asyncio的《人教版 3156》取材于《人教版 3153》,后者又是 Twisted 开发团队的一名成员写的。因此,asyncio借用了 Twisted 的协议、传输、生产者和消费者,为 Twisted 程序员提供了一个熟悉的环境。

然而,这种共同的祖先在很大程度上与集成使用的库和使用 Twisted 的库的过程无关。相反,任何事件驱动框架所必需的两个概念形成了它们相遇的接口:承诺在值可用之前表示它们,以及事件循环调度 I/O

承诺

到目前为止,您已经熟悉了 Twisted 的Deferred s,它允许开发人员在业务逻辑和错误处理可用之前将它们与值关联起来。在计算机科学文献和其他社区中,Deferred s 通常被称为承诺。正如第二章所解释的,承诺通过外部化回调的组成而无需宿主语言的特殊支持,从而简化了事件驱动程序的开发。

asyncio的基础 promise 实现是它的asyncio.Future类。不像Deferred s,Future s 做而不是同步运行它们的回调;相反,Future.add_done_callback计划在事件循环的下一次迭代中运行回调。在 Python 3.4 或更高版本上运行时,比较以下示例中的Deferred s 和Future s 的行为:

>>> from twisted.defer import Deferred
>>> d = Deferred()
>>> d.addCallback(print)
<Deferred at 0x1234567890>
>>> d.callback("value")
>>> value
>>> from asyncio import Future
>>> f.add_done_callback(print)
>>> f.set_result("value")
>>>

Deferred.addCallbackFuture.add_done_callback都安排一个函数在相应的承诺抽象表示的值可用时运行。然而,Deferred.callback会立即运行所有相关的回调,而Future.set_result makes直到一个事件循环开始下一次迭代才会有进展。

一方面,这消除了存在于Deferred中的重入错误的可能性,因为所有的asyncio代码都可以假设添加回调不会导致它立即运行,即使Future已经有一个值。另一方面,所有的asyncio代码都必须通过事件循环来运行,这使得它的使用和设计都变得复杂。例如:上面我们命名为fFuture用什么事件循环来调度它的print回调?我们得看看asyncio的事件循环系统,以及它与 Twisted 的 reactor 有何不同才能回答这个问题。

事件循环

如第一章所述,Twisted 将其事件循环称为反应器。在第三章中,我们使用twisted.internet.task.react和 Twisted 应用框架来管理 feed aggregation 应用的反应器的创建和供应。这两种获得反应器的方法都比在应用代码中将其作为twisted.internet.reactor导入要好。这是因为反应堆的选择取决于它的使用环境;不同的平台提供了自己的 I/O 复用原语,所以运行在 macOS 上的 Twisted 应用应该使用kqueue,而运行在 Linux 上的应该使用epoll;测试可能更喜欢存根反应器实现,以最小化对共享操作资源的影响;而且,正如我们将看到的,应用可能希望通过在另一个事件循环上运行 Twisted 来将其与其他框架结合起来。导入反应器而不是接受它作为 callables 的参数的代码本身不能在反应器选择之前导入,这大大增加了它的使用复杂性。出于这个原因,Twisted 引入了像react这样的 API 来促进反应器上应用的参数化。

虽然 Twisted 必须开发新的 API 来管理反应器的选择和安装,但从一开始asyncio就包含了服务于此目的的事件循环策略asyncio包含一个默认策略,开发者可以用asyncio.set_event_loop_policy替换它,用asyncio.get_event_loop_policy检索它。

默认策略将事件循环绑定到线程;asyncio.get_event_loop返回当前线程的循环,必要时创建它,而asyncio.set_event_loop设置它。

这就是我们的示例Future如何将自己与事件循环相关联。asyncio.Future初始化器通过只有关键字的loop参数接受一个事件循环;如果保持NoneFutureasyncio.get_event_loop检索默认策略的当前循环。

从历史上看,asyncio期望它的用户在需要的地方显式地传递当前事件循环,结果是当函数在模块级别以下的任何地方被调用时,get_event_loop中的一个 bug 导致了意外的行为。然而,从 Python 3.5.3 开始,get_event_loop被设计成在回调中运行时可靠地返回运行事件循环。最近的asyncio代码支持get_event_loop,而不是通过调用堆栈传递或设置为实例变量的显式引用。

除了它们的普遍性之外,asyncio的事件循环在功能上也不同于 Twisted 的反应堆。例如,反应堆可以在其生命周期的特定时间点运行系统事件触发器。Twisted 经常管理资源,这些资源必须在任何应用代码运行之前分配,并在进程使用IReactorCore.addSystemEventTrigger关闭之前显式释放;例如,Twisted 的默认 DNS 解析器使用的线程池的生命周期通过一个shutdown事件触发器与反应器的生命周期联系在一起。在撰写本文时,asyncio的事件循环还没有对等的 API。

指导方针

由于asyncio.Future s 和 Twisted 的Deferred s 之间的差异以及两个库的事件循环之间的差异,在结合两者时,有必要遵循特定的指导原则。

  1. 总是在asyncio事件循环之上运行 Twisted reactor。

  2. 从 Twisted 调用asyncio代码时,用Deferred.fromFutureFuture s 转换为Deferred s。用asyncio.Task包装协程并将它们转换成DeferredFuture一样

  3. asyncio调用 Twisted 时,用Deferred.asFutureDeferred s 转换为Future s。将活动的asyncio事件循环作为该方法的参数传递。

第一条指导原则来自于这样一个事实,即IReactorCore的 API 大于asyncio的事件循环。然而,第二个和第三个需要熟悉asyncio的协同程序、FutureTask以及它们之间的区别。

我们在上面看到Future s 的功能等同于Deferred s。我们还在第二章中了解到协程——用async def定义的函数和方法——是一个语言特性;它们没有隐式地绑定到asyncio或 Twisted 或任何其他库。回想一下,协程可以await一个类似未来的对象,而Deferred是类似未来的对象,所以协程可以await一个Deferred

不出所料,asyncio.Futures也是类似未来的对象,所以协程也可以await它们。惯用的asyncio代码很少显式地创建Futuresawait,然而,更喜欢直接await其他协程。考虑以下示例:

>>> import asyncio
>>> from twisted.internet import defer, task, reactor
>>> aiosleep=asyncio.sleep(1.0, loop=asyncio.get_event_loop())
>>> txsleep=task.deferLater(reactor,1.0, lambda:None)
>>> asyncio.iscoroutine(aiosleep)
True
>>> isinstance(txsleep, defer.Deferred)
True

aiosleep是一个对象,它将暂停一个asyncio协程至少一秒钟,而txsleep对使用Deferred s 的 Twisted 代码做同样的事情。虽然txsleep像其他任何一个一样是一个Deferred,但aiosleep实际上是一个适用于其他协程的awaiting的协程。

像所有协程程序一样,必须被编辑才能取得任何进展。这使得它们不适合“启动并忘记”类型的后台操作,这种操作应该在解析一个值时不阻塞调用方。这与txsleep Deferred不同,它将在大约 1 秒后触发,不管它是否有任何回调或错误。

asyncioTask s 的形式提供解决方案。一个任务将一个协程包装在一个Futureawait s 中,由Future代表其创建者。Tasks允许asyncio.gather同时await多个协程。例如,下面的代码将只运行 4 秒钟,而不是 6 秒钟:

import asyncio

sleeps = asyncio.gather(asyncio.sleep(2), asyncio.sleep(4))
asyncio.get_event_loop().run_until_complete(sleeps)

Twisted 的Deferred s 可以与 asyncio 的Future s 用Deferred.fromFutureasFuture链接。使用asyncios Task创建 API,比如asyncio.AbstractEventLoop.create_taskasyncio.ensure_future,使得等待asyncio对象的协同程序能够通过DeferredFuture感知接口与 Twisted 进行互操作。

通过一个例子可以很好地解释如何让asyncio和 Twisted 合作。以下代码展示了我们的三个互操作性指导原则:

import asyncio
from twisted.internet import asyncioreactor
loop = asyncio.get_event_loop()
asyncioreactor.install(loop)
from twisted.internet import defer, task

originalFuture = asyncio.Future(loop=loop)
originalDeferred = defer.Deferred()
originalCoroutine = asyncio.sleep(3.0)

deferredFromFuture = defer.Deferred.fromFuture(originalFuture)
deferredFromFuture.addCallback(print,"from deferredFromFuture")
deferredFromCoroutine = defer.Deferred.fromFuture(
    loop.create_task(originalCoroutine))
deferredFromCoroutine.addCallback(print,"from deferredFromCoroutine")
futureFromDeferred = originalDeferred.asFuture(loop)
futureFromDeferred.add_done_callback(
    lambda result: print(result,"from futureFromDeferred"))

@task.react
def main(reactor):
    reactor.callLater(1.0, originalFuture.set_result, "1")
    reactor.callLater(2.0, originalDeferred.callback, "2")
    return deferredFromCoroutine

我们首先用asyncioreactor.install设置 Twisted 的asyncio反应器。这个函数接受一个asyncio事件循环作为它的参数,它将把 Twisted reactor 绑定到这个参数上。如上所述,asyncio.get_event_loop请求全局(在这种情况下是默认的)事件循环策略创建并缓存一个新的循环,以供稍后的get_event_loop调用检索。

originalFutureoriginalCoroutineoriginalDeferred代表我们将在Deferred s 之间转换的三种对象:一个Future,一个await s asyncio代码的协程,以及一个Deferred

接下来,我们通过Deferred.fromFuture类方法将originalFutureDeferred链接起来,并添加一个print调用作为对新Deferred的回调。记住回调的第一个参数是Deferred的结果,而其他参数会传递给addCallback

在将originalCoroutine传递给Deferred.fromFuture之前,我们必须用create_taskoriginalCoroutine包装在Task中;然而,在那之后,我们继续我们对deferredFromFuture所做的。

正如我们上面看到的,FuturesDeferreds不同,只有在一个asyncio事件循环运行时才会取得进展,asyncio在任何时候都可以有多个事件循环。因此,通过asFutureoriginalDeferredFuture相关联需要显式引用事件循环。在提供这个之后,我们安排在originalDeferredfutureFromDeferred解析为一个值时运行一个信息打印回调。这被Future.add_done_callback复杂化了,它只接受单参数回调。我们使用一个lambda来打印结果和信息性消息。

如果没有事件循环,这些对象都不会有任何进展,所以我们使用task.react来为我们运行反应器。我们安排originalFuture在至少一秒钟后解析到"1",而originalDeferred在至少两秒钟后解析到"2"。最后,当deferredFromCoroutineoriginalCoroutine完成时,我们终止反应堆。

运行该程序应该会产生以下输出:

1 from deferredFromFuture
<Future finished result="2"> from futureFromDeferred
None from deferredFromCoroutine

第一行对应于我们添加到deferredFromFutureprint回调,第二行对应于futureFromDeferred的回调(注意Future回调接收它们的Future作为它们的参数),第三行对应于deferredFromCoroutine的回调。

这个例子说明了集成asyncio所必需的三个准则,并且以一种抽象的方式 Twisted,很难应用于现实世界的问题。然而,正如我们所解释的,不可能给出更具体的仍然普遍适用的建议。但是既然我们现在知道了这些球员,我们可以通过一个案例研究来看看他们是如何一起表演的。

案例研究:使用 aiohttp 和 treq 的代理

aiohttp ( https://aiohttp.readthedocs.io )是运行在 Python 3.4 及更高版本上的asyncio的成熟 HTTP 客户端和服务器库。

treq,正如我们在第三章中看到的,是一个构建在 Twisted 之上的高级 HTTP 客户端库。

我们可以一起使用这些来构建一个简单的 HTTP 代理。配置为使用 HTTP 代理的客户端向它发送所有请求;然后,代理将这些请求转发给所需的目标,并将其响应发送回客户端。我们将使用aiohttp的服务器部分与客户端对话,使用treq代表客户端检索页面。

HTTP 代理用于过滤和缓存内容,以及传递 POSTs、put 和所有其他 HTTP 方法。当它只是将 GET 请求来回传递给客户机时,我们就认为我们的成功了!

让我们从运行 Twisted 下最简单的aiohttp服务器开始。用 Python 3.4 或更高版本的创建一个新的虚拟环境,安装aiohttp,Twisted,和treq,然后运行下面的程序:

import asyncio
from twisted.internet import asyncioreactor

asyncioreactor.install(asyncio.get_event_loop())

from aiohttp import web
from twisted.internet import defer, task

app = web.Application()

async def handle(request):
    return web.Response(text=str(request.url))

app.router.add_get('/{path:.*}', handle)

async def serve():
    runner = web.AppRunner(app)
    await runner.setup()
    site = web.TCPSite(runner, 'localhost',8000)
    await site.start()

def asDeferred(f):
    return defer.Deferred.fromFuture(asyncio.ensure_future(f))

@task.react
@defer.inlineCallbacks
def main(reactor):
    yield asDeferred(serve())
    yield defer.Deferred()

我们从安装asyncio Twisted reactor 并将其封装在缓存的事件循环中开始,就像我们在前面的例子中所做的那样。

接下来,我们导入aiohttp的 web 模块并构建一个Application,这是库提供的基本 web 应用抽象。我们添加一个正则表达式路由到它,匹配所有的 URL(.*),并设置句柄协程作为它的处理程序。这个协程接受一个代表客户机请求的aiohttp.web.Request实例作为它的参数,并返回它的 URL 作为响应。

serve协程构建了AppRunnerSite对象,这是设置我们的应用并将其绑定到网络端口所必需的。

我们的应用、它的处理程序和serve协程直接取自aiohttp的文档,如果我们根本不使用 Twisted,它们将保持不变。我们从安装asyncio反应堆开始的互操作是在task.react运行的main函数中实现的。像往常一样,这是一个Deferred,尽管这次它使用了inlineCallbacks。我们可以把它写成一个async def风格的协程,然后用ensureDeferred把它转换成一个Deferred;我们选择使用inlineCallbacks来展示不同的风格是如何互换使用的。

asDeferred助手函数接受协程或Future。然后,它使用asyncio.ensure_future to来确保它接收到的任何东西都成为Future;如果是协程,则计算为Task,如果是Future,则计算为同一个对象。然后可以将结果传递给Deferred.fromFuture

我们用这个把serve协程包在Deferred里,然后通过等待一个永远不会启动的Deferred来永远阻塞反应堆。

运行这个程序将在 Twisted 下运行我们简单的 URL 回显服务。在浏览器中访问http://localhost:8000将返回您用来访问它的 URL 添加路径元素,比如http://localhost:8000/a/b/c,会导致不同的 URL。

现在我们已经有了基本的东西,我们可以实现我们的代理了:

import asyncio
from twisted.internet import asyncioreactor

asyncioreactor.install(asyncio.get_event_loop())

from aiohttp import web
from twisted.internet import defer, task

app = web.Application()

async def handle(request):
    url=str(request.url)
    headers = Headers({k: request.headers.getall(k)
                       for k in request.headers})
    proxyResponse = await asFuture(treq.get(url, headers=headers))
    print("URL:", url,"code:", proxyResponse.code)
    response = web.StreamResponse(status=proxyResponse.code)
    for key, values in proxyResponse.headers.getAllRawHeaders():
        for value in values:
            response.headers.add(key.decode(), value.decode())
    await response.prepare(request)
    body = await asFuture(proxyResponse.content())
    await response.write(body)
    await response.write_eof()
    return response

app.router.add_get('/{path:.*}', handle)

async def serve():
    runner = web.AppRunner(app)
    await runner.setup()
    site = web.TCPSite(runner, 'localhost',8000)
    await site.start()

def asFuture(d):
    return d.asFuture(asyncio.get_event_loop())

def asDeferred(f):
    return defer.Deferred.fromFuture(asyncio.ensure_future(f))

@task.react @defer.inlineCallbacks
def main(reactor):
    yield asDeferred(serve())
    yield defer.Deferred()

上面的代码与我们的 miminal aiohttp实现有两处不同:函数handle和一个新的asFuture助手。

handle函数首先从客户机的请求中提取目标 URL。回想一下,HTTP 代理的客户端通过在请求行中提供完整的 URL 来指定它们的目标;aiohttp将这个解析后的表示作为request.url可用。

接下来,我们从aiohttp请求中恢复所有客户端的头值,并将它们转换成一个twisted.web.http_headers.Headers实例,这样它们就可以包含在出站treq请求中。HTTP 头可以是多值的,aiohttp用不区分大小写的多字典来处理这个问题;request.headers.getall(key)返回请求中该头关键字的所有值的列表。结果字典将键映射到它们的值列表,这与 Twisted 的Headers初始化器相匹配。注意aiohttp把头解码成文本,而 Twisted 的Headers是按字节工作的;幸运的是,Twisted 会自动将文本头键和值自动编码为字节。

一旦我们准备好了适合与treq一起使用的客户机头的副本,我们就发出 GET 请求。此时,asyncio事件循环正在调度我们的handle协程,所以无论我们做什么await都必须与asyncio兼容。然而,treq根据Deferred s 工作,这些可以被等待,但是当asyncio试图调度它们时会出错而失败。解决方案是将Deferred包装在与调度我们的handler的同一个事件循环相关联的Future中。

这正是asFuture助手所做的。因为我们在程序开始时用get_event_loop将我们的反应器绑定到一个全局事件循环,所有后续对get_event_loop的调用都将返回相同的循环。这包括在aiohttp内部的调用和在我们自己的代码内部的调用,这就是asFuture如何用正确的事件循环绑定封闭的Future

正如我们在例子中看到的,asyncio等待Futures完全按照 Twisted 的方式包装Deferreds,等待Deferreds自己。因此,我们的处理程序恢复并将treq响应对象分配给proxyResponse。此时,我们打印出一条消息,详细说明检索到的 URL 及其状态代码。

接下来,我们构造一个aiohttp.web.StreamResponse,并向它提供我们从目标 URL 接收到的相同状态代码,这样客户端将看到代理看到的相同代码。我们还颠倒了头翻译,将 Twisted 的Header键和值复制到我们的StreamResponse的头中。twisted.web.http_headers.Headers.getAllRawHeaders用字节表示头键和值,所以为了StreamResponse的缘故,我们必须解码它们。

然后,我们将带有StreamResponse.prepare的响应信封发送回客户端。剩下的就是接收和发送回主体,我们用treq的 Response 的content方法来完成;这又是一个Deferred,所以为了asyncio,我们必须用asFuture把它包起来。

下面是我们配置一个 web 浏览器作为 HTTP 代理并访问 http://twistedmatrix.com/ :时程序输出的摘录

URL: http://twistedmatrix.com/ code: 200
URL: http://twistedmatrix.com/trac/chrome/common/css/bootstrap.min.css code:200
URL: http://twistedmatrix.com/trac/chrome/common/css/trac.css code: 200
...

摘要

在这一章中,我们学习了如何在一个应用中编写 Twisted 和asyncio。因为两者共享承诺事件循环的核心概念,所以在asyncio之上 Twisted 运行是可能的。

使用asyncio和 Twisted 需要遵循以下三个准则:总是在asyncio的事件循环之上运行反应器;从 Twisted 状态调用asyncio时,用Deferred.asFutureFuture s 转换为Deferreds;从asyncio调用 Twisted 时与Deferred.fromFuture相反。

因为asyncio仍在发展中,所以不可能提供更具体的集成指南。相反,我们将所学应用于案例研究:一个简单的 GET-only HTTP 代理,带有aiohttptreq。虽然很小,但我们的代理与真实的应用非常相似,以至于我们学会了如何将这些准则付诸实践,并在 Python 的两个异步编程社区之间架起了桥梁。

十、Buildbot 和 Twisted

Buildbot 是一个自动化软件构建、测试和发布过程的框架。对于具有复杂和不寻常的构建、测试和发布需求的组织和项目来说,这是一个受欢迎的选择。该框架是高度可定制的,并附带“电池”,包括对许多版本控制系统、构建和测试框架以及状态显示的支持。由于 Buildbot 是用 Python 编写的,因此可以很容易地用关键组件的特定用途实现来扩展。我们将 Buildbot 与 Django 进行比较:它提供了构建复杂的定制应用的基础,但它不像 Joomla 或 WordPress 这样的工具那样简单。

Buildbot 的历史

布莱恩·华纳在 2000-2001 年写了 Buildbot 的前身,当时他在一家路由器公司工作。他厌倦了每天早上和同事们争论,他们把代码签入 CVS,这些代码可以在他们的 Solaris 机器上运行,但不能在他的 Linux 机器上运行。

它最初是闭源的,使用asyncorepickle来实现一个 RPC 系统,在这个系统中,工作人员驱动整个过程。中央 buildmaster 只接受来自工作人员的状态信息,并将其呈现在基于 web 的瀑布显示上。它是以 Mozilla 的“火绒箱”为蓝本的。

在寻找asyncore例子的过程中,布莱恩发现了 Twisted,并且发现它已经比较高级,成长很快。在 2002 年初离开路由器公司后,他构建了一个干净的 build 系统的重新实现,部分是作为学习 Twisted 的一种方式,结果变成了 Buildbot。

直到 2009 年左右,Buildbot 都没有数据库后端。在那之前,数据库很难部署,将数据直接存储在磁盘上并不少见,这似乎是一种高效的解决方案。一切都是小规模的:磁盘很快,网络很慢,一个“大”CI 应用只运行几十个并行构建。

从 2009 年开始,Mozilla 开始使用 Buildbot,组织的需求很快超过了这个简单的模型。几年之内,Mozilla 就拥有了数千名员工和 50 多名 buildmasters。为了支持这一点,他们让 Brian 添加了一个部分数据库后端,以允许 buildmasters 协调他们的工作。这个数据库实现不存储构建的结果——这些结果保留在各个构建大师的 pickle 文件中。

web 界面是完全同步的,呈现构建结果的静态 HTML 表示。因此,当 buildmaster 从数据库和 pickle 文件加载结果时,显示一些页面可能会阻塞它几分钟。在 Mozilla,仅仅是浏览一个“瀑布”页面就可能导致中断,所以访问这些页面是不被允许的。

大约就在这个时候,Dustin Mitchell 接管了项目的维护工作,并开始组织长期的工作来实现应用的现代化。随着 2016 年 10 月 Buildbot 0.9.0 的发布,这一努力取得了成功。该项目旨在将 Buildbot 改造成一个数据库支持的服务器应用,提供一个 HTTP API 并托管一个交互式前端 web 应用。在多主配置中,构建结果现在可以从任何主服务器获得,并随着来自工作者的结果而“实时”更新。HTTP API 支持与其他 CI 工具的集成,新的定义良好的异步接口支持第三方插件的开发。

九不是一个容易的项目——它花费了包括 Pierre Tardy、Tom Prince、Amber Yust 和 Mikhail Sobolev 在内的开发团队五年的辛勤工作。它还涉及到解决许多与异步 Python 相关的棘手问题,如本章其余部分所述。

Buildbot 的异步 Python 的演变

当 Brian 开始编写 Buildbot 时,Twisted 已经有了很好的协议支持,包括 Perspective Broker。它的反应堆和延期处理发展良好,并建立在坚实的理论基础上。然而,“异步”在主流软件开发中仍然是一个相对未知的概念,异步代码名副其实地被称为“Twisted 的 Python”

作为一个例子,让我们看看 Buildbot 的Builder.startBuild方法,它存在于 2005 年左右(后来被重写)。它依次执行两个异步操作,首先 ping 所选的 worker,然后调用该 worker 的startBuild方法。这是通过一系列实例方法实现的:

# buildbot/process/builder.py @ 41cdf5a

class SlaveBuilder(pb.Referenceable):
    def attached(self, slave, remote, commands):
        # ...
        d = self.remote.callRemote("setMaster",self)
        d.addErrback(self._attachFailure,"Builder.setMaster")
        d.addCallback(self._attached2)
        return d

    def _attached2(self, res):
        d = self.remote.callRemote("print","attached")
        d.addErrback(self._attachFailure,"Builder.print 'attached'")
        d.addCallback(self._attached3)
        return d

    def _attached3(self, res):
        # now we say they're really attached
        return self

    def _attachFailure(self, why, where):
        assert type(where) is str
        log.msg(where)
        log.err(why)
        return why

这种笨拙的语法需要小心地将变量穿过多个方法,使得控制流难以遵循,并且污染了方法名称空间。这导致了许多有趣的错误,未处理的错误神秘消失,或者回调以意想不到的顺序触发。涉及异步操作的条件和循环很难正确处理,因此,很难正确调试。

我们现在习惯于称函数为异步的(意味着它们返回延迟的)和同步的(意味着它们不返回延迟的)。在这些黑暗时代,区别并不那么明显,Buildbot 中有一些函数可以根据情况返回延迟值或立即值。不用说,这样的函数很难正确调用,并且被重构为严格的同步或异步。

随着 Twisted 的成熟,更重要的是,随着 Python 增加了额外的特性,如生成器、装饰器和 yield 表达式,情况逐渐得到了改善。Twisted 的deferredGenerator允许用普通的 Python 风格,用ifwhilefor语句来编写控制流。它的语法仍然很笨拙,需要三行代码来执行异步操作,如果省略其中任何一行,就会以令人费解的方式失败:

# buildbot/buildslave/base.py @ 8b4e7a9

class BotBase(service.MultiService):
    @defer.deferredGenerator
    def remote_setBuilderList(self, wanted):
        retval = {}
        # ...
        dl = defer.DeferredList([
            defer.maybeDeferred(self.builders[name].disownServiceParent)
            for name in to_remove])
        wfd = defer.waitForDeferred(dl)
        yield wfd
        wfd.getResult()
        # ...
        yield retval # return value

随着 Python 2.5 和yield表达式的引入,Twisted 实现了inlineCallbacks。这些类似于deferredGenerator,但是仅使用一行来执行异步操作:

# master/buildbot/data/buildrequests.py @ 8b4e7a9

class BuildRequestEndpoint(Db2DataMixin, base.Endpoint):
    @defer.inlineCallbacks
    def get(self, resultSpec, kwargs):
        buildrequest = yield self.master.db.buildrequests.getBuildRequest(kwargs['buildrequestid
        if buildrequest:
            defer.returnValue((yield self.db2data(buildrequest)))
        defer.returnValue(None)

这种方法要宽容得多,只是很容易忘记给出一个延期。这种错误导致异步操作与调用函数“并行”执行,并且通常不会导致任何问题,直到操作失败并且调用函数不受阻碍地继续执行。几个这样的潜在错误已经通过了广泛的测试,并在 Buildbot 版本中持续存在。

随着 Twisted 和 Buildbot 转向 Python 3,Python 的async/await语法将提供一种更自然的方式来编写异步 Python,尽管它不会解决被遗忘的await的问题。使用以下语法,上面的函数读起来更加自然:

class BuildRequestEndpoint(Db2DataMixin, base.Endpoint):
    async def get(self, resultSpec, kwargs):
         buildrequest = await self.master.db.buildrequests.getBuildRequest(kwargs['buildrequestid'])
         if buildrequest:
             return (await self.db2data(buildrequest))
         return None

历史上,异步 Python 仅用于性能关键的网络应用,大多数 Python 应用都是基于同步模型构建的。NodeJS 社区已经表明,标准化的、可互操作的异步可以带来一个由库、实用程序和框架组成的生机勃勃的生态系统,这些库、实用程序和框架可以自由组合。Python 现在有了async/awaitasyncio使得为 Twisted 编写的代码能够与为其他异步框架编写的代码进行互操作,促进了类似的增长。

迁移同步 API

在早期,Buildbot master 作为单个进程运行,并将其状态存储在磁盘上的 pickle 文件中。它同步读取和写入这些文件,因此主服务器中的大多数操作不涉及延迟。

大约在 2010 年,随着持续集成在软件开发社区中流行起来,Buildbot 安装开始增长,pickle 文件没有扩展。添加数据库后端的时候到了,我们面临一个选择:将所有这些状态函数转换为返回延迟,或者从主线程进行同步数据库调用,阻塞其他操作直到它们完成。第一个选项很吸引人,但是当一个函数被修改为返回一个 Deferred 时,那么每个调用它的函数也必须被修改为返回一个 Deferred,从而影响整个代码库。Buildbot 是一个框架,所以大多数安装都包含大量调用 Buildbot 函数的定制代码。让这些函数返回 Deferred 构成了一个突破性的改变,需要用户重写并重新测试他们的定制代码。

为了方便起见,我们决定在主线程上进行大多数数据库调用。大多数关于构建状态的数据——结果、步骤和日志——都留在磁盘上。虽然这使我们能够按时发布该特性,但它有可预见的性能问题。事实上,在像 Mozilla 这样的大型安装中,数据库查询可能会让主服务器停顿很长时间,以至于工作人员会超时,取消正在运行的构建,并尝试重新连接。

这种情况在 Buildbot 中的许多其他 API 中重复出现,因为我们向曾经简单且同步的代码添加了新功能。如果我们可以在没有任何兼容性要求的情况下重新开始,我们会让每个公开的 API 方法都是异步的,并在每次调用用户代码时接受延迟。

异步构建步骤

构建步骤很难做到异步。虽然 Buildbot 包含了许多针对常见任务的“固定”构建步骤,但我们也允许用户实现他们自己的步骤。当一个步骤执行时,这样的定制构建步骤调用许多方法来添加日志输出、更新状态等等。从历史上看,所有这些调用都是同步的,因为它们更新内存中的状态,然后刷新到磁盘。

Buildbot 0.9 消除了那些磁盘上的数据结构,现在将一切存储在数据库中。它还提供“实时”更新,因此在构建步骤完成之前缓存构建步骤的结果是不可取的。因此,所有更新状态的同步方法变成了异步——但是现有的定制构建步骤同步地调用它们!

我们解决这个问题的方法是不寻常的:定义“旧风格”(同步)和“新风格”的构建步骤,每一个都有不同的行为。当执行旧式构建步骤时,Buildbot 从这些方法中收集所有未处理的延迟,并且当该步骤完成时,等待直到所有延迟都触发。由于大多数方法都提供关于步骤进度的信息,调用方不期望任何返回值。我们添加了一个简单的方法来区分旧的和新的构建步骤实现,并且只激活旧步骤的兼容性机制。这个策略非常成功,对于少数失败的定制构建步骤,解决方案很简单:重写为一个新型的构建步骤。

在以“新”风格重写内置构建步骤之前,我们开发了这种兼容性机制。这为在以更可靠的新风格重写所有内置步骤之前测试和改进机制提供了机会。

Buildbot 的代码

Buildbot 对于异步应用来说并不常见。大多数这样的应用关注请求/响应周期,异步编程比基于线程的同步模型允许更高的并行度。另一方面,Buildbot 维护主服务器及其附属工作服务器之间的长期连接,并对这些工作服务器执行顺序操作。甚至接受来自工作线程的新连接的过程也涉及一系列复杂的操作,包括检查重复的工作线程、询问新工作线程的特性,以及设置它来执行构建。

构建这种应用的同步方法将涉及每个工作线程,以及任何其他服务对象(如调度程序或变更源)的线程。即使这种方法的安装规模不大,也可能有数千个线程,并伴随着所有的调度和并发问题。

异步实用程序

虽然 Twisted 提供了各种有用的异步工具,但 Buildbot 发现了一些这些工具不支持的行为。就像队列和锁支持构建同步、线程化的应用一样,这些工具也支持构建异步应用。

德本尼斯

一个生产规模的 Buildbot 主机可能正在与数百个工作人员通信,接收带有更新状态和日志数据的事件。这些事件通常很容易合并,例如,几行日志数据可以合并到一个块中,但必须及时处理,以支持实时日志记录和动态状态更新。

解决方法是“去抖动”这些事件,当几个事件快速连续发生时只调用一次处理程序。去抖动方法指定了一个延迟,并保证修饰方法在该时间段内至少被调用一次,但可以在该时间段内合并多个调用。

去抖动会导致间歇性错误,因为它允许一个方法在不再有意义的时候执行。例如,如果一个构建步骤已经被标记为完成,那么继续向该步骤添加日志行是没有意义的。为了避免这个问题,去抖动方法有一个“停止”方法,它将等待(异步)任何挂起的调用,从而支持干净的状态转换。

异步服务

由于 Buildbot 基于优秀的 Twisted 应用框架,这个框架提供了(除了其他特性之外)IServiceIServiceCollection接口,可以用来创建服务的层次结构。Buildbot 将 buildmaster 服务安排在这个层次结构的顶部,将 workers、change sources 等的管理器作为子服务添加。工人和变更源被添加为他们各自经理的子代。

这种设计对于 Buildbot 应用的结构至关重要:支持应用的启动和关闭。更重要的是,它允许 Buildbot 在运行时动态地重新配置自己。例如,如果修改配置以添加额外的工作者,则重新配置过程创建新的工作者服务,并将其作为工作者管理器的子代添加。

应用框架只有一个问题:startService是同步的。

因为我们有处理与数据库或消息队列对话的服务,所以应用框架正确序列化服务启动对我们来说至关重要。通过这种序列化,我们可以确保所有的工人、建筑工人等。,并在我们开始发布构建请求之前监听它们的请求消息队列。例如,当重新配置添加一个新员工时,必须将该员工添加到数据库中。在异步操作完成之前,工作线程还没有真正开始。

虽然初始化依赖可以被看作是服务依赖的正交问题,但是让startService异步对我们来说非常方便。

class AsyncMultiService(AsyncService, service.MultiService):

    def startService(self):
        service.Service.startService(self)
        dl = []
        # if a service attaches another service during the reconfiguration
        # then the service will be started twice, so we don't use iter, but rather
        # copy in a list
        for svc in list(self):
            # handle any deferreds, passing up errors and success
            dl.append(defer.maybeDeferred(svc.startService))
        return defer.gatherResults(dl, consumeErrors=True)
    [...]

Buildbot 添加了一个MultiService的子类AsyncMultiService,它支持其子服务中的异步startService方法。它处理添加和删除服务的边缘情况,这意味着addServicesetServiceParent、?? 和disownServiceParent也是异步的。

我们有幸重写了这个功能,因为我们控制了对addServicestartService的所有调用。如果不引入一个全新的、互不兼容的类层次结构,Twisted 本身很难做出这样的改变。

事实上,由于 Twisted 调用了顶级服务的startService方法,所以在这种情况下,需要小心处理异步行为。Buildbot 的顶层服务是BuildMaster,它的startService方法返回一个永不失败的 Deferred,使用一个try / except来捕捉任何错误并停止反应器。由于反应器在启动时尚未运行,startService开始等待反应器启动:

class BuildMaster(...):

    @defer.inlineCallbacks
    def startService(self):
        [...]
        # we want to wait until the reactor is running, so we can call
        # reactor.stop() for fatal errors
        d = defer.Deferred()
        self.reactor.callWhenRunning(d.callback, None)
        yield d

        startup_succeed = False
        try:
            [...]
        except:
            f = failure.Failure()
            log.err(f, 'while starting BuildMaster')
            self.reactor.stop()

我们的系统没有很好地处理对等服务之间的依赖关系。例如,WorkerManager依赖于MessageQueueConnector,但两者都是masterService的子节点。MessageQueueConnector管理外部支持的消息队列,在与代理的连接完成之前不能接受任何消息或注册请求。这种登记请求是WorkerManager所需要的。这两个服务并行启动,是同一个服务的子服务。到目前为止,这个问题已经通过乐观地对任何消息或注册请求进行排队直到连接被维持而得到解决。我们可以通过添加不同于服务层次结构的初始化依赖层来改进我们的系统。如果你想有一个高效和简单的界面,这种系统的设计并不容易做到,这不需要重写我们所有服务的所有startService

另一种设计,在 Twisted 16.1.0 中引入的ClientService类中使用的,是立即从startService返回,同时允许启动进程并行运行。这种设计要求服务启动不能失败,或者开发一些其他的传递失败的机制。Buildbot 依靠AsyncMultiService的直接错误行为来处理运行时重新配置,当新配置有错误时,它必须优雅地失败。对于ClientService,连接无限重试,因此启动过程永远不会真正失败,即使它永远不会真正完成。立即返回方法还需要仔细考虑在启动完成之前调用服务方法的情况,通常是通过保护每个方法一直等到启动完成。

LRU 高速缓存

缓存对于扩展任何应用都是至关重要的,Buildbot 也不例外。一种常见的高速缓存回收策略是最近最少使用(LRU),其中当新条目需要空间时,丢弃最近没有使用的高速缓存条目。当可以从缓存中的数据满足请求时,发生缓存“命中”;缓存“未命中”需要从其来源获取数据。

LRU 缓存很常见,PyPI 上有几个发行版可以实现它们。然而,当时它们都是同步的,并且是为在线程环境中使用而设计的。

在异步实现中,高速缓存未命中将涉及等待获取,并且对同一高速缓存条目的额外请求可能在等待期间到达。这些请求不应该触发额外的获取,而是应该等待相同的获取完成。这需要谨慎处理延迟,尤其是在错误处理方面。

可能的

在很多情况下,我们想调用某个函数,但不关心结果或它被调用的确切时间。在异步系统中,最好在当前反应器迭代完成后调用这些函数。这允许更公平地分配工作,反应器能够在调用函数之前处理其他事件。

一个简单的方法是调用reactor.callLater(0, callableForLater);这相当于节点的process.nextTick。然而,这具有难以测试的缺点。根据测试的时间安排,callableForLater可能无法在测试结束前完成,从而导致间歇性的测试失败。这种方法也无法处理来自callableForLater的任何异常或错误。

Buildbot 的buildbot.util.eventual.eventually包装reactor.callLater。它提供了一个额外的flushEventualQueue方法,测试可以用它来等待所有挂起的函数调用完成。它通过将错误记录到 Twisted 日志中来处理被调用函数中的错误。

与同步代码接口

与 JS 生态系统不同,异步不是 Python 中进行 I/O 操作的默认和唯一方式。Python 生态系统随着时间的推移而发展,有许多非常有用且经过深思熟虑的库,而且大多数都是同步的。作为一个集成工具,Buildbot 会喜欢使用所有这些库。

我们开发了几个最佳实践来从我们的异步核心使用这些同步库。

sqllcemy(SQL 语法)

SQLAlchemy 是一个著名的库,它将 SQL 抽象为 Python。它支持多种 SQL 方言,并使支持多种数据库后端变得更加容易。SQLAlchemy 提供了 Pythonic 化的 SQL 生成 DSL(域特定语言),这允许它存储和重用 SQL 片段,并且还自动处理必要的 SQL 注入保护。

截至目前,Buildbot 支持 SQLite、MySQL 和 PostgreSQL。

SQLAlchemy 有数据库连接池的概念;SQL 引擎将从一个请求到另一个请求重用它与数据库的连接。在 Buildbot 中,我们将这个连接池映射到一个threadpool,然后每个数据库操作都在一个线程内部操作。

我们所有的数据库操作都在一个专用的db模块中实现,并遵循相同的模式。

  • 数据库组件代码必须来自buildbot.db.base.DBConnectorComponent.

  • 每个公共方法都应该从异步代码中调用,并返回一个Deferred

  • 我们使用一个嵌套函数来访问 sync 代码中异步方法的 Python 范围,以避免传递参数。

  • 我们使用self.db.pool.do(..).从异步世界跳到同步世界

  • 我们总是在函数或方法的名字前面加上前缀thd,这意味着使用阻塞代码。

class StepsConnectorComponent(base.DBConnectorComponent):

    def getStep(self, stepid=None, buildid=None, number=None, name=None):
        # create shortcut handle to the database table
        tbl = self.db.model.steps

        # we precompute the query inside the mainthread to fast exit in case of error
        if stepid is not None:
            wc = (tbl.c.id == stepid)
        else:
            if buildid is None:
                return defer.fail(RuntimeError('must supply either stepid or buildid'))
            if number is not None:
                wc = (tbl.c.number == number)
            elif name is not None:
                wc = (tbl.c.name == name)
            else:
                return defer.fail(RuntimeError('must supply either number or name'))
            wc = wc & (tbl.c.buildid == buildid)

        # this function could appear in a profile, so better give it a meaningful name
        def thdGetStep(conn):
            q = self.db.model.steps.select(whereclause=wc)
            # the next line does sync IO and block. That is why we need to be in a threadpool.
            res = conn.execute(q)
            row = res.fetchone()

            rv = None
            if row:
                rv = self._stepdictFromRow(row) res.close()
            return rv
        return self.db.pool.do(thdGetStep)

要求

Buildbot 与之交互的许多工具都可以通过 HTTP API 进行控制。和 Python 的urllib一样,Twisted 也有自己的 http 客户端库twisted.web.client。然而,优秀的python-requests图书馆被证明是非常好的制作。它有一个非常简单而强大的 API,强调约定胜于配置(因此有“人类的 HTTP”格言),连接池,keepalive,代理支持,以及重要的是确保自动化的可靠性,自动重试。

自然,Python 程序员会希望在 Buildbot 中使用类似的 API。但是 requests 是一个同步 API,因为人类喜欢同步。

有一个使用 Twisted client 实现 requests API 的treq库,但是它还不具备requests的所有可靠性特性。

最初,Buildbot 社区编写了txrequests库,这是一个简单的请求会话包装器,它在ThreadPool中发出每个请求,类似于我们对 SQLAlchemy 所做的。然后,Buildbot 实现了一个抽象请求 API 的HttpClientService,并允许选择treqtxrequests后端。

HTTPClientService实现了几个重要的特性,这是我们使用txrequests编写代码的经验的结果:它抽象了两个实现之间的差异,使用安装的任何一个。该服务包括一个单元测试框架,它允许我们测试我们的组件,而不依赖于一个假的 HTTP 服务器。它还支持组件之间共享会话,因此,例如,与 GitHub 接口的两个组件可以使用相同的 HTTP 会话。

class GitHubStatusPush(http.HttpStatusPushBase):

    @defer.inlineCallbacks
    def reconfigService(self, token, startDescription=None,
                        endDescription=None, context=None, baseURL=None, verbose=False,**kwargs):
        yield http.HttpStatusPushBase.reconfigService(self,**kwargs)

        [...]
        self._http = yield httpclientservice.HTTPClientService.getService(
            self.master, baseURL, headers={
                'Authorization': 'token ' + token,
                'User-Agent': 'Buildbot'
            },
            debug=self.debug, verify=self.verify)
        self.verbose = verbose

    [...]
    def createStatus(self,
                     repo_user, repo_name, sha, state, target_url=None,
                     context=None, issue=None, description=None):
        payload = {'state': state}

        if description is not None:
            payload['description'] = description

        if target_url is not None:
            payload['target_url'] = target_url

        if context is not None:
            payload['context'] = context

        return self._http.post(
            '/'.join(['/repos', repo_user, repo_name, 'statuses', sha]),
            json=payload)
    [...]

class TestGitHubStatusPush(unittest.TestCase, ReporterTestMixin):
    [...]
    @defer.inlineCallbacks
    def setUp(self):
        self.master = fakemaster.make_master(testcase=self,
                                             wantData=True, wantDb=True, wantMq=True)

        yield self.master.startService()
        # getFakeService will patch the HTTPClientService, and make sure any
        # further HTTPClientService configuration will have same arguments.
        self._http = yield fakehttpclientservice.HTTPClientService.getFakeService(
            self.master,self,
            HOSTED_BASE_URL, headers={
                'Authorization': 'token XXYYZZ',
                'User-Agent': 'Buildbot'
            },
            debug=None, verify=None)
        self.sp = GitHubStatusPush('XXYYZZ')
        yield self.sp.setServiceParent(self.master)
    @defer.inlineCallbacks
    def test_basic(self):
        build = yield self.setupBuildResults(SUCCESS)
        # we make sure proper calls to txrequests have been made
        self._http.expect(
            'post',
            '/repos/buildbot/buildbot/statuses/d34db33fd43db33f',
            json={'state': 'pending',
                  'target_url': 'http://localhost:8080/#builders/79/builds/0',
                  'description': 'Build started.', 'context': 'buildbot/Builder0'})
    # this will eventually make a http request, which will be checked against expectations
    self.sp.buildFinished(build)

Docker 工人

我们使用的库的另一个例子是官方的 Python docker 库。这是另一个同步库,它利用python - requests来实现 Docker HTTP 协议。

Docker 协议很复杂,可能会经常改变,所以我们决定不使用我们的HTTPClientService框架定制客户端。但是官方的 Docker API 库是同步的,所以我们需要以一种不会阻塞主线程的方式包装它。

我们只是使用了twisted.internet.threads.deferToThread来实现这个包装。这个实用函数使用默认的共享线程池,这是 Twisted 自动管理的。

class DockerBaseWorker(AbstractLatentWorker): [...]
    def stop_instance(self, fast=False):
        if self.instance is None:
            # be gentle. Something may just be trying to alert us that an
            # instance never attached, and it's because, somehow, we never
            # started.
            return defer.succeed(None)
        instance = self.instance
        self.instance = None
        return threads.deferToThread(self._thd_stop_instance, instance, fast)

    def _thd_stop_instance(self, instance, fast):
        docker_client = self._getDockerClient()
        log.msg('Stopping container %s... ' % instance[ 'Id'][:6])
        docker_client.stop(instance['Id'])
        if not fast:
            docker_client.wait(instance['Id'])
        docker_client.remove_container(instance['Id'], v=True, force=True)
        if self.image  is None:
            try:
                docker_client.remove_image(image=instance['image'])
            except docker.errors.APIError as e:
                log.msg('Error while removing the image: %s ', e)

对共享资源的并发访问

并发编程是一个困难的计算机科学领域,有许多陷阱。当您并行运行几个程序时,您需要确保它们不会同时处理相同的数据。使用 Twisted,很容易在两个不同的延迟链(或inlineCallbacks生成器或协程)中同时运行相同的函数。这个典型的问题叫做重入。当然,使用异步编程,函数不会真正同时运行两次。它在“反应器”线程中运行。因此,原则上,您可以对共享状态进行任何读-修改-写操作,而不必考虑并发性。

这是真的。。。直到达到以下限制:

作为并发障碍的让步

你可以把 Twisted 合理化为协作式多任务,直到你做了一些 I/O 操作。在那一点上,, yieldawait,d.addCallback()成为你的并发障碍。您需要注意不要修改这些语句之间的共享状态。

class MyClass(object):
    [...]
    # The following function cannot be called several times in parallel, as it will be modifying
    # self.data attribute between "yield"
    # It is not safe for reentrancy
    def unsafeFetchAllData(self, n):
        self.data = []
        for i in range(n):
            # during the yield, the context of the main thread could change up to the
            # point where the function is called again.
            current_data = yield self.fetchOneData(i)
            # BAD! modifying the shared state accross yield!
            self.data.append(current_data)

        # A correct implementation which does not involve locks is
        def safeFetchAllData(self, n):
            # we prepare the data in a local variable
            data = []
            for i in range(n):
                current_data = yield self.fetchOneData(i)
                data.append(current_data)
            # even if several fetchAllData is called several times in parallel, self.data will always be coherent.
            self.data = data

线程池函数不应该改变状态

有时,您需要进行一些繁重的计算或使用正在进行阻塞 I/O 的库。您通常希望在不同于“反应器”线程的帮助线程中进行这些操作,以避免在长时间的处理过程中挂起反应器。

因此,在使用线程时,您必须考虑保护您的共享状态免受并发访问。然而,为了避免使用任何类型的线程互斥,我们在 Buildbot 中遵循一个简单的规则。我们在非反应器线程中运行的所有函数或方法必须对应用状态没有副作用。相反,它们只通过函数参数和返回值与应用的其余部分进行通信。

from twisted.internet import defer
from twisted.internet import threads

class MyClass(object):
    [...]
    def unsafeFetchAllData(self, n):
        def thdfetchAllData():
            # BAD! modifying the shared state from a thread!
            self.data = []
            for i in range(n):
                with open("hugefile-{}.dat".format(i)) as f:
                    for line in f:
                        self.data.append(line)
        return threads.deferToThread(thdfetchAllData)

    @defer.inlineCallbacks
    def safeFetchAllData(self, n):
        def thdfetchAllData():
            data = []
            for i in range(n):
                with open("hugefile-{}.dat".format(i)) as f:
                    for line in f:
                        data.append(line)
            # we don't modify state, but rather pass the results to the main thread
            return data
        data = yield threads.deferToThread(thdfetchAllData)
        self.data = data

这个例子包括从大文件中加载数据,但是任何同步操作,或者任何没有异步库可用的操作,都将遵循相同的模式。

延期锁

根据我们的经验,遵循前面的两个最佳实践将使您避免 99%的并发问题。对于剩下的 1%,Twisted 有很好的并发原语。但是,在使用它们之前你应该三思,因为它经常隐藏设计问题。

  • 实现了一个信号量,这种情况下最多可以发生 N 个对同一资源的并发访问。

  • DeferredLock实现一个简单的锁。它相当于 N==1 的 a DeferredSemaphore,但具有更简单的实现。

  • DeferredQueue实现一个可以通过延迟读取的队列。

这些类的源代码很有启发性,值得一读。与它们的线程对应物不同,由于异步原理,实现非常简单。在它们缺少特性的情况下,用所需的特性来扩展或重新实现它们通常很简单。例如,DeferredQueue没有提供确定队列长度的方法,而队列长度是监控生产服务的一个关键特性。

测试

今天,自动化测试对于任何严肃的软件工程工作都是必要的,但是 15 年前不是这样,尤其是在开源世界。Buildbot、Jenkins 和 Travis-CI 等工具极大地改善了这种情况,现在很难找到一个开源库或应用不具备起码的基本测试。

Buildbot 的测试套件有着坎坷的历史。该应用的早期版本有一系列集成风格的测试,但是不可靠,难以理解,并且代码覆盖率很低。在某种程度上,这些被证明比它们的价值更麻烦,我们选择完全删除它们,并重新开始单元测试。此后,我们为一些现有代码编写了新的单元测试,但更重要的是,要求新的或重构的代码要有新的测试。经过几年的努力,Buildbot 的行覆盖率现在已经达到 90%左右,许多未经测试的代码只是为了向后兼容而被保留下来。这样的覆盖率对于像 Buildbot 这样的框架来说是至关重要的,因为在 build bot 中,没有一个单独的安装会使用哪怕是框架代码的一小部分。

Twisted 的测试框架,Trial 对于测试高度异步的代码库是不可或缺的。凭借多年的异步测试经验,Trial 的特性列表为异步测试框架设定了标准。

默认情况下,测试用例是异步的,这意味着它们可以返回一个延迟的。测试框架确保延迟的被等待,并在反应器基础设施的新实例中运行每个测试用例。Trial 还有SynchronousTestCase的概念,跳过反应器设置,运行速度更快。

未能处理延期是一个常见的错误。Trial 引入了“脏反应器”的原理,以便尝试并捕获某一类未处理的延迟。

例如,考虑以下代码:

@defer.inlineCallbcks
def writeRecord(self, record):
    db = yield self.getDbConnection()
    db.append(self.table, record) # BAD: forgotten yield

和伴随测试:

@defer.inlineCallbacks
def test_writeRecord(self):
    record = ('foo', 'bar')
    yield self.filer.writeRecord(record)

在测试延期完成后,试验将检查反应堆的未决 I/O 和定时器列表。如果append操作尚未完成,挂起的套接字读或写操作将导致DirtyReactor异常。在未处理的失败状态下被垃圾收集的任何延迟也将被标记为测试失败。不幸的是,如果未处理的操作在测试之前成功完成,则 Trial 无法检测到错误。这使得不干净的反应器错误时断时续,给用户和开发人员带来了一些挫折。

Python 3.5 的协程在语言中添加了一些特性,以更好地跟踪这种编程错误(RuntimeError: coroutine [...] was never awaited),但这些特性只适用于协程。

赝品

单元测试需要被测试单元的良好隔离。大多数 Buildbot 组件依赖于其他组件,包括数据库、消息队列和数据 API。Buildbot 中的约定是在每个服务对象上包含对作为self.masterBuildMaster实例的引用。其他对象则通过主对象的属性可用,如self.master.data.buildrequests。出于测试的目的,buildbot.test.fake.fakemaster.FakeMaster类定义了一个假的 master,它可以提供对一系列类似的假组件的访问。

许多这些假组件都是简单的虚拟类,用于测试。这种赝品的风险在于,它们不能忠实地再现真实元件的行为。对于小组件,这种风险通常很小,只要我们足够小心,我们就可以确信它们是正确的。

然而,数据库 API 是一个复杂的组件,有许多方法和复杂的交互。一种选择是总是对数据库进行测试——build bot 支持 SQLite,它内置于 Python 中,因此这对开发人员来说不是很大的负担。然而,即使是为每个测试建立一个内存数据库也是很慢的。相反,Buildbot 只使用简单的 Python 数据结构来展示 DB API 的完整实现。为了确保它对真实数据库 API 的保真度,它必须通过与真实实现相同的单元测试。结果是一个赝品,它保证为依赖于它的组件的单元测试提供可靠的结果——一个“经过验证的赝品”这个伪造品比生产代码更快,同时还提供了高度可靠的测试结果。

摘要

Buildbot 是一个大型、成熟的代码库,从早期开始就伴随着 Twisted 一起成长。它的历史展示了异步 Python 在过去十年中的历程——以及一些错误的转折。它的最新版本提供了大量实用的、真实的 Twisted 代码。

十一、Twisted 和 HTTP/2

介绍

HTTP/2 是作为几乎所有万维网基础的古老协议的最新修订版:超文本传输协议。HTTP 最初是由蒂姆·伯纳斯·李在 1989 年在 CERN(欧洲核研究组织)开发的,从那时起,它就一直是网络的引擎。该协议的主导地位是如此完整,以至于几乎所有大多数人认为是“互联网”的东西实际上都是万维网的一部分,因此使用 HTTP。

从本质上讲,HTTP 是一种允许浏览器与网站通信的协议。它为您的浏览器请求“资源”,如网页或图像,以及服务器提供这些资源作为响应,提供了一种正式的编码。它还支持上传数据。虽然它最常见的用途是网站,但 HTTP 也通常通过使用“web APIs”用于机器对机器的通信,这使程序员可以编写与存储在其他计算机上的数据进行交互的应用。你听说过的大多数大公司都运行 web API!

在早期,该协议经历了多次修订,但随着互联网工程任务组(IETF)于 1996 年发布 RFC 1945,该协议成为最常见的形式。这代表了该协议第一个长期版本的愿景,并确立了其众所周知的特性。这些包括它基于文本的、人类可读的性质;它依赖于定义良好的行为(如 GET、POST 和 DELETE)的动词字典;以及用于管理内容缓存的工具。HTTP/1.0 之后紧接着是 HTTP/1.1,这是一个增量版本,对协议的表达能力和效率进行了大量改进。HTTP/1.1 于 1997 年在 RFC 2068 中首次指定,并于 1999 年在著名的 RFC 2616 中更新。这个版本的 HTTP 在接下来的 15 年里几乎完全没有变化。所有在这个时代成熟的软件和服务都是建立在这个 90 年代的协议之上的。

不幸的是,HTTP/1.1 有许多缺点,使得它越来越不适合 2010 年代的网络。作为一个基于文本的协议,它非常冗长,需要传输比严格要求更多的字节。它也缺乏任何形式的多路复用, 2 意味着任何时候传输中的每个 HTTP 请求/响应对都需要一个专用的 TCP 连接,这将导致下面进一步探讨的问题。与大多数二进制协议相比,它解析起来也很复杂和缓慢。

这些缺点的结合导致 HTTP/1.1 连接在延迟、带宽和操作系统资源使用方面存在问题。这些担忧促使 Google 开始尝试 HTTP/1.1 的替代方案,这些方案保持了相同的语义,但使用了不同的网络格式来传输数据。在对这个名为 SPDY, 3 的实验性协议进行了几年的测试后,很明显,该协议为 HTTP/1.1 的许多问题提供了解决方案,IETF HTTP 工作组决定使用 SPDY 作为 HTTP 协议新修订版(版本 2)的基础。

HTTP/2 包含了对 HTTP/1.1 的许多改进。它将协议从基于文本改为使用带长度前缀的二进制帧流。它添加了一种特殊的压缩形式,适合与 HTTP 头一起使用,极大地减少了与给定的 HTTP 请求或响应相关的开销。它提供了多路复用和流控制,允许多个 HTTP 请求/响应对话发生在一个 TCP 连接上。最后,它增加了对协商扩展的明确支持,使 HTTP/2 在将来比 HTTP/1.1 更容易扩展。

自 2015 年被标准化以来,HTTP/2 变得极其成功。所有主流浏览器都像大多数主流 web 服务器一样支持它,它正迅速取代 HTTP/1.1 成为 web 上使用的主要协议。这种广泛的部署意味着开发人员希望能够在他们自己的应用中利用该协议,包括那些直接构建在 Twisted 上的应用。

Twisted 包含一个 HTTP 服务器。2016 年,我们开始扩展这个提供 HTTP/1.1 支持的 HTTP 服务器,以提供 HTTP/2 支持,该功能的初始版本于 2016 年 7 月在 Twisted 16.3 中发布。本章的其余部分将讨论这个实现是如何构建的,它的关键特性,并涵盖这个实现使用的几种有用的异步编程技术。

设计目标

Twisted 中的 HTTP/2 集成工作从一开始就有许多特定的设计目标。

无缝集成

HTTP/2 项目的第一个也是最重要的设计目标是尽可能无缝地与 Twisted 现有的 web 服务器集成,这是twisted.web的一部分。该项目的理想结果是现有的 Twisted Web 应用能够支持 HTTP/2,而无需修改任何代码。这将使现有的和新的 web 应用尽可能广泛地访问 HTTP/2,并且进入门槛极低。

令人高兴的是,HTTP/2 被设计成具有与 HTTP/1.1 相同的“语义”。这意味着任何有效的 HTTP/1.1 消息在 HTTP/2 中都必须至少有一个完全等价的表示。尽管在网络上发送的字节的具体排列是不同的,但是 HTTP 会话的抽象含义可以在 HTTP/1.1 和 HTTP/2 中准确地表达。这意味着,至少在原则上,允许twisted.web的用户透明地启用 HTTP/2 而无需任何代码更改是可能的。

在 Twisted 中,通过广泛使用接口来定义抽象层,这种“无缝”集成成为可能。接口是您可以在一系列相关对象上调用的函数的正式描述。例如,您可以像这样使用zope.interface描述一个“车辆”界面:

from zope.interface import interface

class IVehicle(Interface):
    def turn_on():
        pass

    def turn_off():
        pass

有了这个定义的接口,你就可以通过对接口而不是对特定的实现编程来编写可以操作任何类型车辆的程序。像这样的接口是一种形式的多态(面向对象编程中使用的一个术语),是基于类的继承的一种替代。本节将不再进一步探讨多态接口的概念,只是说为对象定义接口允许您编写代码,可以非常优雅地使用同一接口的替代实现。

在 HTTP 的情况下,原则上我们可以定义一组接口,用于在语义级别使用 HTTP(不参考特定的网络格式),并让用户针对这些接口编写代码。例如,你可以有一个HTTPServer接口,它公开了一个按照通用的HTTPRequestHTTPRespose对象来操作的接口,并且保护用户代码不受底层连接的特定属性的影响。

不幸的是,以这种方式定义接口并不总是容易做到的,并且在实践中遇到了许多困难,需要解决这些困难来实现这个设计目标。这些将在本章的后面部分详细介绍。然而,一旦解决了这些困难,我们就能够构建一个最终的实现,它几乎与现有的 HTTP/1.1 实现无缝结合。

最终结果是,从 Twisted 16.3 开始,任何使用twisted.web的应用都可以通过在安装或升级 Twisted 时安装可选的http2 extra 来获得自动 HTTP/2 支持。然后,Twisted 将从操作系统中检测所有相关的特性,假设一切正常,HTTP/2 将在可能的情况下自动使用。

默认情况下的最佳行为

HTTP/2 是一个复杂的协议,具有许多可调参数,这些参数会影响协议的效率。帧大小、优先级管理、压缩策略、并发流限制,甚至缓冲区大小都在调节协议效率方面发挥作用。

因为 Twisted 中的 HTTP/2 支持对用户是透明的,所以大多数用户很可能不会注意到它的存在。因此,协议在默认情况下尽可能高效地运行是至关重要的。这是因为如果用户不知道某个特性的存在,他们就不能合理地为他们的用例配置这个特性。

这是特性开发的一个一般经验,它是从前面的设计目标延续下来的:想要完全无缝和透明的特性也必须有适用于最广泛用例的合理缺省值。如果不这样做,用户将会在不知情的情况下体验到软件的次优行为,如果他们最终意识到这种行为,他们将不得不进行复杂的分析和调试来跟踪它。

由于这个原因,Twisted 的 HTTP/2 支持需要小心谨慎。默认配置需要在几乎所有情况下都能很好地执行,并且没有大量的开销,其最低目标是至少与 HTTP/1.1 实现一样好。否则这个特性最终会惩罚启用它的用户,使它完全没有价值。

分离关注点和重用代码

最后,也是最重要的,设计目标是避免重复发明太多轮子。设计网络应用时,一个重要的反模式是构建定制组件,而不是粘在已解决问题的已有实现中。当使用像 Twisted 这样的框架时,这尤其具有诱惑力,因为在集成预先存在的解决方案时,需要小心避免阻塞事件循环。这样做的原因是,用于避免阻塞事件循环的特定机制通常因框架而异,因此为每个框架编写定制代码非常诱人:这样做的代价是不可能跨多个框架重用大块代码。

幸运的是,Python 生态系统已经包含了一个“sans-io”HTTP/2 实现。这是一个可以用来解析和序列化 HTTP/2 协议的协议栈,但是它不理解任何关于 I/O 的东西。像这样的实现被设计成粘合到像 Twisted 这样的框架中,并且它们允许大量的代码重用。

这是网络编程中最重要的设计模式之一,因此值得重复:只要有可能,您应该努力将您的协议解析器与您的特定 I/O 实现分开。您的协议解析器应该只对内存中的字节缓冲区进行操作,无论是消费还是产生它们,并且应该没有从网络获取字节或向网络提供字节的机制。这种设计模式允许您更容易地将您的协议解析器从一种 I/O 模式移植到另一种模式,并且使测试和扩展您的协议解析器变得非常容易。

有了这个设计目标就改变了工作的性质。Twisted HTTP/2 实现处理 HTTP/2 协议中需要向网络写入字节和从网络写入字节的部分,设置和处理计时器,并将 HTTP/2 事件翻译成twisted.web接口。sans-io HTTP/2 实现负责将字节流解析成 HTTP/2 事件,并将来自twisted.web的函数调用转换成字节发出。

这种代码重用还允许花更多的时间来优化 Twisted 可以增加最大价值的实现部分。Twisted 的实现非常注重减少数据到达网络的延迟,有效地传播背压,以及减少不必要的系统调用或 I/O 开销。当核心协议逻辑被分解到一个单独的项目中时,这就容易多了。

一般来说,当处理“标准”问题时,这是最好的方法。它缩小了代码库的规模,避免了花费太多的工程时间来解决已经解决的问题,并允许您专注于提高解决方案的效率和可伸缩性。

实施问题

一旦确定了设计目标,就可以开始编写代码了。虽然对于许多开发人员来说,这是有趣的部分,但也经常会发生一些意想不到的事情。此外,我们经常会发现,在概念上讨论时,设计的某些方面非常简单,但是当它们被转化为代码时,就变得非常复杂了。本节涵盖了与具体实现相关的一些具体问题。

到底什么是联系?标准接口的价值

twisted.web,中,有许多对象合作实现 HTTP 支持。最简单的版本是将底层 TCP TransportHTTPChannelRequest对象联系起来。这种关系如图 11-1 所示。

img/455189_1_En_11_Fig1_HTML.jpg

图 11-1

三个最重要的对象 在 Twisted 中提供 HTTP 支持

在实现 HTTP/2 支持时,我们发现标准的 Twisted HTTP 请求处理程序(twisted.web.http.Request)期望以twisted.web.http.HTTPChannel的形式传递一个对 HTTP 连接处理对象的引用(或者类似接口的东西:令人沮丧的是,期望的接口从未被编译)。在Request的构造函数中,它进入了刚刚传递的通道,并取出了transport属性来保存它自己。所有后续调用Request.write写出响应体的操作都将被代理到transport.write。无论transport对象是什么,transport.write都会调用write函数。这个对象将实现twisted.internet.interfaces.ITransport:Twisted 中广泛使用的另一个zope.interface接口。在这种情况下,ITransport是一个特别常见的接口,用于表示任何类型的可写数据传输。这通常是诸如 TCP 之类的低级流协议,但实际上可以是提供流写接口的任何东西。在旧的 HTTP/1 模型中,这几乎总是底层的 TCP 传输。

对于 HTTP/1.1 来说,这种分层冲突最终会很好地工作,因为一旦发送了响应头,响应体就可以被视为任意的字节流。然而,这对于 HTTP/2 来说非常不适用:多路复用、优先级和流量控制都使得防止应用对 TCP 连接进行任意写入变得极其重要。

作为 HTTP/2 工作的一部分,我们需要清理它。然而,我们不能简单地移除这些属性:它们是Request的公共 API 的一部分,需要被保留。4

最直接的改变是让 HTTP/1.1 twisted.web.http.HTTPChannel对象成为ITransport的实现者,它将大多数方法代理到底层传输。这确保了HTTPChannel在封装自己的资源方面做得更好,因为它确保了用户不需要进入它内部来编写响应体,同时也解决了以前设计中的一些语义问题。本质上,HTTPChannel应该是响应的传输,而不是拥有一个可以在其中发送响应的传输的对象。当然,由于向后兼容策略,HTTPChannel不能删除它的transport属性,所以它没有真正封装传输,但是阻止它的使用是重要的第一步。

一旦这样做了,就可以将Request的内部实现改为对每个最初发送到传输层的调用使用HTTPChannel。本质上是自我的每一个实例。transport体内的一个Request法被改成了self.channel。这确保了 Twisted 的 HTTP 请求处理的默认实现现在适当地考虑了 TCP 连接和 HTTP 连接之间的预期抽象。

不幸的是,由于 Twisted 的兼容性政策,我们无法在这里创建一个干净的断点。已经存在大量使用 Twisted Web 创建的 HTTP/1.1 应用,其中一些不可避免地直接写入传输(或者处理传输,例如,到达并检索 TLS 证书)。出于这个原因,transport 属性不能从HTTPChannel中删除,并且还需要出现在作为 HTTP/2 等价物提供的任何对象上。

正如上一节所讨论的,HTTP/2 中的多路复用需要多个协作对象来提供所需的抽象。这也意味着有两个独立的对象一起提供与HTTPChannel相同的接口。Request只需要HTTPChannel接口的一个子集。出于兼容性目的,该部分接口被放置在H2Stream上。

由于Request需要从其渠道获得transport属性,H2Stream也需要一个transport属性。然而,HTTP/2 代码不需要像 HTTP/1.1 代码那样继续提供相同的抽象冲突:假设没有遗留 API 需求,它只需要能够访问属性。由于这个原因,所有的H2Stream对象都有一个总是被设置为Nonetransport属性。

这是一个很好的例子,如果在RequestHTTPChannel对象之间存在标准接口,情况会变得更容易。在最初创建时,没有预见到可能有必要让这些对象中的每一个都支持其伙伴对象的多种可能的实现,因此这两个对象之间使用的接口没有正式定义。缺少正式定义意味着这些对象的有效接口是它们的整个 API 表面:所有方法和所有属性。

当试图创建额外的抽象层时,这种宽泛且隐含的接口会导致巨大的困难。如果重新实现一个对象的人需要完全模拟它的整个公共 API,那么提供替代实现和构建适当的抽象就变得相当困难。

然而,从积极的一面来看,Request需要从HTTPChannel 那里得到的大部分有效接口都被ITransport的形式定义了。因为Request花费了大部分时间来编写HTTPChannel的传输,并且因为该传输只能被合理地假设为ITransport的实现者,所以很容易识别出哪些方法需要添加到HTTPChannel以及它们的行为应该是什么。一旦这样做了,识别需要呈现的有效 API H2Stream是什么就很简单了。

由于在 Twisted Web 的早期缺乏对可扩展性的关注,集成 HTTP/2 比需要的要困难。然而,情况可能会更糟:由于在 Twisted 的所有代码中广泛使用了接口,解决这些抽象违规问题比其他情况要容易得多。

这对未来的工程师来说应该是一个重要的教训:当系统被设计时,它很可能被设计成组件之间的高级接口。这些接口应该被编入代码中,因为它们提供了关于每个组件对其他组件的期望的非常有用的指导,并允许组件在未来进行更易处理的扩展和增强。

多路复用和优先级

HTTP/2 最复杂的部分之一是它的多路复用支持。引入 HTTP/2 的这个核心特性是为了允许多个 HTTP 请求/响应对使用同一个 TCP 连接,方法是在同一个 TCP 连接上同时发送和接收它们。与 HTTP/1.1 使用多个并发 TCP 连接相比,这种方法有许多优点:

  1. 它使用较少的系统资源。每个 TCP 连接占用客户机和服务器的操作系统中的一个文件描述符,这增加了两个操作系统跟踪网络连接所必须做的工作量。它还增加了内核和 Twisted 应用中使用的内存量,前者必须分配数据结构来跟踪连接,后者分配大量数据结构来管理每个传输。

  2. 它导致更好的吞吐量和更高的数据传输率。最广泛部署的 TCP 拥塞控制算法被设计为期望在任何时间任何两个主机之间不会有多于一个的 TCP 连接。两台主机之间有许多连接的结果,特别是如果它们都在传输大量数据(这是 web 上常见的一种使用模式),就是多个并发连接的吞吐量无法达到链路上的最大可能吞吐量。

  3. 它保持连接“更热”如果 TCP 连接长时间处于空闲状态,它们很容易被关闭(由中间体或任一对等体关闭),或者返回到“慢启动”状态,在这种状态下,先前关于链路拥塞的知识被丢弃。在任一情况下,当该连接开始被重用时,随着 TCP 慢启动阶段的进行,它将具有长时间的低吞吐量,并且如果该连接被关闭,它还将具有 TCP 和 TLS 握手的附加延迟。“热”连接,即持续或几乎持续使用的连接,避免了这两个问题,从而减少了延迟并提高了吞吐量。

在 HTTP/2 中,多路复用是通过将一个 HTTP/2 连接分成多个双向“流”来实现的。每个流都携带一个 HTTP 请求及其相关的响应。这非常简单,只需给每个流一个唯一的标识符,并确保属于该流的每个数据帧都携带该流标识符。这允许由 TCP 连接提供的单个有序数据流被分成多个逻辑数据流,如图 11-2 所示。

img/455189_1_En_11_Fig2_HTML.jpg

图 11-2

流是分散的数据块。它们可以以任何顺序交错。

然而,仅仅用适当的流标识符标记所有数据是不够的。为了解释为什么,考虑一个假想的网站作为云图片库会发生什么。这个网站有两个目的:它显示图像,它接受用户输入对它们进行修改。每个用户输入触发一个 API 请求/响应:此外,用户滚动或编辑将导致服务器向下流另一个图像文件。

API 请求/响应通常非常小:例如,它们可能是仅由几百个字节组成的 JSON 文档。图像的比例要大得多:可能有几兆字节。此外,生成图像不需要任何计算:它们存储在磁盘上,因此它们的数据随时可供网络服务器使用。

那么,一个可能的问题是,服务器可能会用图像流的数据完全填充 HTTP/2 连接,阻塞 API 响应的数据。API 响应构成了需要发送的数据的一小部分,但是该数据比图像数据具有更高的优先级。用户可能愿意等待缩略图加载,但他们不太可能愿意等待所有图像下载,直到他们看到他们的用户界面交互的效果。

大多数多路数据传输媒体都存在这个问题:我们如何确保最高优先级的数据尽快到达,同时确保连接总是得到最大限度的利用?对于这个问题有许多可能的解决方案,但是 HTTP/2 使用了一个方案,该方案涉及客户端设置流优先级

流优先级允许客户端通知服务器不同流上数据的相对重要性。这些数据的目的是让服务器决定如何将稀缺的资源分配给客户机发出的不同请求。一般来说,服务器必须分配的主要资源是带宽,但是更复杂的服务器也可以使用这些信息来分配 CPU 时间、文件描述符或磁盘空间:实际上是任何有限的资源。

最简单的流优先级方案是简单地给每个流分配一个数字优先级。编号较大的流比编号较小的流更重要,应该首先提供服务。由于缺乏表达能力,这种模式往往很难实现:虽然它允许您指出一些数据比其他数据更重要,但它严重地无法让您表达该数据有多重要。

最简单可行的方案是给每个流一个数字权重。这个权重反映了流的相对重要性:如果流 X 的权重是流 Y 的的两倍,那么服务的重要性大约是流 Y 的两倍。这种方法的优点是,它可以用于按比例分配资源:在前面的例子中,流 X 应该被分配流 Y 的两倍的资源。这允许客户端发出信号,表明他们认为获得对流 X 的及时响应比对流 Y 的及时响应更重要,以及他们认为这有多重要。

HTTP/2 的前身协议 SPDY 使用了这种简单的方法。然而,到了指定 HTTP/2 的时候,HTTP 工作组认为这种方法不够有表现力,并且遗漏了一些用例。特别是,它不允许客户端容易地表达约束“如果由于某种原因不能在流 B 上使用资源,则只在流 A 上使用它们。”换句话说,这允许客户端说“没有流 B 的结果,流 A 是没有价值的,所以不要在 A 上花费任何时间,除非流 B 由于某种原因被阻塞。”

由于这个原因,HTTP/2 有一个更复杂的优先级系统。这个系统允许客户指定一个优先级的,其中树中的每个节点依赖于它上面的父节点。这些优先级不影响“控制”数据,例如 HTTP 头:它们仅用于指示所请求资源的优先级。

对于 Twisted 的 web 服务器,我们很难分配上面讨论的大部分非带宽资源,因为我们对用户的应用没有足够的了解,无法准确知道我们应该问什么问题。结果只能把带宽分了。为了在 Twisted 中尽可能高效地做到这一点,我们对划分带宽做了一个简单的近似:我们划分帧。例如,如果流 A 和 B 的权重分别为 32 和 64,那么优先级算法的完美实现将为流 A 分配 1/3 的带宽,为流 B 分配另外的 2/3。准确地做到这一点将需要拆分用户对transport.write的每个调用中到达的数据,这将需要重复地将数据复制进和复制出缓冲区。如果没有用于此目的的高性能缓冲区,这种对内存的重复切片和复制是非常慢的(在开发时 Twisted 中没有这种功能,也不在本工作的范围内),这意味着我们要尽可能地避免它。

为了避免这种切片,我们可以保持数据被写入,而是给每个流一个数量等于其相对权重的。每当发送缓冲区中有空间容纳更多要发送的数据时,Twisted 实现将根据流权重检查哪些有数据要发送的流应该接着发送。然后,我们发送单个数据块,达到该流的最大帧尺寸 6 ,然后清洗并重复。这种基于帧的多路复用是网络协议设计中的常见模式,可以很容易地用于任意帧的协议。

该优先级树的构建和维护由第三方priority库处理。这个库构建并维护由客户端发送的优先级状态,并提供一个 iterable 来逐步指示 Twisted 实现接下来应该服务哪个流。它还包括来自 Twisted 应用的关于每个流是否有任何数据可发送的信息。没有数据要发送的流被视为阻塞,通常会分配给这些流的 TCP 连接部分会在作为子依赖项的流之间拆分。

围绕优先级树循环运行所有数据的需要给数据发送管道增加了一个难题,这在 HTTP/1.1 实现中是不存在的。对于 HTTP/1.1,响应数据的所有写入都可以直接传递给底层的 TCP 连接对象,该对象可以负责处理缓冲和发送数据。对于 HTTP/2,我们不想这样做,因为我们需要根据相对流优先级来交错写入。

更重要的是,实现需要对客户端发送的流优先级的变化做出响应:如果客户端增加了流的优先级,我们希望这能尽快反映在数据中。如果实现急切地将所有流数据写入 TCP 连接对象,可能会导致大量等待发送的数据缓冲区,这些缓冲区是根据旧的流优先级而不是新的流优先级来分配的。对于连接上的 TCP 吞吐量远低于 Twisted 应用中生成数据的速率的情况,这可能会导致在优先级变化反映到实际数据中之前出现数秒钟的延迟:这显然是不可接受的。

因此,Twisted HTTP/2 实现需要自己对数据进行内部缓冲,并异步发送数据给对transport.write的调用。这是通过重复使用IReactor .callLater来调度一个将发送最高优先级可用数据块的功能来实现的。

使用callLater允许我们通过注意来自 TCP 连接的反压力来避免发送缓冲区溢出(更多细节见下一节),并确保我们发送所有可用数据而不阻塞对write.的任何调用

数据发送函数的核心如下所示(为了清楚起见,去掉了错误处理和一些边缘情况):

class H2Connection:
    def _sendPrioritisedData(self, *args):
        stream = None

        while stream is None:
            try:
                stream = next(self.priority)
            except priority.DeadlockError:
                # All streams are currently blocked or not progressing. Wait
                # until a new one becomes available.
                self._sendingDeferred = Deferred()
                self._sendingDeferred.addCallback(self._sendPrioritisedData)
                return
        # Wait behind the transport. This is managed elsewhere in this class,
        # as part of the implementation of IPushProducer.
        if self._consumerBlocked is not None:
            self._consumerBlocked.addCallback(self._sendPrioritisedData)
            return

        remainingWindow = self.conn.local_flow_control_window(stream)
        frameData = self._outboundStreamQueues[stream].popleft()
        maxFrameSize = min(self.conn.max_outbound_frame_size, remainingWindow)

        if frameData is _END_STREAM_SENTINEL:
            # There's no error handling here even though this can throw
            # ProtocolError because we really shouldn't encounter this problem.
            # If we do, that's a nasty bug.
            self.conn.end_stream(stream)
            self.transport.write(self.conn.data_to_send())

            # Clean up the stream
            self._requestDone(stream)
        else:
            # Respect the max frame size.
            if len(frameData) > maxFrameSize:
                excessData = frameData[maxFrameSize:]
                frameData = frameData[:maxFrameSize]
                self._outboundStreamQueues[stream].appendleft(excessData)

            # If for whatever reason the max frame length is zero and so we
            # have no frame data to send, don't send any.
            if frameData:
                self.conn.send_data(stream, frameData)
                self.transport.write(self.conn.data_to_send())

            # If there's no data left, this stream is now blocked.
            if not self._outboundStreamQueues[stream]:
                self.priority.block(stream)

            # Also, if the stream's flow control window is exhausted, tell it
            # to stop.
            if self.remainingOutboundWindow(stream) <= 0:
                self.streams[stream].flowControlBlocked()
        self._reactor.callLater(0, self._sendPrioritisedData)

这个功能可以分为四个逻辑部分。第一个检查是否有任何被认为“能够进行”的流(也就是说,在它们的流控制窗口 7 中有可发送的数据和空间来发送它)。如果没有,那么我们就没有任何数据要发送,所以我们设置了一个Deferred,当一个流由于任何原因被解除阻塞时,它将被回调。

第二部分检查发送缓冲区中是否有空间。这是由Deferred发出的另一个信号:如果在self._consumerBlocked,中有一个Deferred,那么 Twisted 已经通知我们发送缓冲区已满,我们应该避免写入。同样,我们不做任何工作就返回,并确保当Deferred触发时,这个函数将被调用。在这两种情况下,该功能都不会被调用,直到阻止其进程的情况得到解决。

第三和第四部分与实际数据的发送有关。在这种情况下,我们有一个流,它有可发送的数据,并且在发送缓冲区中有空间来发送它。然后,我们从队列中弹出一大块数据(之前写在对write的调用中)。如果对象是_END_STREAM_SENTINEL,,那么主体就完成了,我们需要完成流的发送。否则,我们创建一个可以发送数据的数据帧,并可选地进行一些其他的状态管理。

最后一步,如果我们发送了任何数据,我们安排使用callLater调用这个方法,如前所述。

这种方法虽然比 HTTP/1.1 发送数据所需的逻辑复杂得多,但却是 HTTP/2 多路复用方法的核心。这种增加的计算复杂性使得 HTTP/2 在 Python 代码中比 HTTP/1.1 慢,但是极大地提高了协议的网络性能。

上述方法是如何处理复杂的多路复用数据发送或任何种类的缓冲发送逻辑的模型:可以在过程的每一步重复调用的单个函数,并且如果由于任何原因它不能做任何工作(例如,因为传输不能接受更多数据,或者因为没有数据要发送),可以容易地重新调度该函数。

反压力

新手程序员在使用像 Twisted 这样的异步系统时经常犯的一个错误是没有考虑如何处理过载情况。像 Twisted 这样的异步网络框架极大地增加了应用可以处理的网络流量,但是使用该框架的开发人员编写的应用代码可能跟不上 Twisted 和操作系统可以处理的数据量。

所有联网的应用都有可能遇到这样的情况:工作进入系统的速度快于系统的处理速度。一个简单的例子是一个 web 应用,它可以在 10 ms 内在一个 CPU 内核上处理一个请求。如果这个应用暴露在低于每秒 100 个请求的恒定负载下,那么一切都很好。

当这个完全相同的系统的负载水平超过每秒 100 个请求时,会发生什么情况?这个问题有许多可能的答案,但是这个系统中大多数 Twisted 应用的标准行为是它们将缓冲数据。 8

这种方法对于“尖峰”负载来说通常是合理的:如果系统上的负载只是短暂地超过每秒 100 个请求,然后又下降到这个水平以下,那么请求将短暂地经历更长的延迟(响应请求所需的时间),因为它们在被处理之前会在缓冲区中停留一段时间,但是 Twisted 应用将在新数据到达之前更快地从缓冲区中提供数据,因此缓冲区将慢慢变空。

然而,如果负载持续一段时间超过每秒 100 个请求,或者实质上超过该水平(例如,数百或数千倍),则缓冲表示有问题。每个请求的等待时间将会增加,可能会达到与失败难以区分的程度(大多数用户等待请求响应的时间不会超过一两秒,因此对于这些用户来说,20 秒的请求等待时间相当于请求失败)。更糟糕的是,如果过载持续,缓冲区将继续增长,如果不加检查,最终将消耗系统中的所有内存。最有可能的结果是操作系统会终止该进程:在最坏的情况下,该进程将开始交换,这将大大降低其计算速度并降低应用的处理速度,使应用更难处理过载。

因此,可伸缩的 Twisted 应用需要为过载做好准备。最常见的处理方式是创建传播背压的系统。背压是从一个系统到另一个系统的信号,表示“您提交工作的速度超过了我的完成速度,请慢下来。”通过异步应用正确地传播背压,允许该应用向接收工作的系统部分传达它可以处理多少工作。

具有讽刺意味的是,传播背压的一个很好的例子是阻塞 I/O。当使用阻塞 I/O 通过 TCP 发送数据时,如果远程对等体读取数据的速度不够快,发送调用将最终阻塞,直到远程对等体消耗了足够的数据来允许您的操作系统继续发送。这会强制降低发送应用的速度,使其发送数据的速度不会快于远程应用从套接字读取数据的速度。

Twisted 中的背压

目前,在 Twisted 中,通过让传输和协议实现两个接口来传播背压:IPushProducerIConsumer。一般来说,Transport实现IPushProducer,而Protocol实现IConsumer,尽管在更复杂的系统中(比如 Twisted 中的 HTTP/2 实现),同一个对象可能同时实现IConsumer(用于入站数据)和IPushProducer(用于出站数据)。

这两个接口非常简单:

class IPushProducer(IProducer):
    """
    A push producer, also known as a streaming producer is expected to produce (write to this consumer) data on a continuous basis, unless it has been paused. A paused push producer will resume producing after its resumeProducing() method is called. For a push producer which is not pauseable, these functions may be noops.
    """

    def pauseProducing():
        """
        Pause producing data.

        Tells a producer that it has produced too much data to process for the time being, and to stop until resumeProducing() is called.
        """
    def resumeProducing():
        """
        Resume producing data.

        This tells a producer to re-add itself to the main loop and produce more data for its consumer.
        """

class IProducer(Interface):
    """
    A producer produces data for a consumer.

    Typically producing is done by calling the write method of a class implementing L{IConsumer}.
    """

    def stopProducing():
        """
        Stop producing data.

        This tells a producer that its consumer has died, so it must stop producing data for good.
        """

class IConsumer(Interface):
    """
    A consumer consumes data from a producer.
    """

    def registerProducer(producer, streaming):
        """
        Register to receive data from a producer.

        This sets self to be a consumer for a producer. When this object runs out of data (as when a send(2) call on a socket succeeds in moving the last data from a userspace buffer into a kernelspace buffer), it will ask the producer to resumeProducing().

        For L{IPushProducer} providers, C{pauseProducing} will be called whenever the write buffer fills up and C{resumeProducing} will only be called when it empties.

        @type producer: L{IProducer} provider

        @type streaming: C{bool}
        @param streaming: C{True} if C{producer} provides L{IPushProducer},
        C{False} if C{producer} provides L{IPullProducer}.
        @raise RuntimeError: If a producer is already registered.

        @return: L{None}
        """

    def unregisterProducer():
        """
        Stop consuming data from a producer, without disconnecting.
        """

    def write(data):
        """
        The producer will write data by calling this method.

        The implementation must be non-blocking and perform whatever buffering is necessary. If the producer has provided enough data for now and it is a L{IPushProducer}, the consumer may call its C{pauseProducing} method. 

        """

这些接口最重要的部分是IPushProducer``.pauseProducing``IPushProducer.resumeProducingIConsumer.write。其余的是管理性的,涉及到告诉消费者关于生产者的情况,以及告诉生产者消费者不能再接受数据。

当一个IConsumer经历了太多的负载,以至于他们希望数据停止进入他们,他们可以在他们注册的生产者上调用pauseProducing。当他们准备接受更多的工作时,他们会打电话给resumeProducing。此时,消费者的注册生产者将再次开始调用write,直到IConsumer再次调用pauseProducing

HTTP/2 中的背压

HTTP/2 有两种背压信号方法,都使用流量控制算法。第一个是与 HTTP/1.1 共享的,因为它实际上内置于 HTTP/1.1 和 HTTP/2 都使用的 TCP 中。TCP 维护一个接收方窗口,将接收方的能力反馈给发送方。如果 TCP 连接的一端停止从套接字读取,另一端最终会发现不允许它发送进一步的数据。

此外,HTTP/2 还维护自己的四个流控制窗口:两个用于整个连接(一个用于从客户机发送到服务器的数据,一个用于从服务器发送到客户机的数据),两个用于每个流(同样,每个方向一个)。这些流控制窗口限制了每个对等体被允许发送多少数据:流窗口管理在给定的流上可以发送多少数据,而连接窗口控制在整个连接上可以发送多少数据。

这些窗口中的每一个也可以用于传播背压:让这些窗口大小中的任何一个变为零都会迫使远程对等体停止发送其部分或全部数据。这意味着我们希望能够将这些从客户端发送的背压信号传播到 Twisted 服务器。我们还希望能够将反压力信号从 Twisted 应用传播到客户端:如果 web 应用处理数据的速度比客户端发送数据的速度慢,我们应该适当降低数据交付的速度。 9

这方面的策略有两个:增加对 Twisted 服务器的支持,以发出和消耗背压,并适当地管理我们的 HTTP/2 流控制窗口。先说发射和消耗背压。

IConsumer/ IPushProducer接口的一个关键之处是这两个接口是一对一的。这意味着每个消费者只能有一个生产者,并且每个生产者一次只能为一个消费者生产。这对于 HTTP/2 来说是个问题,因为我们有多个数据流,每个数据流都会单独传播背压。

解决这个问题最简单的方法是用两个对象来定义 HTTP/2 连接,而不是一个。第一个对象拥有底层的 TCP 传输,并将自己注册为该传输的生产者和消费者:在代码中,这个类是twisted.web._http2.H2Connection

当客户端启动新的流时,这个对象创建一个新的对象来处理流数据,同时作为应用代码的生产者和消费者:在代码中,这个类是twisted.web._http2.H2Stream。在这两个对象之间,我们使用一个只为 HTTP/2 存在的自定义接口,以允许连接告诉流何时应该暂停它的生产者,因为流不能再发送(H2Stream.flowControlBlocked)以及何时窗口大小已经改变(H2Stream.windowUpdated)。H2Stream 在其应用上将这些调用转换成对pauseProducing & resumeProducing的调用。类似地,H2Stream允许应用调用pauseProducing来阻止流传递更多的数据。当被调用时,这将导致H2Stream开始缓冲数据,而不是将数据传递给应用。

这种相当混乱的关系如图 11-3 所示。

img/455189_1_En_11_Fig3_HTML.jpg

图 11-3

HTTP/2 连接中各种对象之间的生产者/消费者关系。每条线代表一个生产者/消费者关系。注意,这些关系并不总是用IProducer/IConsumer接口实现,如本节所讨论的。

如果与流相关联的任何流控制窗口为零,则流可能会被“阻塞”。也就是说,如果 TCP 流阻塞(传输调用H2Connection上的pauseProducing,该连接拥有的所有H2Stream对象将调用它们的应用上的pauseProducing。此外,如果连接流控制窗口变为 0,所有的H2Stream对象将在它们的应用上调用pauseProducing。最后,如果特定于流的窗口变为 0,与该流相关联的H2Stream对象将在其应用上调用pauseProducing,但其他对象不会。

然而,这种缓冲并不是无限的。它受流控制窗口的限制。你看,H2Connection还为H2Stream提供了另一个 API:H2Connection.openStreamWindow。当向应用传送数据时,而不是之前,该函数由H2Stream调用。这意味着,如果应用暂停生产,流窗口将不会打开,因此最终将被远程对等体耗尽,远程对等体将被允许在该流上不再发送数据,直到应用开始处理积压。

值得注意的是,即使应用无法处理更多数据,H2Connection也不会阻止客户端在 TCP 连接上发送更多数据。这是因为 HTTP/2 使用许多控制帧来管理流控制窗口和连接状态。这些额外的控制帧不能用于导致过多的数据缓冲,因此没有理由阻止客户端发送它们。

与 HTTP/1.1 相比,适当选择传播背压的应用在 HTTP/2 上会获得更丰富的体验。应用中较慢的部分,或者与较慢的客户端交互的部分,可以愉快地减慢速度,而不会限制系统的整体并发性。这也确保了通过 HTTP/2 提供数据的应用可以优雅而谨慎地处理过载,以一种可管理的方式降低服务质量,防止它们被完全淹没。

应用可以通过确保它们的请求处理器为它处理的每个Request注册一个IPushProducer来选择加入这个信号。twisted.web.http.Request提供IConsumer正是为了这个目的。

应该注意的是,IConsumer/IPushProducer接口是有限的,不一定能提供背压传播 API 应该提供的所有丰富功能。为了看到最终可能取代IConsumer/IPushProducer的更好界面的例子,让我们看看 tubes。 10

现状和未来扩展

Twisted HTTP/2 实现在 2016 年 7 月发布的 Twisted 16.3 中发布。该实现受到许多可选依赖项的限制,必须安装这些依赖项才能启用它,还需要满足 Twisted 正在使用的 OpenSSL 版本的一些要求。这些关口有效地将 HTTP/2 支持置于正在进行的“测试”状态。

自最初发布以来,许多有进取心的用户选择了支持,并帮助跟踪错误和报告问题。结果是 Twisted 的 HTTP/2 栈现在运行在数量巨大的机器上,几乎没有问题。这是一个巨大的成功,也是该项目的持续健康发展的一个非常积极的信号。

这项工作有几个自然的发展方向。第一个也是最大的挑战是编写一个 HTTP/2 客户机,它透明地填充到当前的 HTTP/1.1 客户机中。这是一项尚未认真尝试的重要工作,尽管一些前期工作已经完成。

工作的另一个重点是开始公开 API 以利用 HTTP/2 的特性。特别是,HTTP/2 支持服务器推送,这允许服务器乐观地开始发送客户端呈现页面可能需要的资源。一个有趣的未来增强将是允许 Twisted 应用通过公开适当的 API 以编程方式发出推送的资源。这可以用链接头解析来扩展,以支持来自传统 WSGI 应用的推送。

最后,允许对 HTTP/2 栈进行更多配置的 API 将是一个有用的扩展。目前还不支持允许 Twisted 应用修改 HTTP/2 配置,无论是全局的还是基于每个连接的。增加这种支持是向提供完整特性的 HTTP/2 实现的必要发展。

摘要

在本章中,我们介绍了 RFC 7540 中定义的 HTTP/2 协议。我们讨论了对twisted.web的扩展以支持该协议,重点讨论了该集成的设计目标,以及在实现过程中出现的一些具体问题。我们还讨论了并发编程中背压的重要性,以及接口设计对接口可扩展性的重要性。最后,我们总结了 Twisted 对 HTTP/2 支持的现状和未来发展方向。

十二、Twisted 通道和 Django 通道

介绍

接下来的部分将深入 Django 通道的结构和构建它所使用的技术,并尝试梳理出有用的设计细节,无论何时您构建旨在水平伸缩的复杂多层分布式应用,都可以使用这些细节。

Python 是最早定义 web 应用和 web 服务器之间的标准接口的编程语言之一,它不是基于 CGI,即通用网关接口。CGI 虽然有效,但不是特别快或高性能,因此需要在服务器和应用之间开发更丰富的接口,最好是利用语言原语和功能的接口。

2003 年,Python 核心开发团队采用了 PEP 333,它定义了 Web 服务器网关接口(WSGI)。WSGI 是一个 API 规范,它允许能够创建 Python 对象和调用 Python 函数(从 Python 或通过 C API)的 web 服务器以标准化的方式调用 web 应用。WSGI 的目标是将 web 应用框架从 web 服务器中分离出来,这样任何 web 服务器都可以运行任何 Python web 应用。

从这个角度来看,WSGI 非常成功。本书的大多数读者不会记得 WSGI 之前的世界,因此上面的描述看起来令人困惑:怎么会有 web 框架和 web 服务器没有解耦的世界呢?在后 WSGI 时代,Python 社区见证了伟大的 web 应用框架(如 Django 和 Flask)和伟大的 web 服务器(如 uWSGI、gunicorn 和 Twisted)在 WSGI 灵活性基础上的激增。

然而,这并不是一个完美的协议。特别是,WSGI 服务器通过调用同步 Python 函数来调用 WSGI 应用,并在它返回之前阻止执行。WSGI 中 Python 应用的这种基本同步调用意味着 WSGI 应用不容易以异步方式编写。从表面上看,这给程序员带来了一些不便:他们不能使用 Twisted 或者asyncawait关键字。然而,在更基础的层面上,这使得 Python web 应用有些低效:它们处理的每个并发 web 请求都需要一个全新的操作系统线程来处理。这种方法的低效是众所周知的:毕竟,这是 Twisted 存在的部分原因!

Django 通道代表了一种尝试,即允许以更并发的方式编写 Django 应用,同时保持与 WSGI 应用的向后兼容性。这是一项相当大的工作,因此“Django 通道”实际上涵盖了广泛的相关技术项目。这些包括用于服务器到应用通信的新接口,异步服务器网关接口(ASGI);实现该接口的服务器部分的参考 web 服务器(Daphne);和一个 Django 应用,使 Django 能够处理 ASGI 请求。

Django 通道的净效果是将 Django 的焦点从请求和响应转移到“事件”这使得基于通道的应用不仅可以处理 HTTP 请求和响应,还可以处理 WebSockets 甚至普通的 TCP/UDP 数据。这是通过将整个网络堆栈分成三部分来实现的:

  1. ASGI 服务器。该服务器负责接受输入连接并从写协议(例如 HTTP)转换成 ASGI 消息,将这些消息放入队列(“信道”),并从这些信道接收消息并将它们转换回有线协议数据。在参考实现中,这是 Daphne,一个基于 Twisted 的 web 服务器。

  2. “通道后端”,它基本上是一个可以用作消息代理的数据存储。对于琐碎的应用,这可能只是一些共享内存,但在较大的应用中,这通常是一个 Redis 部署。

  3. 一个或多个“工人”当有消息要处理时,这些工人监听一些或所有通道并运行相关代码。工作线程可能是顺序的和线程化的,但也可能不是。这是传统 Django 应用代码运行的地方。

这三个部分中,1 和 3 都可以与 Twisted 集成。然而,大多数 Django 用户将坚持使用常规的同步 Django 代码作为他们的工作代码,至少在可预见的将来是这样,所以在这一点上没什么可说的。

更值得关注的是 Daphne 和渠道系统的设计。Channels 是一个有用的实例,展示了如何使用 Twisted 和消息代理来构建复杂的多层分布式系统。本章将以这种方式使用通道。它还将讨论与 Autobahn 相关的通道,Autobahn 是 Twisted 的 WebSocket 实现。

渠道构建模块

渠道的基本组成部分是众所周知的和可信的软件工具的集合。这是通道设计的一大优势。对于像 Django 这样重要且广泛使用的软件来说,相对于软件组件的“酷”因素,更重视可靠性和确定性是很重要的。

如前所述,通道分为三个部分。这些组件中的每一个都是建立在一个核心软件之上的。

第一个组件 Daphne 是构建在twisted.web之上的 web 服务器。Daphne 的运营目标还包括支持 WebSockets,这是 Twisted core 中不支持的协议,因此 Daphne 对 twisted.web 进行了一些修改,以便也使用 Autobahn 来提供 WebSocket 支持。这里应该强调的是,Daphne 是一个非常小的代码块,主要负责将 HTTP 和 WebSocket 协议转换为队列上的通道消息,并处理从队列读取数据以将数据写回连接。

第三个组件,通道“workers”,是运行 Django web 框架和通道应用的 Python 进程。这个应用负责监听并发送到适当的队列,通常对应用代码隐藏通道抽象。这里的神奇之处在于,常规的使用 Django 的代码库几乎不需要修改就可以使用,允许从非通道使用的 Django 部署无缝升级。

第二个组件是栈中唯一的非 Python 组件:Redis。Redis 是一个开源的内存键值数据库,支持多种数据结构。虽然它的主要功能是作为一个数据库,但是它有许多属性使它可以作为一个消息代理,包括安全管理队列的能力。

这些组件中的每一个都可以独立于其他组件进行部署,并实现通道拓扑的不同部分。它们一起构成了完整的 web 应用,Daphne 处理协议支持并与客户端对话,Django 处理业务逻辑,Redis 作为另外两个服务之间的消息代理提供服务。

消息代理和队列

Django 通道的一个关键设计特性是,当使用通道运行时,所有常规的 Django 应用都需要继续正常工作,但是增加了一个特性:独立于服务 HTTP 流量的 web 服务器进行水平伸缩的能力。本质上,以前阻塞 web 流量服务的常规 Django 应用必须突然变成异步的,允许 web 服务器在等待响应时避免阻塞。如何在不更改任何代码行的情况下实现这一点?

关键是添加了一个消息代理。消息代理或排队系统是分布式系统中的常见组件。它的目的是将消息从多个消息生产者路由到不同数量的消息消费者,而不要求这些生产者或消费者知道如何找到彼此。

通常消息代理使用一个 FIFO 队列作为他们的核心抽象。产生工作的系统组件通过将工作项添加到 FIFO 队列的后面来完成工作。这些项目由一个或多个“工作进程”从队列的前端取出,这些工作进程负责根据提交的工作采取一些行动。这个系统有很多优点:它可以用作服务发现工具,并且它还提供了消息发送者和消息接收者之间的有用的解耦。

像这样的消息代理的优点是它分离了不同组件的运行时。在 WSGI 中,web 应用与 web 服务器的执行模型紧密耦合,因为 web 服务器需要调用一个 Python 函数,该函数将一直阻塞到执行完成。这种紧密集成意味着 web 服务器和 web 应用不能有不同的并发方法:最终都需要运行单线程同步代码。 1

通过在 web 服务器和 web 应用之间添加一个消息代理,每一个都可以有不同的执行范例。不仅如此,他们还可以使用任何允许他们向消息代理提交工作和从消息代理接收工作的范式。在这种情况下,Daphne(一个基于 Twisted 的异步 web 服务器)可以使用其异步编程模型与消息代理进行交互,而传统的单线程同步 Django 处理程序可以在 workers 中运行,而不会碍事。

更重要的是,我们现在可以拥有比 web 服务器多得多的工作进程。这极大地提高了传统 web 应用的性能:应用的每次调用都可以在单独的进程中完成,而不是占用宝贵的时间来持有 Python 的全局解释器锁。

这使得 Django 应用变得同步但并行。每个 Django 请求处理程序都可以是一个常规的同步阻塞 Python 函数,但是整个应用可以在任意多个进程中并行运行任意多个这样的函数。更重要的是,工作进程的数量可以动态扩展,并且与 web 服务器的数量无关。这允许应用的每个组件根据瓶颈位置进行独立的水平扩展,从而更有效地利用资源。

消息代理是为基本同步的程序添加异步的常用工具。通过允许单线程同步代码的多个实例同时在不同的进程或线程中运行,无需从根本上重写应用就可以增加应用中的异步性。

最重要的是,消息代理允许您避免担心如何协调这些多个并行的工作者。每个工作者就像在自己的小世界中一样,从队列中添加和删除数据,而不用担心数据来自哪里或去往哪里。消息代理负责确保尽可能多的工作人员能够访问数据并适当地处理数据。

虽然消息代理不是万灵药,但是它们是在非并发编程模型中实现可伸缩性和并发性的一个很好的工具。

Twisted 的分布式多层系统

Django Channels 不仅是部署可水平伸缩的 web 应用的有用工具,也是分布式多层软件系统的通用构造的有用示例。

分布式多层软件系统是通过将系统的责任分成“层”来构建的,这些层使用某种消息总线在彼此之间进行通信。在使用 Django 通道的应用的情况下,这通常是 Daphne、Django 和用于持久存储 Django 模型的任何数据库(例如,MySQL 或 PostgreSQL)的三层架构,但是多层架构的想法更为普遍。

像 Twisted 这样的异步网络框架通常是多层系统的关键组件。这在很大程度上是因为多层系统不可避免地会由于使用正式的或临时的 RPC(“远程过程调用”)机制而导致延迟。由于系统给定层中的每个节点都希望尽可能有效地使用系统资源,因此使用异步编程技术的多层系统比不使用异步编程技术的多层系统更具可伸缩性和效率。

规范的多层架构将应用分为三层,每层负责应用的一个单独方面。通常,这涉及一个专门用于存储数据(数据库)的层,一个专门用于执行应用或业务逻辑的层,以及一个专门用于表示的层。这种模式非常常见,事实上非常常见的“模型-视图-控制器”模式与这种规范构造密切相关。

当用 Twisted 编写多层应用时,有必要定义各层之间的通信机制。然而,在所有情况下,最终构建的都是某种形式的 RPC,允许单个层请求其他层工作。假设这些应用无论如何都需要一个 RPC 层,那么依靠某种标准的 RPC 机制将会为您节省大量的时间和精力。

RPC 最常见的选择是 REST,鉴于 Twisted 对 HTTP 的出色支持,这是一个很好的选择,但是根据您的应用,任何数量的不同 RPC 机制都可能是明智的选择。这种架构的关键是知道 Twisted 应用设计的本质非常适合编写基于 RPC 的应用:一旦您的核心应用期望异步,添加更多的异步层通常是相对简单的。通过仔细的 RPC 选择和应用设计,允许应用的任意水平伸缩成为可能。世界上最大的 web 项目都是以这种风格构建的,并且知道 Twisted 为您自己提供了大量工具来拥抱它是很有用的。

现状和未来扩展

2016 年 9 月 9 日,Channels 被采纳为 Django 官方项目。这意味着它是在 Django 项目和 Django 软件基金会的支持下管理的,但它不是 Django 核心库的一部分。该项目仍在积极开发中,并已做好生产准备。

它现在还支持大多数主要功能。HTTP/1.1、HTTP/2 和 WebSockets 都是完全支持的,尽管与 core Twisted 非常相似,但有些仅支持 HTTP/2 的特性还不被支持。Redis 被支持作为主要的通道后端,但是对于较小的部署也支持内存中后端。

Django 通道的未来发展方向是多种多样的。作为部署并发 web 应用的复杂框架,有多个可能的扩展方向。额外的通道后端、替代的 ASGI 服务器,甚至不同 web 框架的兼容层:所有这些以及更多都可能是富有成效的增强方向。对替代协议的更广泛支持也可能对项目有一些价值。

当然,理想的长期前景是采用 Django core 中的 Channels 模型作为默认的执行模型。这将在 Django 中为高度可伸缩的应用设计提供默认支持,帮助确保开发人员从第一天起就为未来的可伸缩性构建他们的应用。

摘要

在这一章中,我们介绍了 Django Channels,这是一个允许在并发、异步编程模型中使用 Django web 应用框架开发 web 应用的框架。我们讨论了通道的基本架构,并介绍了其构造块技术。然后,我们讨论了如何将这些构件重新用于任意多级分布式系统设计,以及如何设计这样一个系统来最大限度地使用 Twisted。最后,我们讨论了渠道的未来发展。

第一部分:基础

第二部分:项目

posted @ 2024-08-09 17:44  绝不原创的飞龙  阅读(31)  评论(0编辑  收藏  举报