ch16-server-sent-events

服务器推送事件 (SSE)

浏览器 API 与协议,第 16 章

服务器推送事件(Server-Sent Events,简称 SSE)实现了从服务器到客户端的高效文本事件流推送——例如服务器生成的实时通知或更新。为实现这一目标,SSE 引入了两个核心组件:浏览器端的全新 EventSource 接口,允许客户端以 DOM 事件的形式接收服务器推送;以及”事件流”数据格式,用于传输单个更新。

浏览器中的 EventSource API 与定义明确的事件流数据格式相结合,使 SSE 成为处理浏览器实时数据的高效且不可或缺的工具:

  • 通过单一长连接实现低延迟传输
  • 高效的浏览器消息解析,无无界缓冲区问题
  • 自动跟踪最后接收消息并自动重连
  • 以 DOM 事件形式通知客户端消息

底层实现上,SSE 提供了跨浏览器的高效 XHR 流式传输;消息实际通过单一长连接 HTTP 传输。然而,与自行处理 XHR 流式传输不同,浏览器接管了所有连接管理和消息解析工作,让我们的应用可以专注于业务逻辑!简而言之,SSE 让实时数据处理变得简单高效。让我们深入了解其内部机制。


EventSource API

EventSource 接口将所有底层连接建立和消息解析抽象为一个简洁的浏览器 API。开始使用非常简单,只需指定 SSE 事件流资源的 URL,并在对象上注册相应的 JavaScript 事件监听器:

var source = new EventSource("/path/to/stream-url");

source.onopen = function () { ... };
source.onerror = function () { ... };

source.addEventListener("foo", function (event) {
  processFoo(event.data);
});

source.onmessage = function (event) {
  log_message(event.id, event.data);

  if (event.id == "CLOSE") {
    source.close();
  }
}
  1. 打开到流端点的新 SSE 连接
  2. 可选回调,连接建立时触发
  3. 可选回调,连接失败时触发
  4. 订阅 “foo” 类型事件;调用自定义逻辑
  5. 订阅所有无明确类型的事件
  6. 如果服务器发送 “CLOSE” 消息 ID,则关闭 SSE 连接

EventSource 可以利用与普通 XHR 相同的 CORS 权限和可选工作流程,从远程源流式传输事件数据。

这就是客户端 API 的全部内容。实现逻辑已为我们处理:连接自动协商、接收数据增量解析、消息边界识别,最后由浏览器触发 DOM 事件。EventSource 接口让应用专注于业务逻辑:打开新连接、处理接收的事件通知、完成时终止流。

SSE 提供了内存高效的 XHR 流式传输实现。与原始 XHR 连接(在连接关闭前缓冲完整接收的响应)不同,SSE 连接可以丢弃已处理的消息,而无需在内存中累积所有消息。

锦上添花的是,EventSource 接口还提供自动重连和最后接收消息跟踪功能:如果连接断开,EventSource 会自动重新连接到服务器,并可选地通告最后接收消息的 ID,以便恢复流并重新传输丢失的消息。

浏览器如何知道每条消息的 ID、类型和边界?这就是事件流协议的作用。简洁的客户端 API 与定义明确的数据格式相结合,使我们能够将大部分工作交给浏览器处理。两者相辅相成,尽管底层数据协议对浏览器中的应用完全透明。


使用自定义 JavaScript 模拟 EventSource

SSE 是 HTML5 规范的早期补充,现代浏览器均已原生支持。对于旧版浏览器(如 Internet Explorer 和早期 Android 浏览器),可以通过 JavaScript 库(即”polyfill”)模拟 EventSource 接口。同样,事件流的传输也可以基于现有 XHR 机制实现:

if (!window.EventSource) {
  // 加载 JavaScript polyfill 库
}

var source = new EventSource("/event-stream-endpoint");
...

使用 polyfill 库的好处在于,它再次让我们的应用专注于应用逻辑,而非浏览器差异和实现状态。然而,虽然 polyfill 提供一致的 API,需注意底层 XHR 传输效率不如原生实现:

  • XHR 轮询会产生消息延迟和高请求开销
  • XHR 长轮询最小化延迟但仍有高请求开销
  • XHR 流式传输支持有限,且将所有数据缓冲在内存中

在没有原生支持高效 XHR 事件流数据流式传输的情况下,polyfill 库可能回退到轮询、长轮询或 XHR 流式传输,每种方式都有其性能成本。如需完整讨论,请参阅实时通知与传输章节。

简而言之,检查你的 polyfill 库实现,确保它满足性能目标!许多流行库(如 jQuery.EventSource)使用 XHR 轮询来模拟 SSE 传输——简单,但也是低效的传输方式。


事件流协议

SSE 事件流作为流式 HTTP 响应传输:客户端发起常规 HTTP 请求,服务器以自定义 “text/event-stream” 内容类型响应,然后流式传输 UTF-8 编码的事件数据。不过,这听起来仍显复杂,来看个例子:

=> 请求
GET /stream HTTP/1.1
Host: example.com
Accept: text/event-stream

<= 响应
HTTP/1.1 200 OK
Connection: keep-alive
Content-Type: text/event-stream
Transfer-Encoding: chunked

retry: 15000

data: 第一条消息是简单字符串。

data: {"message": "JSON payload"}

event: foo
data: "foo" 类型的消息

id: 42
event: bar
data: "bar" 类型的多行消息
data: ID 为 "42"

id: 43
data: 最后一条消息,ID "43"
  1. 客户端通过 EventSource 接口发起连接
  2. 服务器以 “text/event-stream” 内容类型响应
  3. 服务器设置客户端重连间隔(15 秒),以防连接断开
  4. 无消息类型的简单文本事件
  5. 无消息类型的 JSON 载荷
  6. “foo” 类型的简单文本事件
  7. 带消息 ID 和类型的多行事件
  8. 带可选 ID 的简单文本事件

事件流协议极易理解和实现:

  • 事件载荷是一个或多个相邻 data 字段的值
  • 事件可携带可选 ID 和事件类型字符串
  • 事件边界以换行符标记

在接收端,EventSource 接口通过查找换行分隔符解析传入流,从 data 字段提取载荷,检查可选 ID 和类型,最后分派 DOM 事件通知应用。如果存在类型,则触发自定义 DOM 事件;否则调用通用的 “onmessage” 回调;详见 EventSource API 章节。


UTF-8 编码与 SSE 二进制传输

EventSource 不对实际载荷进行额外处理:消息从 data 字段提取,连接在一起,直接传递给应用。因此,服务器可以推送任何文本格式(如简单字符串、JSON 载荷等),应用需自行解码。

需注意,所有事件源数据均为 UTF-8 编码:SSE 并非用于传输二进制载荷的机制!如有必要,可将任意二进制对象 base64 编码以适配 SSE,但这会产生较高(33%)的字节开销;详见资源内联章节。

担心 UTF-8 在线路上的高开销?SSE 连接是流式 HTTP 响应,意味着它可以像任何其他 HTTP 响应一样被压缩(即 gzip)传输!虽然 SSE 不适用于二进制数据传输,但它仍是高效的传输方式:确保你的服务器对 SSE 流应用 gzip 压缩。

缺乏对二进制流的支持并非疏漏。SSE 专门设计为简单、高效的服务器到客户端文本数据传输工具。如需传输二进制载荷,WebSocket 才是合适的工具。

除自动事件解析外,SSE 还提供内置支持,用于重建断开的连接以及恢复客户端断开期间可能错过的消息。默认情况下,如果连接断开,浏览器会自动重建连接。SSE 规范建议 2-3 秒延迟,这是大多数浏览器的常见默认值,但服务器也可随时通过向客户端发送 retry 命令设置自定义间隔。

同样,服务器可为每条消息关联任意 ID 字符串。浏览器自动记住最后接收的 ID,并在发出重连请求时自动附加 “Last-Event-ID” HTTP 头,值为记住的 ID。示例如下:

(现有 SSE 连接)
retry: 4500

id: 43
data: Lorem ipsum

(连接断开)
(4500 毫秒后)

=> 请求
GET /stream HTTP/1.1
Host: example.com
Accept: text/event-stream
Last-Event-ID: 43

<= 响应
HTTP/1.1 200 OK
Content-Type: text/event-stream
Connection: keep-alive
Transfer-Encoding: chunked

id: 44
data: dolor sit amet
  1. 服务器将客户端重连间隔设置为 4.5 秒
  2. 简单文本事件,ID: 43
  3. 自动客户端重连请求,带最后接收事件 ID
  4. 服务器以 “text/event-stream” 内容类型响应
  5. 简单文本事件,ID: 44

客户端应用无需提供额外逻辑来重建连接或记住最后接收的事件 ID。整个流程由浏览器处理,我们依赖服务器处理恢复。具体而言,根据应用和数据流的需求,服务器可实现多种不同策略:

  • 如果丢失消息可接受,则无需事件 ID 或特殊逻辑:只需让客户端重连并恢复流
  • 如果需要消息恢复,则服务器需为相关事件指定 ID,以便客户端重连时报告最后接收的 ID。此外,服务器需实现某种本地缓存,以恢复并向客户端重新传输错过的消息

消息持久化回溯多远的具体实现细节,当然取决于应用需求。另请注意,ID 是可选的事件流字段。因此,服务器也可选择在传输的事件流中检查点特定消息或里程碑。简而言之,评估你的需求,在服务器端实现适当逻辑。


SSE 使用场景与性能

SSE 是服务器到客户端文本实时数据流的高性能传输方式:消息可在服务器可用时立即推送(低延迟),消息开销最小(长连接、事件流协议和 gzip 压缩),浏览器处理所有消息解析,且无无界缓冲区。再加上便捷的 EventSource API(自动重连、以 DOM 事件形式通知消息),SSE 成为处理实时数据的不可或缺的工具!

SSE 有两个关键限制。首先,它仅支持服务器到客户端,因此不适用于请求流式传输场景——例如向服务器流式传输大文件上传。其次,事件流协议专门设计用于传输 UTF-8 数据:二进制流式传输虽然可行,但效率低下。

UTF-8 限制通常可在应用层解决:SSE 向应用通知服务器有新的二进制资源可用,应用再发起 XHR 请求获取。虽然这会产生额外的往返延迟,但也有好处:可利用 XHR 提供的众多服务,如响应缓存、传输编码(压缩)等。如果资源被流式传输,浏览器缓存无法缓存它。

实时推送与轮询一样,可能对电池寿命产生较大负面影响。首先,考虑批处理消息以避免唤醒无线电。其次,消除不必要的保活;无线电空闲时 SSE 连接不会”断开”。更多详情,请参阅消除周期性低效数据传输章节。


SSE 基于 TLS 的流式传输

SSE 在常规 HTTP 连接之上提供简单便捷的实时传输,使其在服务器端易于部署,在客户端易于 polyfill。然而,现有网络中间件(如代理服务器和防火墙)可能不了解 SSE,仍可能引发问题:中间件可能选择缓冲事件流数据,导致延迟增加或 SSE 连接完全中断。

因此,如果遇到此类问题,可考虑通过 TLS 连接传输 SSE 事件流;请参阅代理、中间件、TLS 与 Web 新协议章节。

现代部署建议:

  • HTTP/2 多路复用:在 HTTP/2 环境下,SSE 可与其他资源共享单一连接,减少连接开销
  • TLS 1.3:0-RTT 握手可进一步降低重连延迟
  • 服务器推送(Server Push):虽然 HTTP/2 服务器推送曾被考虑用于 SSE 优化,但现代最佳实践建议谨慎使用,因其可能被滥用或已被部分浏览器弃用