Twisted-专家级编程-四-

159 阅读1小时+

Twisted 专家级编程(四)

原文:Expert Twisted

协议:CC BY-NC-SA 4.0

七、魔法虫洞

魔法虫洞是一个安全的文件传输工具,它的座右铭是“安全地将东西从一台电脑转移到另一台电脑”这对于特定的一次性传输情况最为有用,例如:

  • 在一次会议上,您刚刚坐在某人旁边,您想从您的笔记本电脑上给他们一个您最喜欢的项目的 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 服务器,静态文件处理。你会对它能做的所有事情感到惊讶。

Footnotes 1

物联网。

  2

不要与 AJAX 出现之前流行的“Windows Apache MySQL PHP”相混淆。

  3

项目页面上列出了二进制: https://github.com/mhammond/pywin32

  4

如果你不能或者不想安装一个 crossbar 实例,你可以在 https://crossbar.io/docs/Demo-Instance/ 上找到一个用于演示的。在这种情况下,你可以用它来代替“ws://127.0.0.1:8080/ws”。但是您仍然需要 pip install pyo pensl service _ identity 来使用它。

 

九、使用异步和 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 代码。