Socket.IO 协议
本文档描述了 Socket.IO 协议的第 5 版。
本文档的源代码可以在这里找到 这里.
目录
介绍
Socket.IO 协议使客户端和服务器之间能够进行 全双工 和低开销通信。
它建立在 Engine.IO 协议 之上,该协议处理与 WebSocket 和 HTTP 长轮询的低级管道。
Socket.IO 协议添加了以下功能
- 多路复用(在 Socket.IO 行话中称为 "命名空间")
JavaScript API 示例
服务器
// declare the namespace
const namespace = io.of("/admin");
// handle the connection to the namespace
namespace.on("connection", (socket) => {
// ...
});
客户端
// reach the main namespace
const socket1 = io();
// reach the "/admin" namespace (with the same underlying WebSocket connection)
const socket2 = io("/admin");
// handle the connection to the namespace
socket2.on("connect", () => {
// ...
});
- 数据包确认
JavaScript API 示例
// on one side
socket.emit("hello", "foo", (arg) => {
console.log("received", arg);
});
// on the other side
socket.on("hello", (arg, ack) => {
ack("bar");
});
参考实现是用 TypeScript 编写的
交换协议
Socket.IO 数据包包含以下字段
- 数据包类型(整数)
- 命名空间(字符串)
- 可选的,有效负载(对象 | 数组)
- 可选的,确认 ID(整数)
以下是可用数据包类型的列表
类型 | ID | 用途 |
---|---|---|
CONNECT | 0 | 在 连接到命名空间 期间使用。 |
DISCONNECT | 1 | 在 从命名空间断开连接 时使用。 |
EVENT | 2 | 用于向另一方 发送数据。 |
ACK | 3 | 用于 确认 事件。 |
CONNECT_ERROR | 4 | 在 连接到命名空间 期间使用。 |
BINARY_EVENT | 5 | 用于向另一方 发送二进制数据。 |
BINARY_ACK | 6 | 用于 确认 事件(响应包含二进制数据)。 |
连接到命名空间
在 Socket.IO 会话开始时,客户端必须发送一个 CONNECT
数据包
服务器必须以以下方式响应
- 如果连接成功,则为一个
CONNECT
数据包,有效负载中包含会话 ID - 或者,如果连接不允许,则为一个
CONNECT_ERROR
数据包
CLIENT SERVER
│ ───────────────────────────────────────────────────────► │
│ { type: CONNECT, namespace: "/" } │
│ ◄─────────────────────────────────────────────────────── │
│ { type: CONNECT, namespace: "/", data: { sid: "..." } } │
如果服务器首先没有收到 CONNECT
数据包,则它必须立即关闭连接。
客户端可以同时连接到多个命名空间,使用相同的底层 WebSocket 连接。
示例
- 使用主命名空间(名为
"/"
)
Client > { type: CONNECT, namespace: "/" }
Server > { type: CONNECT, namespace: "/", data: { sid: "wZX3oN0bSVIhsaknAAAI" } }
- 使用自定义命名空间
Client > { type: CONNECT, namespace: "/admin" }
Server > { type: CONNECT, namespace: "/admin", data: { sid: "oSO0OpakMV_3jnilAAAA" } }
- 使用附加有效负载
Client > { type: CONNECT, namespace: "/admin", data: { "token": "123" } }
Server > { type: CONNECT, namespace: "/admin", data: { sid: "iLnRaVGHY4B75TeVAAAB" } }
- 如果连接被拒绝
Client > { type: CONNECT, namespace: "/" }
Server > { type: CONNECT_ERROR, namespace: "/", data: { message: "Not authorized" } }
发送和接收数据
一旦 连接到命名空间 建立,客户端和服务器就可以开始交换数据
CLIENT SERVER
│ ───────────────────────────────────────────────────────► │
│ { type: EVENT, namespace: "/", data: ["foo"] } │
│ │
│ ◄─────────────────────────────────────────────────────── │
│ { type: EVENT, namespace: "/", data: ["bar"] } │
有效负载是强制性的,必须是非空数组。如果不是这样,接收方必须关闭连接。
示例
- 使用主命名空间
Client > { type: EVENT, namespace: "/", data: ["foo"] }
- 使用自定义命名空间
Server > { type: EVENT, namespace: "/admin", data: ["bar"] }
- 使用二进制数据
Client > { type: BINARY_EVENT, namespace: "/", data: ["baz", <Buffer <01 02 03 04>> ] }
确认
发送方可以包含事件 ID 以请求接收方的确认
CLIENT SERVER
│ ───────────────────────────────────────────────────────► │
│ { type: EVENT, namespace: "/", data: ["foo"], id: 12 } │
│ ◄─────────────────────────────────────────────────────── │
│ { type: ACK, namespace: "/", data: ["bar"], id: 12 } │
接收方必须使用相同事件 ID 响应一个 ACK
数据包。
有效负载是强制性的,必须是数组(可能是空的)。
示例
- 使用主命名空间
Client > { type: EVENT, namespace: "/", data: ["foo"], id: 12 }
Server > { type: ACK, namespace: "/", data: [], id: 12 }
- 使用自定义命名空间
Server > { type: EVENT, namespace: "/admin", data: ["foo"], id: 13 }
Client > { type: ACK, namespace: "/admin", data: ["bar"], id: 13 }
- 使用二进制数据
Client > { type: BINARY_EVENT, namespace: "/", data: ["foo", <buffer <01 02 03 04> ], id: 14 }
Server > { type: ACK, namespace: "/", data: ["bar"], id: 14 }
or
Server > { type: EVENT, namespace: "/", data: ["foo" ], id: 15 }
Client > { type: BINARY_ACK, namespace: "/", data: ["bar", <buffer <01 02 03 04>], id: 15 }
从命名空间断开连接
在任何时候,任何一方都可以通过发送一个 DISCONNECT
数据包来结束与命名空间的连接
CLIENT SERVER
│ ───────────────────────────────────────────────────────► │
│ { type: DISCONNECT, namespace: "/" } │
另一方不需要响应。如果客户端连接到另一个命名空间,则低级连接可能会保持活动状态。
数据包编码
本节详细介绍了默认解析器使用的编码,该解析器包含在 Socket.IO 服务器和客户端中,其源代码可以在这里找到 这里.
JavaScript 服务器和客户端实现还支持自定义解析器,这些解析器具有不同的权衡,可能对某些类型的应用程序有利。例如,请参阅 socket.io-json-parser 或 socket.io-msgpack-parser。
另请注意,每个 Socket.IO 数据包都作为 Engine.IO message
数据包发送(更多信息 这里),因此编码结果在通过网络发送时将以字符 "4"
为前缀(在 HTTP 长轮询的请求/响应主体中,或在 WebSocket 帧中)。
格式
<packet type>[<# of binary attachments>-][<namespace>,][<acknowledgment id>][JSON-stringified payload without binary]
+ binary attachments extracted
注意:仅当命名空间不同于主命名空间(/
)时才包含命名空间
示例
连接到命名空间
- 使用主命名空间
数据包
{ type: CONNECT, namespace: "/" }
编码
0
- 使用自定义命名空间
数据包
{ type: CONNECT, namespace: "/admin", data: { sid: "oSO0OpakMV_3jnilAAAA" } }
编码
0/admin,{"sid":"oSO0OpakMV_3jnilAAAA"}
- 如果连接被拒绝
数据包
{ type: CONNECT_ERROR, namespace: "/", data: { message: "Not authorized" } }
编码
4{"message":"Not authorized"}
发送和接收数据
- 使用主命名空间
数据包
{ type: EVENT, namespace: "/", data: ["foo"] }
编码
2["foo"]
- 使用自定义命名空间
数据包
{ type: EVENT, namespace: "/admin", data: ["bar"] }
编码
2/admin,["bar"]
- 使用二进制数据
数据包
{ type: BINARY_EVENT, namespace: "/", data: ["baz", <Buffer <01 02 03 04>> ] }
编码
51-["baz",{"_placeholder":true,"num":0}]
+ <Buffer <01 02 03 04>>
- 具有多个附件
数据包
{ type: BINARY_EVENT, namespace: "/admin", data: ["baz", <Buffer <01 02>>, <Buffer <03 04>> ] }
编码
52-/admin,["baz",{"_placeholder":true,"num":0},{"_placeholder":true,"num":1}]
+ <Buffer <01 02>>
+ <Buffer <03 04>>
请记住,每个 Socket.IO 数据包都包装在一个 Engine.IO message
数据包中,因此它们在通过网络发送时将以字符 "4"
为前缀。
示例:{ type: EVENT, namespace: "/", data: ["foo"] }
将发送为 42["foo"]
确认
- 使用主命名空间
数据包
{ type: EVENT, namespace: "/", data: ["foo"], id: 12 }
编码
212["foo"]
- 使用自定义命名空间
数据包
{ type: ACK, namespace: "/admin", data: ["bar"], id: 13 }
编码
3/admin,13["bar"]`
- 使用二进制数据
数据包
{ type: BINARY_ACK, namespace: "/", data: ["bar", <Buffer <01 02 03 04>>], id: 15 }
编码
61-15["bar",{"_placeholder":true,"num":0}]
+ <Buffer <01 02 03 04>>
从命名空间断开连接
- 使用主命名空间
数据包
{ type: DISCONNECT, namespace: "/" }
编码
1
- 使用自定义命名空间
{ type: DISCONNECT, namespace: "/admin" }
编码
1/admin,
示例会话
以下是一个示例,说明在组合 Engine.IO 和 Socket.IO 协议时通过网络发送的内容。
- 请求 n°1(打开数据包)
GET /socket.io/?EIO=4&transport=polling&t=N8hyd6w
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=UTF-8
0{"sid":"lv_VI97HAXpY6yYWAAAC","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":5000,"maxPayload":1000000}
详细信息
0 => Engine.IO "open" packet type
{"sid":... => the Engine.IO handshake data
注意:t
查询参数用于确保浏览器不会缓存请求。
- 请求 n°2(命名空间连接请求)
POST /socket.io/?EIO=4&transport=polling&t=N8hyd7H&sid=lv_VI97HAXpY6yYWAAAC
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=UTF-8
40
详细信息
4 => Engine.IO "message" packet type
0 => Socket.IO "CONNECT" packet type
- 请求 n°3(命名空间连接批准)
GET /socket.io/?EIO=4&transport=polling&t=N8hyd7H&sid=lv_VI97HAXpY6yYWAAAC
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=UTF-8
40{"sid":"wZX3oN0bSVIhsaknAAAI"}
- 请求 n°4
socket.emit('hey', 'Jude')
在服务器上执行
GET /socket.io/?EIO=4&transport=polling&t=N8hyd7H&sid=lv_VI97HAXpY6yYWAAAC
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=UTF-8
42["hey","Jude"]
详细信息
4 => Engine.IO "message" packet type
2 => Socket.IO "EVENT" packet type
[...] => content
- 请求 n°5(消息输出)
socket.emit('hello'); socket.emit('world');
在客户端上执行
POST /socket.io/?EIO=4&transport=polling&t=N8hzxke&sid=lv_VI97HAXpY6yYWAAAC
> Content-Type: text/plain; charset=UTF-8
42["hello"]\x1e42["world"]
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=UTF-8
ok
详细信息
4 => Engine.IO "message" packet type
2 => Socket.IO "EVENT" packet type
["hello"] => the 1st content
\x1e => separator
4 => Engine.IO "message" packet type
2 => Socket.IO "EVENT" packet type
["world"] => the 2nd content
- 请求 n°6(WebSocket 升级)
GET /socket.io/?EIO=4&transport=websocket&sid=lv_VI97HAXpY6yYWAAAC
< HTTP/1.1 101 Switching Protocols
WebSocket 帧
< 2probe => Engine.IO probe request
> 3probe => Engine.IO probe response
> 5 => Engine.IO "upgrade" packet type
> 42["hello"]
> 42["world"]
> 40/admin, => request access to the admin namespace (Socket.IO "CONNECT" packet)
< 40/admin,{"sid":"-G5j-67EZFp-q59rADQM"} => grant access to the admin namespace
> 42/admin,1["tellme"] => Socket.IO "EVENT" packet with acknowledgement
< 461-/admin,1[{"_placeholder":true,"num":0}] => Socket.IO "BINARY_ACK" packet with a placeholder
< <binary> => the binary attachment (sent in the following frame)
... after a while without message
> 2 => Engine.IO "ping" packet type
< 3 => Engine.IO "pong" packet type
> 1 => Engine.IO "close" packet type
历史
v5 和 v4 之间的区别
Socket.IO 协议的第 5 版(当前)用于 Socket.IO v3 及更高版本(v3.0.0
于 2020 年 11 月发布)。
它建立在 Engine.IO 协议 的第 4 版之上(因此 EIO=4
查询参数)。
更改列表
- 删除对默认命名空间的隐式连接
在以前的版本中,即使客户端请求访问另一个命名空间,它也始终连接到默认命名空间。
现在不再是这种情况,客户端必须在任何情况下都发送一个 CONNECT
数据包。
- 将
ERROR
重命名为CONNECT_ERROR
含义和代码编号(4)没有修改:服务器在拒绝连接到命名空间时仍然使用此数据包类型。但我们认为这个名字更具描述性。
提交:d16c035(服务器)和 13e1db7c(客户端)。
CONNECT
数据包现在可以包含有效负载
客户端可以发送有效负载以进行身份验证/授权。示例
{
"type": 0,
"nsp": "/admin",
"data": {
"token": "123"
}
}
如果成功,服务器将响应一个包含 Socket ID 的有效负载。示例
{
"type": 0,
"nsp": "/admin",
"data": {
"sid": "CjdVH4TQvovi1VvgAC5Z"
}
}
此更改意味着 Socket.IO 连接的 ID 现在将不同于底层 Engine.IO 连接的 ID(在 HTTP 请求的查询参数中找到的 ID)。
CONNECT_ERROR
数据包的有效负载现在是一个对象,而不是一个普通字符串
v4 和 v3 之间的区别
Socket.IO 协议的第 4 版用于 Socket.IO v1(v1.0.3
于 2014 年 6 月发布)和 v2(v2.0.0
于 2017 年 5 月发布)。
修订的详细信息可以在这里找到:https://github.com/socketio/socket.io-protocol/tree/v4
它建立在 Engine.IO 协议的第三版 之上(因此有 EIO=3
查询参数)。
更改列表
- 添加
BINARY_ACK
包类型
以前,ACK
包总是被视为可能包含二进制对象,并对这些对象进行递归搜索,这会影响性能。
参考:https://github.com/socketio/socket.io-parser/commit/ca4f42a922ba7078e840b1bc09fe3ad618acc065
v3 和 v2 之间的区别
Socket.IO 协议的第三版用于早期 Socket.IO v1 版本([email protected]
)(于 2014 年 5 月发布)。
该版本的详细信息可以在此处找到:https://github.com/socketio/socket.io-protocol/tree/v3
更改列表
- 删除使用 msgpack 对包含二进制对象的包进行编码(另请参见 299849b)。
v2 和 v1 之间的区别
更改列表
- 添加
BINARY_EVENT
包类型
这是在为 Socket.IO 1.0 工作期间添加的,目的是为了添加对二进制对象的支持。BINARY_EVENT
包使用 msgpack 进行编码。
初始版本
这个第一个版本是 Engine.IO 协议(使用 WebSocket/HTTP 长轮询、心跳的底层管道)和 Socket.IO 协议分离的结果。它从未包含在 Socket.IO 版本中,但为后续迭代铺平了道路。
测试套件
test-suite/
目录中的测试套件允许您检查服务器实现的合规性。
用途
- 在 Node.js 中:
npm ci && npm test
- 在浏览器中:只需在浏览器中打开
index.html
文件即可
作为参考,以下是通过所有测试的 JavaScript 服务器的预期配置
import { Server } from "socket.io";
const io = new Server(3000, {
pingInterval: 300,
pingTimeout: 200,
maxPayload: 1000000,
cors: {
origin: "*"
}
});
io.on("connection", (socket) => {
socket.emit("auth", socket.handshake.auth);
socket.on("message", (...args) => {
socket.emit.apply(socket, ["message-back", ...args]);
});
socket.on("message-with-ack", (...args) => {
const ack = args.pop();
ack(...args);
})
});
io.of("/custom").on("connection", (socket) => {
socket.emit("auth", socket.handshake.auth);
});