Engine.IO 是 Socket.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/
)。
查询参数
使用如下查询参数:
参数名 | 参数值 | 说明 |
EIO | 4 | 必选,协议的版本 |
transport | polling | 必选,传输的方式 |
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 连接。
使用如下查询参数:
参数名 | 参数值 | 说明 |
EIO | 4 | 必选,协议的版本 |
transport | websocket | 必选,传输的方式 |
sid | <sid> | 在会话(Session)建立之后必选,会话的ID |
如果缺少必选的查询参数,服务端必须关闭 WebSocket 连接。
每个数据包(读或写)都要发送自己的 WebSocket 数据帧。
客户端不应发起多个 WebSocket 连接。如果有,服务端必须关闭 WebSocket 连接。
协议
一个 Engine.IO 数据包包括:
- 一个数据包类型
- 一个可选的有效载荷
以下是所有可用的数据包类型:
类型 | ID | 用途 |
open | 0 | 用于握手。 |
close | 1 | 用于指示可以关闭传输。 |
ping | 2 | 用于心跳机制。 |
pong | 3 | 用于心跳机制。 |
message | 4 | 用于向另一端发送有效载荷。 |
upgrade | 5 | 用于把 HTTP 长轮询 升级到 WebSocket。 |
noop | 6 | 用于把 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 | 类型 | 描述 |
sid | string | 会话ID。 |
upgrades | string[] | 所有可用的传输升级。 |
pingInterval | number | Ping的间隔,用于心跳机制。(单位:毫秒) |
pingTimeout | number | Ping的超时时间,用于心跳机制。(单位:毫秒) |
maxPayload | number | 每一块的字节数,用于客户端把数据包装填入载荷中。 |
例如:
{
"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 v1
和 v2
版本。
从 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