使用 Twisted 和 Python 的事件驱动编程

作者:Ken Kinder

起初,有 fork 服务器,然后出现了线程服务器。尽管它们可以很好地管理少量并发连接,但当网络会话达到数百甚至数千时,fork 和线程服务器会产生太多独立的、消耗资源的进程,效率低下。今天,有一种更好的方法,异步服务器。用于第三代语言的新型框架正在驯服曾经复杂的事件驱动编程世界。

Twisted 已成为 Python 社区中一颗冉冉升起的新星,它使异步编程变得简单而优雅,同时提供了大量的事件驱动实用程序类库。在本文中,我将讨论异步事件驱动编程以及如何在 Twisted 中完成。因为仅仅阅读代码对您的帮助有限,所以我引用了一个为本文开发的真实 Twisted 应用程序的示例:一个简单的代理服务器,可以阻止不需要的 cookie、图像和连接。关于如何获取完整源代码的说明,请参见在线资源。

什么是 Twisted?

Twisted 项目作为一种强大且日益稳定的网络应用程序实现方式,越来越受欢迎。Twisted 的核心是一个异步网络框架。但与其他此类框架不同,Twisted 拥有丰富的集成库,用于处理常见的协议和编程任务,例如用户身份验证甚至远程对象代理。Twisted 背后的理念之一是打破工具包之间的传统分离,因为服务 Web 内容的同一台服务器可以解析 DNS 查找。虽然软件包本身非常大,但应用程序不需要导入 Twisted 的所有组件,因此运行时开销保持在最低限度。

与 Python 一样,Twisted 的用户群已从其学术根基扩展到商业和政府部门。在 Zoto,我们在分布式照片存储和管理应用程序中使用 Twisted,因为它使我们能够以著名的生产力语言 Python 快速开发可扩展的网络软件。在日常编程中,我欣赏 Twisted 令人印象深刻的工具包和支持性社区。与所有面向社区的开源项目一样,Twisted 也是一项安全的商业赌注,因为它的存在不依赖于任何一家公司或机构的持续支持。

什么是异步编程?

您是否曾经站在杂货店的快速通道中,只买一瓶水,却发现您前面顾客挑战某件商品的价格,导致您和您后面的所有人等待五分钟才能验证价格?关于异步编程的解释很多,但我认为理解其优点的最好方法是在收银员空闲的情况下排队等待。如果收银员是异步的,他或她会将您前面的人置于等待状态,并在等待价格检查时进行您的交易。不幸的是,收银员很少是异步的。然而,在软件世界中,事件驱动服务器可以最好地利用可用资源,因为没有线程占用宝贵的内存等待套接字上的流量。按照杂货店的比喻,线程服务器通过增加收银员来解决排长队的问题,而异步模型则让每个收银员一次帮助多位顾客。

这并不是说线程模型没有好处。例如,使用微线程,任何特定线程使用的资源量都会大幅减少。异步编程本身就存在复杂性,特别是当您需要连续执行许多阻塞操作时。然而,在 Python 中,线程的好处因 Python 的全局解释器锁 (GIL) 而减弱。Python 中的线程编程非常简单,因为所有内部 Python 操作都是线程安全的。要向列表添加项目或设置字典键,无需锁即可避免线程之间的竞争条件。不幸的是,这是通过 Python 解释器自由使用的解释器范围锁来实现的。因此,尽管两个线程可以同时安全地附加到同一个列表,但如果它们附加到两个不同的列表,则使用相同的锁。由于线程 Python 应用程序会遭受由此产生的性能损失,因此对于像 Python 这样的语言来说,异步单线程编程就更可取了。

接受连接和发送响应

让我们从一个简单的服务器示例开始,该服务器接受端口 1100 上的连接。对于每个连接,它发送 UNIX 时间并关闭套接字。

列表 1. 这个简单的 Twisted 服务器发送时间,然后关闭套接字。

import time
from twisted.internet import protocol, reactor

class TimeProtocol(protocol.Protocol):
    def connectionMade(self):
        self.transport.write(
            'Hello. The time is %s' % time.time())
        self.transport.loseConnection()

class TimeFactory(protocol.ServerFactory):
    protocol = TimeProtocol

reactor.listenTCP(1100, TimeFactory())
reactor.run()

使用一个线程处理多个会话的复杂性是 Twisted 等框架的核心。网络会话由 twisted.internet.protocol.Protocol 类的子类表示,每个 Protocol 实例代表一个网络会话。这些对象由 Factory 对象生成,Factory 对象继承自 twisted.internet.protocol.Factory。单例 twisted.internet.reactor 处理轮询套接字和调用事件的繁琐工作。在 Twisted 中调用 reactor.run() 只是启动事件循环,并且 run() 在应用程序完成时退出,与 GTK 或 Qt 中的事件循环相同。

代理服务器示例

我们的代理服务器有两种网络聊天会话:传入的 HTTP 请求及其各自的传出代理。由于 HTTP 是一种类似聊天的协议,我们可以从 Twisted 的 LineReceiver 继承我们的协议类,LineReceiver 是 Protocol 的子类,同时提供对聊天会话有用的额外功能,例如 HTTP。Twisted 实际上包含专门用于制作和处理 HTTP 请求的类。我们编写自己的类部分是因为 Twisted 的预制类不利于代理服务,也因为它对于本文来说是一个很好的编程练习。

Event-Driven Programming with Twisted and Python

图 1. 代理服务器的类图。Protocol 类处理单个连接,而 Factory 类创建它们。

请参阅图 1,了解我们将要使用的类结构。Factory 类的实例由 Twisted 用于为建立的每个连接生成 Protocol 实例。我们创建一个 SimpleHTTP 类,并从中继承用于管理传入和传出流量的类。由于 HTTP 对于客户端和服务器来说大致相同,因此我们可以在一个超类中管理大部分词法处理,并让子类完成其余部分,这正是 Twisted 自己的 HTTP 类的工作方式。

处理回调

您原本可以用一两个方法完成的操作在事件驱动编程中往往需要多个回调方法。经验法则是,任何时候有您需要等待的阻塞操作,它都会发生在您的代码之外,因此发生在您的两个方法之间。在我们的代理服务器示例中,我们可以将处理请求的每个部分分解为单独的块。代理服务器的大部分工作相当于从浏览器读取数据,对该数据进行一些更改,然后将修改后的数据发送到远程 Web 服务器。从 HTTP/1.1 开始,可以通过一个网络连接处理多个 Web 点击。在图 2 中,您可以看到每个请求会发生什么,请记住每个 HTTP 连接可以发出多个请求。连接框的箭头显示了哪些事件被生成以及生成的顺序。

Event-Driven Programming with Twisted and Python

图 2. 处理代理点击的总体步骤

在阻塞程序中,人们可能希望像这样处理打开远程连接并向其发送一行文本

connection = socket.open(remote_server, remote_port)
connection.write(get_string)
response = connection.readline()

我们都见过这种阻塞代码,那么 Twisted 方法有何不同呢?因为我们不想在事件驱动程序中等待连接建立,所以我们只是安排一些代码在远程服务器响应我们时运行。在 Twisted 中,这种延迟由 twisted.internet.defer.Deferred 类的实例处理,作为您期望从阻塞操作获得的结果的占位符。例如,在我们的代理服务器中,当我们启动远程连接时,我们接受一个 Deferred 对象(列表 2)。

列表 2. Twisted 中的延迟操作就像将它们置于等待状态,直到阻塞操作响应我们。

d = self.outgoing_proxy_cache.getOutgoing(
    host, int(port))
d.addCallback(self.outgoingConnectionMade, uri)
d.addErrback(self.outgoingProxyError, uri)

self.outgoing_proxy_cache.getOutgoing 方法启动出站代理连接。但是,它不会等待连接建立后返回给调用者;它会立即返回。所有方法尽快返回的行为使单线程服务器成为可能。方法占用的任何和所有 CPU 时间都用于处理,而不是等待外部事件发生。

请注意,作为连接对象本身的替代品,返回了一个 Deferred 对象。通过在 Deferred 对象上调用 addCallback 和 addErrback,我们正在安排将来的事件被触发,这样当出站连接准备就绪时,将调用 self.outgoingConnectionMade 方法。通过将 uri 作为第二个参数传递给 addCallback,我们告诉 Twisted 也应该调用 self.outgoingConnectionMade,并将 uri 作为附加参数。

处理错误

如果发生错误,则会使用 Failure 对象调用 self.outgoingProxyError,这使我们进入错误处理。Python 的传统错误处理是通过异常完成的,异常是其他高级语言(如 Java)中熟悉的概念(列表 3)。

列表 3. Python 中的传统错误处理

try:
    (offending code)
except ValueError:
    (error handling code)
except MyError:
    (error handling code)

虽然 Python 的异常处理模型对于同步设计来说非常有效(双关语),但它没有考虑到异步设计。例如,当我们启动出站 HTTP 连接时,Twisted 会继续处理其他事件,同时建立连接。但是,我们希望指定行为来解决在我们请求连接时可能发生的任何问题。幸运的是,制作 Twisted 的优秀人士考虑到了这一点。正如代码被安排在阻塞操作成功完成时运行一样,它也可以被安排在发生错误时运行。

Twisted 还处理事件循环中引发的所有异常,并为开发人员提供管理和记录异常的钩子。这也有一个额外的好处:虽然异常可能会中止特定事件的完成,但即使您没有放置任何异常处理代码,它也不会使服务器崩溃。

Twisted 类和事件处理

当使用某些 Twisted 类(例如我们正在使用的 LineReceiver 类)时,您只需通过向类中添加具有正确名称的方法即可处理许多事件。每次协议收到一行时,都会使用该行的文本作为参数调用 lineReceived 方法。我们的 SimpleHTTP 类旨在对 HTTP 会话进行最少的处理,它具有如下方法

  • startNewRequest:在每个请求开始时调用。

  • lineReceived:旨在促进面向聊天的协议。每次通过套接字传来一行文本时,都会自动调用此方法。

  • rawDataReceived:当发送二进制文件或原始数据流时,处理由换行符分隔的信息是不合理的。为了解决这个问题,LineReceiver 允许我们切换到原始模式传输,在这种情况下,将调用 rawDataReceived 而不是 lineReceived。

  • handleFirstLine:HTTP 的工作方式是使用单行开始每个请求。通常,客户端发送带有 URI 的 GET 或 POST 请求,服务器以状态代码响应。handleFirstLine 用于处理这些情况中的任何一种。

  • handleHeadersFinished:在 HTTP 标头完全发送时调用。

  • handleRequestFinished:在 HTTP 请求本身完成时调用。

为协议处理中发生的状态或操作编写单独的方法是 Twisted 程序员排队事件的方式。在请求开始时,我们可以指定在处理请求的每个阶段发生的事件。在我们之前的示例中,我们决定在建立连接后调用 self.outgoingConnectionMade。让我们看一下该方法,如列表 4 所示。

列表 4. 在 Twisted 中安排事件

def outgoingConnectionMade(self, outgoing_proxy,
                           uri):
    """
    This occurs when our outbound proxy has
    connected. It's a Twisted callback method.
    """
    assert(outgoing_proxy, OutgoingProxy)
    self.outgoing_proxy = outgoing_proxy
    outgoing_proxy.incoming_proxy = self

    # Send HTTP command and echo back result
    outgoing_proxy.write('%s %s %s' % \
        (self.http_command,
         uri,
         self.http_version) \
         + self.delimiter)

    outgoing_proxy.firstline_sent_def.addCallback(
        self.outgoingFirstlineReceived)

    # Send anything we have queued.
    self.flushOutgoingBuffer()

    # Add callbacks for when headers are ready
    outgoing_proxy.headers_finished_def.addCallback(
        self.outgoingHeadersReceived)
    outgoing_proxy.request_finished_def.addCallback(
        self.handleOutgoingRequestFinished)

请注意,outgoing_proxy 代表我们代表我们正在服务的 Web 浏览器连接到远程服务器的连接。我们通过调用 outgoing_proxy.write 发送 HTTP 请求。我们还安排在从远程服务器收到响应时调用 self.outgoingFirstlineReceived 方法。当远程服务器发送回其所有 HTTP 标头时,将调用 self.outgoingHeadersReceived 方法。最后,当远程服务器完全完成响应我们的出站 HTTP 请求时,将调用 self.handleOutgoingRequestFinished。

虽然 outgoingConnectionMade 方法在任何这些事件发生之前返回,但我们正在排队将来发生的事件。很可能在等待一个连接的响应时,同一个线程中打开和关闭了其他十个请求。与连接相关的所有信息都作为实例数据存储在协议类上。Factory 生成协议实例,协议实例保持会话状态,延迟对象将未来数据绑定到事件处理程序。完成拼图,reactor 管理轮询套接字的繁琐工作。这是 Twisted 构建所基于的工具组合。

总结

您可以下载本文中讨论的代理服务器的所有 606 行代码进行修改。虽然我不会将公司内网放在它后面,但我已经使用它一周来过滤掉不需要的 cookie 和图像,甚至阻止我的桌面访问特定供应商。当我开始使用 Twisted 时,很容易理解异步编程的概念,稍微难一点的是弄清楚如何将事件映射到我想要的流程,更难的是向别人解释它。但是,不要气馁。虽然我们在 Zoto 开始时几乎没有 Twisted 知识,但我们仅用一年多的时间就构建了一个功能齐全且极具可扩展性的集群应用程序,用于存储和管理在线照片,并且只有一个人(我)全职从事服务器工作。

当然,Twisted 并不适合所有人。它的庞大性虽然强大,但也可能令人生畏。对于 Python 中的简单异步聊天服务器,请查看 Medusa。与 Twisted 一样,Medusa 将异步编程组织到 Factory(称为 Dispatcher)和聊天类中。

本文的资源: www.linuxjournal.com/article/7963

Ken Kinder 目前在俄克拉荷马州俄克拉荷马城为 Zoto 开发集群 Twisted 服务器。他喜欢远足、滑雪、摄影和(当然)Linux。他的家乡是科罗拉多州博尔德。

加载 Disqus 评论