首页
/ 深入理解 Socket.IO v4 协议:包类型、编码格式与完整线上传输流程

深入理解 Socket.IO v4 协议:包类型、编码格式与完整线上传输流程

2026-09-04 19:23:42作者:田桥桑Industrious

Socket.IO v4 协议建立在 Engine.IO v3(EIO=3)之上,在低层 WebSocket / HTTP long-polling 管道之上叠加了命名空间复用(multiplexing)与包确认(acknowledgement)两大能力。本文以仓库中的协议规范文档 docs/socket.io-protocol/v4.md 为骨架,逐字段拆解 v4 的 7 种包类型、字符串编码格式与完整交互流程,并结合当前仓库中 packages/socket.io-parser 的参考实现,说明这些设计如何落地为可运行的编解码逻辑,帮助读者读懂线上抓到的每一段 42["hello"] 类字节流。

协议定位与版本

v4 是 Socket.IO 协议的第 4 次修订,随 socket.io@1.0.3...latest 引入。它构建于 Engine.IO 协议的第 3 次修订之上,因此线上请求会携带 EIO=3 查询参数。

需要明确一个前提:当前仓库中的实现(socket.iosocket.io-client 版本均为 4.8.3,socket.io-parser 版本 4.2.7)已经演进到第 5 次修订EIO=4)。v5 文档见 v5-current.md,v4 与 v5 的差异见本文末尾"协议历史"小节。阅读本文时应注意:v4 描述的隐式默认命名空间连接、ERROR 命名等细节,在当前代码中已由 v5 取代,但包类型编号与编码格式的核心结构保持一致。

v4 相对低层 Engine.IO 协议增加的能力:

  • multiplexing(命名空间复用):同一条底层 WebSocket 连接可承载多个命名空间。
// server-side
const nsp = io.of("/admin");
nsp.on("connect", socket => {});

// client-side
const socket1 = io(); // 默认命名空间
const socket2 = io("/admin");
socket2.on("connect", () => {});
  • 包确认(acknowledgement):发送方可附带回调,接收方通过 ACK 包回传结果。
// on one side
socket.emit("hello", 1, () => { console.log("received"); });
// on the other side
socket.on("hello", (a, cb) => { cb(); });

包结构

一个 v4 包包含以下字段:

  • type:整数,包类型(见下节)
  • nsp:命名空间(字符串)
  • data(可选):payload(string 或 Array)
  • id(可选):acknowledgment id(整数)

当前仓库的参考实现将包建模为如下接口(见 Packet 定义),其中还额外携带了二进制场景下的 attachments 计数:

export interface Packet {
  type: PacketType;
  nsp: string;
  data?: any;
  id?: number;
  attachments?: number;
}

七种包类型

v4 定义了 7 种包类型,编号与语义如下。

0 - CONNECT

由客户端发起(请求访问某命名空间),也由服务端下发(接受连接)。不含 payload,也不含 acknowledgment id。

{ "type": 0, "nsp": "/admin" }

客户端可在命名空间字段中附加额外信息(例如鉴权参数):

{ "type": 0, "nsp": "/admin?token=1234&uid=abcd" }

说明:v4 通过 nsp 字段里拼查询串来传递鉴权信息。v5 改为在 data 中携带结构化 payload,这是 v4 与 v5 的一处关键差异(见末尾历史小节)。

1 - DISCONNECT

当一方希望断开某个命名空间时使用。不含 payload 与 acknowledgment id。

{ "type": 1, "nsp": "/admin" }

2 - EVENT

当一方希望向另一方传输数据(不含二进制)时使用。包含 payload,可含 acknowledgment id。

{ "type": 2, "nsp": "/", "data": ["hello", 1] }

带 acknowledgment id:

{ "type": 2, "nsp": "/admin", "data": ["project:delete", 123], "id": 456 }

3 - ACK

当一方收到带 acknowledgment id 的 EVENTBINARY_EVENT 时回传。它携带上一包收到的 acknowledgment id,并可能携带 payload(不含二进制)。

{ "type": 3, "nsp": "/admin", "data": [], "id": 456 }

4 - ERROR

由服务端在拒绝某命名空间的连接时发送。可能携带说明拒绝原因的 payload。

{ "type": 4, "nsp": "/admin", "data": "Not authorized" }

命名说明:v4 中该类型名为 ERROR,v5 更名为 CONNECT_ERROR,编号仍为 4,语义不变(见 PacketType 枚举)。

5 - BINARY_EVENT

当一方希望传输含二进制的数据时使用。包含 payload,可含 acknowledgment id。

{ "type": 5, "nsp": "/", "data": ["hello", <Buffer 01 02 03>] }

带 acknowledgment id:

{ "type": 5, "nsp": "/admin", "data": ["project:delete", <Buffer 01 02 03>], "id": 456 }

6 - BINARY_ACK

当一方收到带 acknowledgment id 的 EVENTBINARY_EVENT 时回传,且响应中包含二进制。

{ "type": 6, "nsp": "/admin", "data": [<Buffer 03 02 01>], "id": 456 }

包编码格式

这一节详述默认解析器(即随 Socket.IO 服务端与客户端一起分发的那个解析器)所使用的编码。其源码见 packages/socket.io-parser

JS 服务端与客户端还支持自定义解析器,它们有各自的取舍,适合特定类型的应用(例如 msgpack 解析器,仓库 socket.io 客户端分发包中也提供了 socket.io.msgpack.min.js)。

关键一点:每个 Socket.IO 包都是作为一个 Engine.IO message 包(类型 4)发送的,因此在真正走线时(HTTP long-polling 的请求/响应体,或 WebSocket 帧中),编码结果前面会多一个前缀 4

编码格式

<packet type>[<# of binary attachments>-][<namespace>,][<acknowledgment id>][JSON-stringified payload without binary]

+ binary attachments extracted

注意:命名空间仅在它不同于默认命名空间(/)时才被包含

编码示例

  • 默认命名空间的 CONNECT
{ "type": 0, "nsp": "/" }

编码为 0

  • /admin 命名空间的 CONNECT
{ "type": 0, "nsp": "/admin" }

编码为 0/admin,

  • /admin 命名空间的 DISCONNECT
{ "type": 1, "nsp": "/admin" }

编码为 1/admin,

  • EVENT
{ "type": 2, "nsp": "/", "data": ["hello", 1] }

编码为 2["hello",1]

  • 带 acknowledgment id 的 EVENT
{ "type": 2, "nsp": "/admin", "data": ["project:delete", 123], "id": 456 }

编码为 2/admin,456["project:delete",123]

  • ACK
{ "type": 3, "nsp": "/admin", "data": [], "id": 456 }

编码为 3/admin,456[]

  • ERROR
{ "type": 4, "nsp": "/admin", "data": "Not authorized" }

编码为 4/admin,"Not authorized"

  • BINARY_EVENT
{ "type": 5, "nsp": "/", "data": ["hello", <Buffer 01 02 03>] }

编码为 51-["hello",{"_placeholder":true,"num":0}] + <Buffer 01 02 03>

  • 带 acknowledgment id 的 BINARY_EVENT
{ "type": 5, "nsp": "/admin", "data": ["project:delete", <Buffer 01 02 03>], "id": 456 }

编码为 51-/admin,456["project:delete",{"_placeholder":true,"num":0}] + <Buffer 01 02 03>

  • BINARY_ACK
{ "type": 6, "nsp": "/admin", "data": [<Buffer 03 02 01>], "id": 456 }

编码为 61-/admin,456[{"_placeholder":true,"num":0}] + <Buffer 03 02 01>

编码在源码中的实现

上述编码规则在参考实现中由 Encoder.encodeAsString 逐段拼出,顺序与文档完全一致:先写类型,再写(若有)附件数与 -,再写(若非 / 的)命名空间与逗号,再写(若有)id,最后写 JSON 字符串化的 payload。见 encodeAsString 实现

let str = "" + obj.type;
// attachments if we have them
if (obj.type === PacketType.BINARY_EVENT || obj.type === PacketType.BINARY_ACK) {
  str += obj.attachments + "-";
}
// if we have a namespace other than `/` we append it followed by a comma
if (obj.nsp && "/" !== obj.nsp) {
  str += obj.nsp + ",";
}
// immediately followed by the id
if (null != obj.id) {
  str += obj.id;
}
// json data
if (null != obj.data) {
  str += JSON.stringify(obj.data, this.replacer);
}

反向解码则由 Decoder.decodeString 负责,它按同样顺序解析类型、附件数、命名空间、id 与 JSON payload,并对非法输入抛出 unknown packet typeIllegal attachmentsinvalid payload 等错误,见 decodeString 实现

二进制的"占位符"机制

文档示例中反复出现的 {"_placeholder":true,"num":0} 并非神秘语法,而是编码二进制包时的一种"提取"技巧:编码端先用带编号的占位符替换 payload 中的每一个二进制对象,把二进制数据本身抽出来单独附在字符串后面发送;解码端再按编号回填。

这一逻辑由 deconstructPacket / reconstructPacket 实现,见 binary.ts。其中 deconstructPacket 递归遍历 payload,遇到二进制就生成 { _placeholder: true, num: 序号 } 并把它 push 进缓冲区列表、记录 attachments 计数;reconstructPacket 则在收齐全部二进制块后,按 num 将缓冲区放回占位符位置。判定"什么算二进制"(ArrayBuffer、其视图、BlobFile)则封装在 is-binary.tsisBinary / hasBinary 中。

解码侧还有一处值得注意的健壮性设计:Decoder 通过一个 maxAttachments(默认 10)上限来拒绝过大的附件声明,见 DecoderOptions 与解码

交互协议(Exchange protocol)

连接默认命名空间

在连接建立时,服务端总是先发送一个默认命名空间(/)的 CONNECT 包。 也就是说,即便客户端请求的是非默认命名空间,它也会先收到默认命名空间的 CONNECT 包。

Server > { type: CONNECT, nsp: "/" }

此时不期望客户端有任何响应。

这是 v4 的标志性行为,也是 v5 的主要改动点:v5 取消了"隐式连接默认命名空间",要求客户端在任何情况下都主动发送 CONNECT 包。阅读当前仓库代码时不会看到"服务端先自发默认 CONNECT"的行为,这正是 v5 的体现。

连接非默认命名空间

Client > { type: CONNECT, nsp: "/admin" }
Server > { type: CONNECT, nsp: "/admin" } (如果连接成功)
or
Server > { type: ERROR, nsp: "/admin", data: "Not authorized" }

断开非默认命名空间

Client > { type: DISCONNECT, nsp: "/admin" }

反过来同理。不期望对方有任何响应。

确认(Acknowledgement)

Client > { type: EVENT, nsp: "/admin", data: ["hello"], id: 456 }
Server > { type: ACK, nsp: "/admin", data: [], id: 456 }
or
Server > { type: BINARY_ACK, nsp: "/admin", data: [ <Buffer 01 02 03> ], id: 456 }

反过来同理。

样例会话(Sample session)

下面展示当 Engine.IO 与 Socket.IO 两层协议叠加时,线上实际传输的完整过程(对应 v4 的 EIO=3)。

  • 请求 1(open 包)
GET /socket.io/?EIO=3&transport=polling&t=N8hyd6w
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=UTF-8
96:0{"sid":"lv_VI97HAXpY6yYWAAAC","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":5000}2:40

细节:

96          => 首条消息的字符数(不是字节数)
:           => 分隔符
0           => Engine.IO "open" 包类型
{"sid":...  => Engine.IO 握手数据
2           => 第 2 条消息的字符数
:           => 分隔符
4           => Engine.IO "message" 包类型
0           => Socket.IO "CONNECT" 包类型

注意:t 查询参数用于确保该请求不被浏览器缓存。

  • 请求 2(message in)

在服务端执行 socket.emit('hey', 'Jude')

GET /socket.io/?EIO=3&transport=polling&t=N8hyd7H&sid=lv_VI97HAXpY6yYWAAAC
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=UTF-8
16:42["hey","Jude"]

细节:

16          => 字符数
:           => 分隔符
4           => Engine.IO "message" 包类型
2           => Socket.IO "EVENT" 包类型
[...]       => 内容
  • 请求 3(message out)

在客户端执行 socket.emit('hello'); socket.emit('world');

POST /socket.io/?EIO=3&transport=polling&t=N8hzxke&sid=lv_VI97HAXpY6yYWAAAC
> Content-Type: text/plain; charset=UTF-8
11:42["hello"]11:42["world"]
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=UTF-8
ok

细节:

11          => 第 1 个包的字符数
:           => 分隔符
4           => Engine.IO "message" 包类型
2           => Socket.IO "EVENT" 包类型
["hello"]   => 第 1 个内容
11          => 第 2 个包的字符数
:           => 分隔符
4           => Engine.IO "message" 包类型
2           => Socket.IO "EVENT" 包类型
["world"]   => 第 2 个内容
  • 请求 4(WebSocket 升级)
GET /socket.io/?EIO=3&transport=websocket&sid=lv_VI97HAXpY6yYWAAAC
< HTTP/1.1 101 Switching Protocols

WebSocket 帧:

< 2probe                                        => Engine.IO probe 请求
> 3probe                                        => Engine.IO probe 响应
> 5                                             => Engine.IO "upgrade" 包类型
> 42["hello"]
> 42["world"]
> 40/admin,                                     => 请求访问 admin 命名空间(Socket.IO "CONNECT" 包)
< 40/admin,                                     => 授予 admin 命名空间访问权
> 42/admin,1["tellme"]                          => 带确认的 Socket.IO "EVENT" 包
< 461-/admin,1[{"_placeholder":true,"num":0}]   => 带占位符的 Socket.IO "BINARY_ACK" 包
< <binary>                                      => 二进制附件(在下一帧发送)
... 一段时间无消息后
> 2                                             => Engine.IO "ping" 包类型
< 3                                             => Engine.IO "pong" 包类型
> 1                                             => Engine.IO "close" 包类型

协议历史

v4 与 v3 的区别

  • 新增 BINARY_ACK 包类型。

在此之前,ACK 包总被当作"可能含二进制对象"来处理,需要递归搜索这类对象,可能拖累性能。v4 引入独立的 BINARY_ACK 类型后,普通 ACK 与二进制 ACK 各司其职,避免了无谓的递归扫描。

v3 与 v2 的区别

  • 移除了使用 msgpack 来编码含二进制对象的包。

v2 与 v1 的区别

  • 新增 BINARY_EVENT 包类型。

这是在向 Socket.IO 1.0 推进期间加入的,目的是支持二进制对象。当时的 BINARY_EVENT 包使用 msgpack 编码。

初次修订

第一次修订是 Engine.IO 协议(处理 WebSocket / HTTP long-polling 与心跳的低层管道)与 Socket.IO 协议分离的结果。它从未被纳入任何 Socket.IO 正式发布版本,却为后续迭代铺平了道路。

与当前仓库实现的对照

阅读当前仓库代码时,可以把 v4 的这些规范当作"祖源"来理解 v5。几处可直接对照的证据:

  • 包类型编号一脉相承。当前 PacketType 枚举CONNECT=0、DISCONNECT=1、EVENT=2、ACK=3、CONNECT_ERROR=4、BINARY_EVENT=5、BINARY_ACK=6,与 v4 完全对齐,唯一变化是把 v4 的 ERROR 更名为 CONNECT_ERROR
  • 编码/解码格式不变。前缀 4、命名空间逗号分隔、二进制占位符与附件计数,这些在 encodeAsString / decodeString 中仍然生效。
  • 错误包的载荷形态升级。v4 的 ERROR 载荷是纯字符串(如 "Not authorized"),v5 改为对象 { message, data }。当前服务端在下发 CONNECT_ERROR 时会按连接所用协议版本区分载荷形态——对 protocol === 3(即旧版本)走字符串分支,否则走对象分支,见 namespace.ts 的错误处理
  • 默认命名空间不再隐式连接。v4 "服务端总是先发默认命名空间 CONNECT"的行为在 v5 中已被移除,客户端需显式发送 CONNECT
  • 对旧 Engine.IO(EIO=3)客户端的兼容。当前服务端仍保留对 EIO3 客户端的支持,可通过 allowEIO3 选项开启,相关行为见 v2 兼容性测试

这些对照说明:v4 文档描述的包结构与编码格式是 Socket.IO 协议长期稳定的内核,即便协议修订号推进到 v5,理解 v4 仍能帮助你准确解读线上字节流。

登录后查看全文
热门项目推荐
相关项目推荐