【译】Engine.IO 协议

Engine.IOSocket.IO 的底层实现基础,协议的原文在 官方文档GitHub 上。

本文在翻译的过程中参考了 KevinWu0904 大佬在他的博客——极客熊生中翻译的版本,该版本最后更新于2021年5月12日,与目前所使用的版本有些许出入。

下文的外部链接多为 Wikipedia 或 MDN Web Docs 的名词解释。


本文档描述了 Engine.IO 协议的第四个版本。

本文档的源代码可以从这里找到。

介绍

Engine.IO 实现了一种在服务端和客户端之间低开销的全双工通信。

它基于 WebSocket,当无法建立 WebSocket 连接时会使用 HTTP 长轮询作为备用方案。

参考实现是用 TypeScript 编写的:

Socket.IO 协议建立在 Engine.IO 协议为所提供的功能之上。

传输

Engine.IO 的服务端和客户端之间可以通过 HTTP 长轮询和 WebSocket 方式连接。

HTTP 长轮询

HTTP 长轮询传输由连续的 HTTP 请求组成。

  • 长期运行的 GET 请求,用于从服务器接收信息
  • 短期运行的 POST 请求,用于向服务器发送数据

请求地址

默认的 HTTP 请求地址是 /engine.io/

它可能会被建立在此协议上的库覆盖(例如, Socket.IO 协议使用的是 /socket.io/ )。

查询参数

使用如下查询参数:

参数名参数值说明
EIO4必选,协议的版本
transportpolling必选,传输的方式
sid<sid>在会话(Session)建立之后必选,会话的ID

如果缺少必选的查询参数,服务端必须返回 HTTP 400 错误码。

HTTP 请求头(Header)

当传输二进制数据的时候,发送方(服务端或者客户端)必须发送包含 Content-Type: application/octet-stream 的请求头。

没有额外声明 Content-Type 的,接收方应当推断数据是纯文本。

发送和接收数据

发送数据

想要发送数据,客户端必须创建一个 POST 请求,其中请求体(Request Body)是编码后的数据。

CLIENT                                                 SERVER

  │                                                      │
  │   POST /engine.io/?EIO=4&transport=polling&sid=...   │
  │ ───────────────────────────────────────────────────► │
  │ ◄──────────────────────────────────────────────────┘ │
  │                        HTTP 200                      │
  │                                                      │

如果会话ID(来自查询参数的 sid )未知,服务端必须返回 HTTP 400 状态码。

为了表示成功,服务端必须返回 HTTP 200 状态码并在响应体(Response Body)中返回字符串 ok

为了保证数据包有序,客户端不得同时有多个活跃的 POST 请求。如果有,服务端必须返回 HTTP 400 错误状态码并且关闭当前会话。

接收数据

想要接收数据,客户端必须创建一个 GET 请求。

CLIENT                                                SERVER

  │   GET /engine.io/?EIO=4&transport=polling&sid=...   │
  │ ──────────────────────────────────────────────────► │
  │                                                   . │
  │                                                   . │
  │                                                   . │
  │                                                   . │
  │ ◄─────────────────────────────────────────────────┘ │
  │                       HTTP 200                      │

如果会话ID(来自查询参数的 sid )未知,服务端必须返回 HTTP 400 状态码。

如果缓冲区没有为给定会话缓冲的数据包,服务端可能不会立刻回应。一旦有数据包要发送,服务端应该对它们进行编码(参考数据包编码一节),并在 HTTP 响应体中发给客户端。

为了保证数据包有序,客户端不得同时有多个活跃的 GET 请求。如果有,服务端必须返回 HTTP 400 错误状态码并且关闭当前会话。

WebSocket

WebSocket 传输方式基于可以为服务器和客户端之间提供双向和低延迟信道的 WebSocket 连接

使用如下查询参数:

参数名参数值说明
EIO4必选,协议的版本
transportwebsocket必选,传输的方式
sid<sid>在会话(Session)建立之后必选,会话的ID

如果缺少必选的查询参数,服务端必须关闭 WebSocket 连接。

每个数据包(读或写)都要发送自己的 WebSocket 数据帧

客户端不应发起多个 WebSocket 连接。如果有,服务端必须关闭 WebSocket 连接。

协议

一个 Engine.IO 数据包包括:

  • 一个数据包类型
  • 一个可选的有效载荷

以下是所有可用的数据包类型:

类型ID用途
open0用于握手。
close1用于指示可以关闭传输。
ping2用于心跳机制。
pong3用于心跳机制。
message4用于向另一端发送有效载荷。
upgrade5用于把 HTTP 长轮询 升级到 WebSocket。
noop6用于把 HTTP 长轮询 升级到 WebSocket。

握手

要建立连接,客户端必须向服务端发送一个 GET 请求:

  • HTTP 长轮询(默认)
CLIENT                                                    SERVER

  │                                                          │
  │        GET /engine.io/?EIO=4&transport=polling           │
  │ ───────────────────────────────────────────────────────► │
  │ ◄──────────────────────────────────────────────────────┘ │
  │                        HTTP 200                          │
  │                                                          │
  • 仅 WebSocket 会话
CLIENT                                                    SERVER

  │                                                          │
  │        GET /engine.io/?EIO=4&transport=websocket         │
  │ ───────────────────────────────────────────────────────► │
  │ ◄──────────────────────────────────────────────────────┘ │
  │                        HTTP 101                          │
  │                                                          │

如果服务器接受了连接,必须返回一个 open 数据包和如下使用 JSON 格式编码的载荷:

Key类型描述
sidstring会话ID。
upgradesstring[]所有可用的传输升级。
pingIntervalnumberPing的间隔,用于心跳机制。(单位:毫秒)
pingTimeoutnumberPing的超时时间,用于心跳机制。(单位:毫秒)
maxPayloadnumber每一块的字节数,用于客户端把数据包装填入载荷中。

例如:

{
  "sid": "lv_VI97HAXpY6yYWAAAC",
  "upgrades": ["websocket"],
  "pingInterval": 25000,
  "pingTimeout": 20000,
  "maxPayload": 1000000
}

客户端必须在后续的所有请求中发送该 sid 值。

心跳机制

握手完成后,将使用心跳机制检测连接的存活性。

CLIENT                                                 SERVER

  │                   *** Handshake ***                  │
  │                                                      │
  │  ◄─────────────────────────────────────────────────  │
  │                           2                          │  (ping packet)
  │  ─────────────────────────────────────────────────►  │
  │                           3                          │  (pong packet)

服务端会依据给定的间隔(握手阶段的 pingInterval )发送 ping 数据包,客户端有一定时间(握手阶段的 pingTimeout )发回 pong 数据包。

如果服务端没有收到返回的 pong 数据包,那么它应该认为连接已关闭。

相应的,如果客户端在 pingInterval + pingTimeout 内没有收到 ping 数据包,它也应该认为连接已关闭。

升级

默认情况下,客户端应该创建一个 HTTP 长轮询连接,如果可以的话再升级到更好的传输方式。

为升级到 WebSocket,客户端必须

  • 暂停 HTTP 长轮询传输(不再发送 HTTP 请求),以保证没有数据包被遗失。
  • 用相同的会话 ID 打开一个 WebSocket 请求。
  • 发送一个 ping 数据包并在载荷中发送字符串 probe

服务端必须

  • 向任何正在等待的 GET 请求发送一个 noop 数据包,以干净地关闭 HTTP 长轮询传输。
  • 发送一个 pong 数据包并在载荷中发送字符串 probe

最后,客户端必须发送一个 upgrade 数据包来完成升级。

完整的流程如下图:

CLIENT                                                 SERVER

  │                                                      │
  │   GET /engine.io/?EIO=4&transport=websocket&sid=...  │
  │ ───────────────────────────────────────────────────► │
  │  ◄─────────────────────────────────────────────────┘ │
  │            HTTP 101 (WebSocket handshake)            │
  │                                                      │
  │            -----  WebSocket frames -----             │
  │  ─────────────────────────────────────────────────►  │
  │                         2probe                       │ (ping packet)
  │  ◄─────────────────────────────────────────────────  │
  │                         3probe                       │ (pong packet)
  │  ─────────────────────────────────────────────────►  │
  │                         5                            │ (upgrade packet)
  │                                                      │

消息

握手完成后,客户端和服务端可以通过 message 数据包中的载荷来交换数据。

数据包编码

Engine.IO 数据包的序列化取决于载荷类型(纯文本或二进制)以及传输方式。

HTTP 长轮询

由于 HTTP 长轮询的性质,多个数据包可能连接在一个有效载荷里面以增加吞吐量。

格式:

<packet type>[<data>]<separator><packet type>[<data>]<separator><packet type>[<data>][...]

例如:

4hello\x1e2\x1e4world

代表:

4      => message 数据包类型
hello  => message 消息负载
\x1e   => 分隔符
2      => ping 数据包类型
\x1e   => 分隔符
4      => message 数据包类型
world  => message 负载

数据包由记录分隔符\x1e,或 ^^ )分隔。

二进制数据的有效载荷必须以 base64 编码,并且以字符 b 为前缀。

例如:

4hello\x1ebAQIDBA==

代表:

4         => message 数据包类型
hello     => message 消息负载
\x1e      => 分隔符
b         => 二进制前缀
AQIDBA==  => base64 编码的十六进制数据 01 02 03 04

客户端应当用握手时侯收到的 maxPayload 来决定连接多少数据包。

WebSocket

每个 Engine.IO 数据包都使用自己的 WebSocket 数据帧发送。

格式:

<packet type>[<data>]

例如:

4hello

代表:

4      => message 数据包类型
hello  => message 数据载荷(使用 UTF-8 编码)

二进制数据原样发送,无需修改。

历史

从 V2 到 V3

  • 添加对二进制数据的支持

本协议的第二版用于 Socket.IO v0.9 及以下版本。

本协议的第三版用于 Socket.IO v1v2 版本。

从 V3 到 V4

  • 反向心跳机制

ping 数据包现在由服务端发送,因为浏览器中设置的计时器不够可靠。我们怀疑很多超时问题来自客户端的延迟。

  • 在对二进制数据进行编码时始终使用 base64

此更改支持以相同的方式处理所有有效负载(不论有没有二进制数据),而不必考虑客户端或者当前传输方式是否支持二进制数据。

注意这只适用于 HTTP 长轮询。二进制数据在 WebSocket 数据帧中发送时无需额外转换。

  • 使用记录分隔符 \x1e 而不是字符计数

字符计数会导致其他语言无法(或者更难)实现协议,这些语言可能不使用 UTF-16 编码。

例如, 被编码成 2:4€ ,但是 Buffer.byteLength('€') === 3

注意:我们假定数据中不使用记录分隔符。

第四版(当前版本)在 Socket.IO v3 及更高版本中使用。

测试样例

仓库目录中的测试套件可以帮助检查服务端的实现。

使用方法:

  • 在 Node.js 中: npm ci && npm test
  • 在浏览器中:打开 index.html

作为参考,以下是 JavaScript 服务端实现通过所有测试的预期配置:

import { listen } from "engine.io";

const server = listen(3000, {
  pingInterval: 300,
  pingTimeout: 200,
  maxPayload: 1e6,
  cors: {
    origin: "*"
  }
});

server.on("connection", socket => {
  socket.on("data", (...args) => {
    socket.send(...args);
  });
});

最后更新于 2023 年 3 月 7 日。


翻译完了也就全都看明白了。
我之所以翻译这个还是为了 C# 的 Socket.IO 实现来着。

—— 雨落 2023.3.26

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注