【译】Socket.IO协议

2021-04-28 创建
2021-04-28 更新
9分钟阅读时长

前言

Socket.IO是一个优秀的JavaScript实时通信框架。它提供基于事件的双向流通信模型,底层封装了Websocket和HTTP long-polling。

Socket.IO的设计基于它定义的标准协议,本质上是语言无关的。因此除了官方提供的NodeJS版本之外,也有其它语言的开源实现,例如:go-socket.io

笔者在工作中曾基于go-socket.io完成音视频信令服务器的相关功能,对框架的优秀能力有比较深刻的直观感受。因此本文通过对Socket.IO协议的翻译,旨在进一步理解Socket.IO的底层实现原理。

另一点比较遗憾的是,go-socket.io支持的协议版本远落后于官方的NodeJS实现,并且该开源项目暂时没有太大进展。因此笔者也希望通过加深对新版本协议的理解,来尝试进一步完善go-socket.io。

协议原文

协议原文的地址:socket.io-protocol

协议译文

Socket.IO 协议

本文描述了Socket.IO协议。可参考基于JavaScript语言的具体实现:socket.io-parsersocket.io-clientsocket.io

协议版本

这是Socket.IO协议的第5个版本,在socket.io@3.0.0...latest中已经实现。

第4个版本详见:https://github.com/socketio/socket.io-protocol/tree/v4

第3个版本详见:https://github.com/socketio/socket.io-protocol/tree/v3

第1个版本和第2个版本的协议包括在Socket.IO 1.0的工作内容当中,但并没有发布过正式的Release协议版本。

本协议是构建在Engine.IO协议的第4个版本基础之上。

Engine.IO描述的是一个基于Websocket和HTTP long-polling更底层的基础传输系统,而Socket.IO则是在它之上的一层封装。Socket.IO支持如下的功能:

  • 多路复用(名称空间)

JavaScript API示例:

// server-side
const nsp = io.of("/admin");
nsp.on("connect", socket => {});
// client-side
const socket1 = io(); // main namespace
const socket2 = io("/admin");
socket2.on("connect", () => {});
  • Packet的ACK

JavaScript API示例:

// on one side
socket.emit("hello", 1, () => { console.log("received"); });
// on the other side
socket.on("hello", (a, cb) => { cb(); });

Packet格式

一个Packet包括以下字段:

  • 类型(整数,详见下文)
  • 名称空间(字符串)
  • 可选字段:Packet内容(对象 | 数组)
  • 可选字段:ACK的ID(整数)

Packet类型

0-CONNECT

该事件发生时机:

  • 当客户端连接一个名称空间。客户端会发送一个用于鉴权的payload,例如:
{
  "type": 0,
  "nsp": "/admin",
  "data": {
    "token": "123"
  }
}
  • 当服务端接受来自一个名称空间的连接。请求成功后,服务端会响应一个带有Socket ID的payload,例如:
{
  "type": 0,
  "nsp": "/admin",
  "data": {
    "sid": "CjdVH4TQvovi1VvgAC5Z"
  }
}

1-DISCONNECT

该事件发生在一端想要断开名称空间的连接时。它不包括任何payload和ACK ID,例如:

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

2-EVENT

该事件发生在一端想要给另一端传输数据(不包括二进制数据)时。它包括payload,可能还会包括ACK ID,例如:

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

包括ACK ID的样例:

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

3-ACK

该事件发生在一端接收到EVENT事件或带有ACK ID的BINARY_EVENT事件。它包括对之前这个事件的ACK ID,可能还会包括payload(不包括二进制数据),例如:

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

4-CONNECT_ERROR

该事件发生在服务端拒绝一个名称空间的连接时。它包括一个"message"字段,可能还会包括一个"data"字段,例如:

{
  "type": 4,
  "nsp": "/admin",
  "data": {
    "message": "Not authorized",
    "data": {
      "code": "E001",
      "label": "Invalid credentials"
    }
  }
}

5-BINARY_EVENT

注意:BINARY_EVENT和BINARY_ACK都用于内建的解析器中,为了区别出包中是否包括二进制内容。它们不会用于其他自定义解析器中。

该事件发生在一端想要给另一端传输数据(包括二进制数据)时。它包括payload,可能还会包括ACK ID,例如:

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

包括ACK ID的样例:

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

6-BINARY_ACK

该事件发生在一端接收到EVENT事件或带有ACK ID的BINARY_EVENT事件。它包括对之前这个事件的ACK ID,可能还会包括payload(包括二进制数据),例如:

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

Packet编码

本小节描述了Socket.IO 客户端和服务端之间默认解析器的编码细节,源码实现参考:这里

JavaScript服务端和客户端也支持自定义解析器,适用于不同场景。具体可以参考:socket.io-json-parsersocket.io-msgpack-parser

另外注意一点:Socket.IO的packet本质上是Engine.IOmessage类型的packet(关于Engine.IO参考这里),所以编码结果发送时候会带上4这个数字前缀。(在HTTP long-polling的请求和响应包体中,或者在Websocket的数据帧中。)

编码格式

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

+ binary attachments extracted

注意:

  • 当名称空间不是默认的/时候才会出现在编码格式中。

编码样例

  • /名称空间CONNECT
{
  "type": 0,
  "nsp": "/",
  "data": {
    "token": "123"
  }
}

编码为:0{"token":"123"}

  • /admin名称空间的CONNECT
{
  "type": 0,
  "nsp": "/admin",
  "data": {
    "token": "123"
  }
}
```json
编码为`0/admin,{"token":"123"}`

* `/admin`名称空间的`DISCONNECT`
```json
{
  "type": 1,
  "nsp": "/admin"
}

编码为1/admin

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

编码为2["hello",1]

  • 带ACK 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[]

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

编码为4/admin,{"message":"Not authorized"}

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

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

  • 带ACK 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>

协议交互

连接名称空间

对于每个名称空间(包括主名称空间),客户端首先发送一个CONNECT,服务端回复一个带有Socket ID的CONNECT

Client > { type: CONNECT, nsp: "/admin" }
Server > { type: CONNECT, nsp: "/admin", data: { sid: "wZX3oN0bSVIhsaknAAAI" } } (if the connection is successful)
or
Server > { type: CONNECT_ERROR, nsp: "/admin", data: { message: "Not authorized" } }

名称空间断连

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

反之亦然。同时另外一端无需任何消息回复。

ACK

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 }

反之亦然。

会话示例

这里有一份包括了Socket.IO和Engine.IO的会话示例。

  • Request n°1 (open packet)
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}

细节:

0           => Engine.IO "open" packet type
{"sid":...  => the Engine.IO handshake data

注意:参数t用于防止浏览器缓存该请求。

  • Request n°2 (namespace connection request)
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
  • Request n°3 (namespace connection approval)
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"}
  • Request 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
  • Request n°5 (message out)

客户端会执行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
  • Request n°6 (WebSocket upgrade)
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的差异

  • 移除默认名称空间的连接

在之前的版本中,客户端总是会跟默认名称空间建连,即使是另一个名称空间的请求。现在客户端必须发送一个CONNECT

git commit:09b6f23(服务端)和 249e0be(客户端)

  • ERROR重命名为CONNECT_ERROR

语义和数值4并没有改变:该packet类型依然是被用在服务端拒绝一个名称空间的连接上面。只是我们认为新的名称更合适。

git commit:d16c035 (服务端) 和 13e1db7c (客户端)。

  • CONNECT现在包含payload

客户端可以发送一个包含授权目的的payload。例如:

{
  "type": 0,
  "nsp": "/admin",
  "data": {
    "token": "123"
  }
}

假设结果成功,那么服务端会返回包含Socket ID的payload。例如:

{
  "type": 0,
  "nsp": "/admin",
  "data": {
    "sid": "CjdVH4TQvovi1VvgAC5Z"
  }
}

这个变更意味着Socket.IO的连接ID现在和底层Engine.IO的连接ID不是同一个(可以在HTTP请求的query参数中看到)。

git commit:2875d2c (服务端) 和 bbe94ad (客户端)

  • CONNECT_ERROR现在是一个对象,而不再是一个原生字符串。

git commit:54bf4a4 (服务单) 和 0939395 (客户端)

v4和v3的差异

  • 新增BINARY_ACK

过去的版本中,ACK总要假设它包括二进制数据,对于性能上面有一些损失。

v3和v2的差异

  • 移除msgpack编码二进制数据包的功能。(参考299849b

v2和v1的差异

  • 新增BINARY_EVENT

这是在Socket.IO 1.0版本的实现中为了支持二进制数据引入的。BINARY_EVENT是由msgpack

最初版本

最初的版本将Socket.IO和Engine.IO分离开。它虽然没有被某个release的Socket.IO框架实现,但给后面的迭代铺平了道路。

版权

MIT

参考

  1. RFC6455: The Websocket Protocol

总结

本文是对Socket.IO协议原文的译文,旨在深入学习该框架的实现原理。

Avatar
吴国华 Go语言/微服务/后端/云原生/技术管理