第八章:事件驱动的 Web 框架 
原文信息
作者简介 
Leo Zovic(在线更为人知的名字是 inaimathi)是一位正在恢复的平面设计师,职业生涯中编写过 Scheme、Common Lisp、Erlang、Javascript、Haskell、Clojure、Go、Python、PHP 和 C。他目前在多伦多的一家基于 Ruby 的创业公司工作,写博客讨论编程,玩桌游。
引言 
2013 年,我决定为卡牌和棋盘游戏编写一个基于 Web 的游戏原型工具,名为 House。在这类游戏中,一个玩家通常会等待另一个玩家移动;然而,当另一个玩家最终采取行动时,我们希望等待的玩家能够快速收到移动通知。
这个问题比最初看起来要复杂得多。在本章中,我们将探讨使用 HTTP 构建这种交互的问题,然后我们将用 Common Lisp 构建一个 Web 框架,让我们能够在未来解决类似的问题。
HTTP 服务器的基础 
在最简单的层面上,HTTP 交换是单个请求后跟单个响应。客户端发送请求,包括资源标识符、HTTP 版本标签、一些头部和一些参数。服务器解析该请求,确定要做什么,并发送响应,其中包括相同的 HTTP 版本标签、响应代码、一些头部和响应体。
请注意,在此描述中,服务器响应来自特定客户端的请求。在我们的情况下,我们希望每个玩家在任何移动发生时立即得到更新,而不是仅在他们自己移动时才收到通知。这意味着我们需要服务器向客户端推送消息,而无需先收到信息请求。
有几种标准方法可以通过 HTTP 启用服务器推送。
Comet/长轮询 
"长轮询"技术让客户端在收到响应后立即向服务器发送新请求。服务器不会立即满足该请求,而是等待后续事件来响应。这在语义上有所区别,因为客户端仍在每次更新时采取行动。
服务器发送事件(SSE) 
服务器发送事件要求客户端启动连接并保持打开状态。服务器定期向连接写入新数据而不关闭它,客户端在新消息到达时解释它们,而不是等待响应连接终止。这比 Comet/长轮询方法更有效,因为每条消息不必承担新 HTTP 头的开销。
WebSockets 
WebSockets 是建立在 HTTP 之上的通信协议。服务器和客户端打开 HTTP 对话,然后执行握手和协议升级。最终结果是他们仍在通过 TCP/IP 通信,但根本不使用 HTTP。这比 SSE 的优势在于你可以自定义协议以提高效率。
长期连接 
这三种方法彼此完全不同,但它们都有一个重要的共同特征:它们都依赖于长期连接。长轮询依赖于服务器保留请求直到新数据可用,SSE 在客户端和服务器之间保持开放流,定期向其写入数据,WebSockets 更改特定连接使用的协议,但保持其打开。
要了解为什么这可能会给普通 HTTP 服务器带来问题,让我们考虑底层实现如何工作。
传统 HTTP 服务器架构 
单个 HTTP 服务器同时处理许多请求。从历史上看,许多 HTTP 服务器使用每个请求一个线程的架构。也就是说,对于每个传入的请求,服务器创建一个线程来执行响应所需的工作。
由于这些连接都是短期的,我们不需要很多并行执行的线程来处理它们。这个模型还通过使服务器程序员能够编写代码就好像在任何给定时间只处理一个连接一样,简化了服务器的实现。它还让我们可以自由地通过杀死相应的线程并让垃圾收集器完成其工作来清理失败或"僵尸"连接及其相关资源。
关键观察是,托管"传统" Web 应用程序并拥有 N 个并发用户的 HTTP 服务器可能只需要并行处理 N 个请求中的很小一部分即可成功。对于我们试图构建的交互式应用程序类型,N 个用户几乎肯定需要应用程序同时维护至少 N 个连接。
保持长期连接的结果是我们需要:
- 大量廉价线程,或
- 用单个线程处理许多连接的方法
有些编程环境如 Racket、Erlang 和 Haskell 提供足够"轻量级"的线程结构以考虑第一个选项。这种方法要求程序员显式处理同步问题,这在连接长时间打开并且可能都在竞争相似资源的系统中会更加普遍。
如果我们没有廉价的线程或不愿意处理显式同步,我们必须考虑用单个线程处理许多连接。在这个模型中,我们的单个线程将同时处理许多请求的微小"切片",尽可能高效地在它们之间切换。这种系统架构模式最常被称为事件驱动或基于事件。
由于我们只管理单个线程,我们不必太担心保护共享资源免受同时访问。但是,在这个模型中我们确实有一个独特的问题。由于我们的单个线程同时处理所有进行中的请求,我们必须确保它永不阻塞。阻塞任何连接都会阻塞整个服务器在任何其他请求上取得进展。如果当前客户端无法进一步服务,我们必须能够转移到另一个客户端,并且我们需要以一种不会丢弃到目前为止所做工作的方式来做到这一点。
构建事件驱动的 Web 服务器 
大多数使用单个进程管理并发工作流的程序使用一种称为事件循环的模式。让我们看看我们的 Web 服务器的事件循环可能是什么样子。
事件循环 
我们的事件循环需要:
- 监听服务器套接字上的新连接
- 在现有连接上读取数据
- 处理已准备好的连接
(以下省略详细的 Common Lisp 代码实现说明)
将服务器扩展为 Web 框架 
我们现在已经构建了一个相当实用的 Web 服务器,可以将请求、响应和消息传送到客户端和从客户端传送。任何由该服务器托管的 Web 应用程序的实际工作都是通过委托给处理程序函数来完成的。
服务器和托管应用程序之间的接口很重要,因为它决定了应用程序程序员使用我们的基础设施的难易程度。理想情况下,我们的处理程序接口会将请求中的参数映射到执行实际工作的函数。
处理程序的 DSL 
我们正在构建一个领域特定语言(DSL)用于处理函数;也就是说,我们正在创建一个特定的约定和语法,使我们能够简洁地表达我们希望处理程序验证的内容。这种构建小型语言来解决手头问题的方法经常被 Lisp 程序员使用,它是一种可以应用于其他编程语言的有用技术。
讨论 
在本章中,我们看到了用相对少量的代码可以完成什么来解决事件驱动 Web 框架的复杂问题。事件驱动架构允许我们在单个线程上高效处理许多长期连接,这是构建需要服务器推送功能的现代 Web 应用程序的关键。
通过使用 Common Lisp 的宏系统,我们能够创建一个简洁的 DSL 来定义处理程序和验证规则。这种方法使应用程序代码保持清晰和声明性,同时为我们提供了灵活性来处理复杂的类型验证和路由逻辑。
总结 
我们构建了一个完整的事件驱动 Web 框架,可以:
- 处理多个并发的长期连接
- 支持服务器发送事件(SSE)用于实时更新
- 提供类型安全的参数验证
- 使用声明式 DSL 定义路由和处理程序
- 动态选择和调整策略以提高性能
这个框架展示了如何使用事件驱动架构和函数式编程技术来构建高效、可扩展的 Web 应用程序。