EngineIO Protocol
Engine.IO是Socket.IO更底层的实现基础,要想完整理解Socket.IO必须对Engine.IO协议也有深刻认知。
Engine.IO session详解
传输层与Engine.IO URL建立一个连接。
服务端返回一个JSON格式的
1
OPEN
握手数据,包括如下内容:
sid
session id(字符串)upgrades
可能有传输升级(字符串数组)pingTimeout
服务端需配置ping超时时间,用于客户端判断服务端是否不可达。(数字)pingInterval
服务端需配置ping间隔时间,用于客户端判断服务端是否不可达。(数字)
客户端必须通过
pong
周期性回复服务端的ping
。客户端和服务端可以在任意时刻交换
message
。Polling方式的传输允许发送
close
来关闭socket,因为预期中会一直处于”opening”和”closing”的状态。
会话示例
- Request n°1 (open packet)
1 | GET /engine.io/?EIO=4&transport=polling&t=N8hyd6w |
细节:
1 | 0 => "open" packet type |
注意:参数t
用于防止浏览器缓存该请求。
- Request n°2 (message in)
服务端会执行socket.send('hey')
:
1 | GET /engine.io/?EIO=4&transport=polling&t=N8hyd7H&sid=lv_VI97HAXpY6yYWAAAC |
细节:
1 | 4 => "message" packet type |
注意:sid
参数包括握手数据中的sid。
- Request n°3 (message out)
客户端将会执行socket.send('hello'); socket.send('world');
:
1 | POST /engine.io/?EIO=4&transport=polling&t=N8hzxke&sid=lv_VI97HAXpY6yYWAAAC |
细节:
1 | 4 => "message" packet type |
- Request n°4 (WebSocket upgrade)
1 | GET /engine.io/?EIO=4&transport=websocket&sid=lv_VI97HAXpY6yYWAAAC |
Websocket数据帧:
1 | < 2probe => probe request |
Websocket 会话示例
这种情况下,客户端只启用Websocket(没有HTTP polling)。
1 | GET /engine.io/?EIO=4&transport=websocket |
Websocket数据帧:
1 | < 0{"sid":"lv_VI97HAXpY6yYWAAAC","pingInterval":25000,"pingTimeout":5000} => handshake |
路由
Engine.IO路由组成如下:
1 | /engine.io/[?<query string>] |
engine.io
的路径名仅可以被基于engine.io上层协议的框架修改- 查询字符串是可选的,有6个保留字:
transport
:表明传输方式。默认支持polling
,websocket
。j
:如果传输方式是polling
且返回值必须是JSONP类型,则j
必须设置为JSONP返回值的index。sid
:如果客户端要加session id,必须放在查询字符串上。EIO
:Engine.IO的版本号。t
:时间戳的hash值,用来防止浏览器缓存。
FAQ:/engine.io
这部分路由是否可修改? 可以,服务端在不同的子路径下拦截请求。
FAQ:是什么因素决定一个选型出现在编码了的查询字符串上?换句话说,为什么transport
字段不在路由上面呢? 约定是子路径仅用于让Engine.IO的服务端消除是否要处理这个请求的歧义。这当然只有Engine.IO的前缀(/engine.io
)。
编码方式
有两种不同的编码方式:
- packet
- Payload
Packet
一个编码的packet可以是UTF-8的字符串,也可以是二进制数据。字符串形式的编码packet如下:
1 | <packet type id>[<data>] |
例如:
1 | 4hello |
对于二进制数据来说,无需包含packet类型,因为只有message
可以包括二进制数据。
0 open
当新的传输通道打开会从服务端发送。
1 close
请求关闭此传输通道但不会立刻关闭连接。
2 ping
由服务端发送,客户端回复pong包。
样例:
- server sends:
2
- client sends:
3
3 pong
客户端发送用于回复ping包。
4 message
消息体,客户端和服务端可以通过它们的回调接口传输数据
样例一:
- 服务端发送:
4HelloWorld
- 客户端接收并触发回调:
socket.on('message', function (data) { console.log(data); });
样例二:
- 客户端发送:
4HelloWorld
- 服务端接收并触发回调:
socket.on('message', function (data) { console.log(data); });
5 upgrade
在engine.io切换传输方式之前,它需要测试服务端和客户端之间是否允许这种传输方式。如果测试成功,客户端将会发送一个upgrade包给服务端并要求它刷新旧传输方式的缓存并切换到新的传输方式。
6 noop
noop包主要用于当一个websocket连接将要建立时候强制一轮poll循环。
样例:
- 客户端通过新的传输方式连接
- 客户端发送:
2probe
- 服务端接收并发送:
3probe
- 客户端接收并发送:
5
- 服务端刷新环境,关闭旧传输方式并切换至新的传输方式
Payload
Payload是一系列packet捆绑在一起。当仅支持字符串且XHR2不支持的情况下,payload编码格式如下:
1 | <packet1>\x1e<packet2>\x1e<packet3> |
packet通过(‘\x1e’)分割,更多信息请参考: https://en.wikipedia.org/wiki/C0_and_C1_control_codes#Field_separators
当payload支持二进制数据时,它会通过发送base64编码的字符串。为了便于解码,二进制格式的packet前会插入字符b
。任意数量的字符串和base64编码的字符串可以被聚合发送。下面是base64编码消息的样例:
1 | <packet1>\x1eb<packet2 data in b64>[...] |
payload用于不支持帧方式的传输,比如polling协议。
- 无二进制数据样例:
1 | [ |
被编码为:
1 | 4hello\x1e4€ |
- 二进制数据样例:
1 | [ |
被编码为:
1 | 4€\x1ebAQIDBA== |
传输方式
Engine.IO的服务端必须支持三种传输方式:
- websocket
- server-sent events (SSE)
- Polling
- Jsonp
- Xhr
Polling
polling方式包括客户端循环GET请求访问服务端获取数据,以及客户端POST请求传输数据给服务端。
XHR
服务端必须支持跨域。
JSONP
服务端的实现必须回复合法的JavaScript。URL上面必须包含查询字符串参数j
,并必须被应用在返回体中。j
是一个整数:
JSONP格式:
1 | `___eio[` <j> `]("` <encoded payload> `");` |
为了确保payload被正确处理,它的转义也必须符合合法的JavaScript。使用一个JSON编码器是一个发送编码payload的良好转义方法。
如下是服务端返回的JSONP样例:
1 | ___eio[4]("packet data"); |
上传数据
客户端通过一个隐藏的iframe
上传数据。到服务端的数据在URI中的编码格式如下:
1 | d=<escaped packet payload> |
此外根据标准转义规范,为了防止浏览器对\n
处理的不一致,\n
会在POST中会转义为\\n
。
Server-sent events
客户端使用一个EventSource的对象用于接收数据,使用一个XMLHttpRequest的对象用于发送数据。
Websocket
payload编码不适用websocket,因为协议本身已经具有一个轻量级的数据帧机制。
发送payload类型的消息,只需要单独编码并连续调用send()
发送。
传输升级
一个连接总是要从polling方式开始(XHR或者JSONP)。websocket要通过发送一个探测开始。加入服务端对探测有返回结果,则会发送一个upgrade包。
为了确保没有消息丢失,upgrade包在当前存在的传输通道缓存中仅会被发送一次,且此时传输被认为是处于暂停状态。
当服务端接收到upgrade包,它必须假设这是新的传输通道,并且发送当前以及存在的通道中所有的缓存内容。
客户端发送的探测是由ping
包类型和probe
字符串拼接成的数据。服务端返回的探测是由pong
包类型和probe
字符串拼接成的数据。
进一步说,升级只会考虑polling -> x
。
超时时间
客户端必须使用pingTimeout
和pingInterval
作为握手的部分(带着open
包)来判断服务端是否断开。
服务端发送一个ping
包。如果在pingTimeout
之内没有接收到包类型,则服务端会认为socket已经断开连接。如果pong
包返回并接收成功的话,服务端会等待pingInterval
之后再发送下一个ping
包。
由于两个值在服务端和客户端是共享的,客户端也可以通过它们来判断服务端是否断开连接当它没有在pingTimeout+pingInterval
之内收到任何数据。
SocketIO Protocol
大部分内容引自出处: blog。开源社区介绍 github socket.io
Packet格式
一个Packet包括以下字段:
- 类型(整数,详见下文)
- 名称空间(字符串)
- 可选字段:Packet内容(对象 | 数组)
- 可选字段:ACK的ID(整数)
Packet类型
0-CONNECT
该事件发生时机:
- 当客户端连接一个名称空间。客户端会发送一个用于鉴权的payload,例如:
1 | { |
- 当服务端接受来自一个名称空间的连接。请求成功后,服务端会响应一个带有Socket ID的payload,例如:
1 | { |
1-DISCONNECT
该事件发生在一端想要断开名称空间的连接时。它不包括任何payload和ACK ID,例如:
1 | { |
2-EVENT
该事件发生在一端想要给另一端传输数据(不包括二进制数据)时。它包括payload,可能还会包括ACK ID,例如:
1 | { |
包括ACK ID的样例:
1 | { |
3-ACK
该事件发生在一端接收到EVENT事件或带有ACK ID的BINARY_EVENT事件。它包括对之前这个事件的ACK ID,可能还会包括payload(不包括二进制数据),例如:
1 | { |
4-CONNECT_ERROR
该事件发生在服务端拒绝一个名称空间的连接时。它包括一个”message”字段,可能还会包括一个”data”字段,例如:
1 | { |
5-BINARY_EVENT
注意:BINARY_EVENT和BINARY_ACK都用于内建的解析器中,为了区别出包中是否包括二进制内容。它们不会用于其他自定义解析器中。
该事件发生在一端想要给另一端传输数据(包括二进制数据)时。它包括payload,可能还会包括ACK ID,例如:
1 | { |
包括ACK ID的样例:
1 | { |
6-BINARY_ACK
该事件发生在一端接收到EVENT事件或带有ACK ID的BINARY_EVENT事件。它包括对之前这个事件的ACK ID,可能还会包括payload(包括二进制数据),例如:
1 | { |
Packet编码
本小节描述了Socket.IO 客户端和服务端之间默认解析器的编码细节,源码实现参考:这里。
另外注意一点:Socket.IO的packet本质上是Engine.IOmessage
类型的packet(关于Engine.IO参考这里),所以编码结果发送时候会带上4
这个数字前缀。(在HTTP long-polling的请求和响应包体中,或者在Websocket的数据帧中。)
编码格式
1 | <packet type>[<# of binary attachments>-][<namespace>,][<acknowledgment id>][JSON-stringified payload without binary] |
注意:
- 当名称空间不是默认的
/
时候才会出现在编码格式中。
编码样例
/
名称空间CONNECT
1 | { |
编码为:0{"token":"123"}
/admin
名称空间的CONNECT
1 | { |
编码为0/admin,{"token":"123"}
/admin
名称空间的DISCONNECT
1 | { |
编码为1/admin
EVENT
1 | { |
编码为2["hello",1]
- 带ACK ID的
EVENT
1 | { |
编码为2/admin,456["project:delete",123]
ACK
1 | { |
编码为3/admin,456[]
CONNECT_ERROR
1 | { |
编码为4/admin,{"message":"Not authorized"}
BINARY_EVENT
1 | { |
编码为51-["hello",{"_placeholder":true,"num":0}] + <Buffer 01 02 03>
- 带ACK ID的
BINARY_EVENT
1 | { |
编码为51-/admin,456["project:delete",{"_placeholder":true,"num":0}] + <Buffer 01 02 03>
BINARY_ACK
1 | { |
编码为61-/admin,456[{"_placeholder":true,"num":0}] + <Buffer 03 02 01>
协议交互
连接名称空间
对于每个名称空间(包括主名称空间),客户端首先发送一个CONNECT
,服务端回复一个带有Socket ID的CONNECT
。
1 | Client > { type: CONNECT, nsp: "/admin" } |
名称空间断连
1 | Client > { type: DISCONNECT, nsp: "/admin" } |
反之亦然。同时另外一端无需任何消息回复。
ACK
1 | Client > { type: EVENT, nsp: "/admin", data: ["hello"], id: 456 } |
反之亦然。
会话示例
这里有一份包括了Socket.IO和Engine.IO的会话示例。
- Request n°1 (open packet)
1 | GET /socket.io/?EIO=4&transport=polling&t=N8hyd6w |
细节:
1 | 0 => Engine.IO "open" packet type |
注意:参数t
用于防止浏览器缓存该请求。
- Request n°2 (namespace connection request)
1 | POST /socket.io/?EIO=4&transport=polling&t=N8hyd7H&sid=lv_VI97HAXpY6yYWAAAC |
细节:
1 | 4 => Engine.IO "message" packet type |
- Request n°3 (namespace connection approval)
1 | GET /socket.io/?EIO=4&transport=polling&t=N8hyd7H&sid=lv_VI97HAXpY6yYWAAAC |
- Request n°4
服务端会执行socket.emit('hey', 'Jude')
1 | GET /socket.io/?EIO=4&transport=polling&t=N8hyd7H&sid=lv_VI97HAXpY6yYWAAAC |
细节:
1 | 4 => Engine.IO "message" packet type |
- Request n°5 (message out)
客户端会执行socket.emit('hello'); socket.emit('world');
1 | POST /socket.io/?EIO=4&transport=polling&t=N8hzxke&sid=lv_VI97HAXpY6yYWAAAC |
细节:
1 | 4 => Engine.IO "message" packet type |
- Request n°6 (WebSocket upgrade)
1 | GET /socket.io/?EIO=4&transport=websocket&sid=lv_VI97HAXpY6yYWAAAC |
WebSocket数据帧:
1 | < 2probe => Engine.IO probe request |
网络电话示例:
1 | 420["netPhone", {msgId: "fe9b9760-0b15-409f-a497-1909e53355d4", signal: "call",…}] |