WebSocket 基礎與應用系列(二)—— Engine-IO 原理了解
本系列第一篇《WebSocket 基礎與應用系列(一)—— 抓個 WebSocket 的包》,沒看過的同學可以看看,看過的同學也可以回顧一把。
1、WebSocket、 Engine.IO、 Socket.IO 之間的關係
WebSocket 是一種在單個 TCP 連接上進行全雙工通信的協議。WebSocket 使得客戶端和服務器之間的數據交換變得更加簡單,允許服務端主動向客戶端推送數據。在 WebSocket API 中,瀏覽器和服務器只需要完成一次握手,兩者之間就直接可以創建持久性的連接,並進行雙向數據傳輸。
Socket.IO 在 Socket.IO server (Node.js) 和 Socket.IO client ( browser, Node.js, or another programming language ) 之間,基於 WebSocket ( 不支持 WebSocket 的情況下,退化成 HTTP long-polling ) 建立一條全雙工實時通信通道.
Engine.IO 是一個 Socket.IO 的抽象實現,作爲 Socket.IO 的服務器和瀏覽器之間交換的數據的傳輸層。它不會取代 Socket.IO,它只是抽象出固有的複雜性,支持多種瀏覽器,設備和網絡的實時數據交換。Engine.IO 使用了 Websocket 和 HTTP long-polling 方式封裝了一套 socket 協議。爲了兼容不支持 Websocket 的低版本瀏覽器,使用長輪詢 (polling) 替代 WebSocket。
2、Engine.IO 支持的功能
Engine.IO 負責在服務器和客戶端之間建立底層連接。包括以下功能:
-
多種傳輸通道及升級機制
-
斷連檢測
2.1、傳輸通道
現在主要有 2 種傳輸通道實現
-
HTTP long-polling
-
WebSocket
2.1.1、HTTP long-polling
HTTP long-polling transport (也簡稱 "polling") 由連續的 HTTP requests 組成:
-
long-running GET requests, for receiving data from the server
-
short-running POST requests, for sending data to the server
基於 HTTP long-polling transport 的特性,連續的 emits 可能合併在一個 HTTP Request 中發送。
2.1.2、WebSocket
The WebSocket 傳輸通道 包含一條 WebSocket 連接,WebSocket 提供了服務端和客戶端之間雙向通信及低時延的通信通道。
基於傳輸通道特性,每個 emit 會以一個 WebSocket 數據幀發送,有時候會分爲 2 個不同的數據幀發送。
2.2、Handshake
Engine.IO 連接建立的時候, Server 端會發送一些消息到客戶端:
{
"sid": "FSDjX-WRwSA4zTZMALqx",
"upgrades": ["websocket"],
"pingInterval": 25000,
"pingTimeout": 20000
}
-
sid: 是 session 的 ID,在所有的子序列 HTTP Request 中都會在參數帶上這個 sid.
-
upgrades: upgrades array 包含了服務端可以支持的更好的 transport.
-
pingInterval 和 pingTimeout:用於心跳機制.
2.3、升級機制
默認的情況下,客戶端先建立 HTTP long-polling 通信通道。
爲什麼呢?
WebSocket 無疑是最好的雙向通道,但是由於公司的代理、個人的防火牆、殺毒軟件等,它並不是在什麼情況下都能成功建立。
從用戶的角度來看,如果 WebSocket 連接建立失敗,那麼用戶至少要等 10S 才能開始真正的數據傳輸,這無疑傷害了用戶的體驗。
總的來說,Engine.IO 首先關注可靠性和用戶體驗,其次纔是服務器性能。
升級的時候,客戶端會做如下動作:
-
保證要發送的隊列中是空的
-
把當前的傳輸通道設爲只讀
-
使用另外的 transport 建立新的連接
-
如果新傳輸通道建立成功,關掉第一條傳輸通道
可以在瀏覽器抓包看到如下網絡連接:
-
握手協議 (contains the session ID — here, zBjrh...AAAK — that is used in subsequent requests)
-
發送數據 (HTTP long-polling)
-
接收數據 (HTTP long-polling)
-
升級協議 (WebSocket)
-
接收數據 (HTTP long-polling, closed once the WebSocket connection in 4. is successfully established)
2.4、斷連檢測
當以下情況出現時,Engine.IO 的連接會判斷爲關閉。
-
一次 HTTP request (either GET or POST) 失敗 (比如服務器掛了)
-
WebSocket 連接關閉 (比如用戶關閉了瀏覽器的 tab)
-
在服務端或者客戶端調用 socket.disconnect ()
-
還有一個心跳機制用來檢測服務端和客戶端的連接是否正常在運行。
服務端會以 pingInterval 的間隔發送 PING 數據包,客戶端收到後在 pingTimeout 時間之內需要發送 PONG 數據包給服務端,如果服務端在 pingTimeout 時間內沒有收到,那麼就認爲這條連接關閉了。相反,客戶端如果在 pingInterval + pingTimeout 時間內沒有收到 PING 數據包,客戶端也判斷連接關閉。
服務端觸發斷連事件的原因有:
客戶端觸發斷連事件的原因有:
3、Engine.IO 的協議
3.1 一次 Engine.IO 會話
-
傳輸通道通過 Engine.IO URL 進行連接建立
-
連接建立之後,服務端會發一個 JSON 格式的握手數據
-
sid:會話 id (string)
-
upgrades: 允許升級的傳輸通道 (Array of String)
-
pingTimeout: 服務端配置的 ping 超時時間,發送給客戶端,客戶端用來檢測服務端是否還正常響應 (Number)
-
pingInterval: 服務端配置的心跳間隔,客戶端用來檢測服務端是否還正常響應 (Number)
-
客戶端收到服務端定時的 ping packet 之後,需要回復客戶端 pong packet
-
客戶端和服務端之間可以傳輸 message packets
-
Polling transports 可以發送 close packet 來關閉 socket
會話例子
- Request n°1 (open packet)
GET /engine.io/?EIO=4&transport=polling&t=N8hyd6w
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=UTF-8
0{"sid":"N-YWtQT1K9uQsb15AAAD","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":5000}
Details:
0 => "open" packet type
{"sid":... => the handshake data
Note: query 參數中的 t 是用來防止瀏覽器緩存請求.
- Request n°2 (message in)
服務端執行 socket.send ('hey') :
GET /engine.io/?EIO=4&transport=polling&t=N8hyd7H&sid=lv_VI97HAXpY6yYWAAAC
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=UTF-8
4hey
Details:
4 => "message" packet type
hey => the actual message
Note: query 中的 sid 是握手協議中 sid.
- Request n°3 (message out)
客戶端執行:socket.send ('hello'); socket.send ('world');
POST /engine.io/?EIO=4&transport=polling&t=N8hzxke&sid=lv_VI97HAXpY6yYWAAAC
> Content-Type: text/plain; charset=UTF-8
4hello\x1e4world
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=UTF-8
ok
Details:
4 => "message" packet type
hello => the 1st message
\x1e => separator
4 => "message" message type
world => the 2nd message
- Request n°4 (WebSocket upgrade)
GET /engine.io/?EIO=4&transport=websocket&sid=lv_VI97HAXpY6yYWAAAC
< HTTP/1.1 101 Switching Protocols
WebSocket frames:
< 2probe => probe request
> 3probe => probe response
< 5 => "upgrade" packet type
> 4hello => message (not concatenated)
> 4world
> 2 => "ping" packet type
< 3 => "pong" packet type
> 1 => "close" packet type
只有 WebSocket 連接的會話
在這個例子中,客戶端只開啓了 WebSocket 傳輸通道 (without HTTP polling).
GET /engine.io/?EIO=4&transport=websocket
< HTTP/1.1 101 Switching Protocols
WebSocket frames:
< 0{"sid":"lv_VI97HAXpY6yYWAAAC","pingInterval":25000,"pingTimeout":5000} => handshake
< 4hey
> 4hello => message (not concatenated)
> 4world
< 2 => "ping" packet type
> 3 => "pong" packet type
> 1 => "close" packet type
3.2 URLs
Engine.IO url 包含了以下內容
/engine.io/[?<query string>]
-
engine.io 路徑名只能由基於 Engine.io 協議之上的的更高級別框架更改,如 Socket.io.
-
query string 是可選的,有 6 個保留的 key:
-
transport: 指定的 transport, 默認爲 polling, websocket.
-
j: 如果需要 JSONP 響應,j 必須與 JSONP 響應索引一起設置。
-
sid: 如果客戶端已經收到 session id,那麼每次請求的 query string 中都必須帶上 sid
-
EIO: 協議的版本
-
t: 用來防止瀏覽器緩存
3.3 編碼
有兩種不同類型的編碼
-
packet
-
payload
3.3.1 Packet
一個編碼的數據包可以是 UTF-8 字符串或者二進制數據。字符串的數據包編碼格式如下:
<packet type id>[<data>]
example:
4hello
對於二進制數據,不包括數據包類型(packet type),因爲只有 “message” 數據包類型可以包括二進制數據。
packet type
- 0 open
新傳輸通道建立的時候,從服務端發送 Sent from the server when a new transport is opened (recheck)
- 1 close
請求關閉此傳輸,但不關閉連接本身。
- 2 ping
由服務器發送。客戶應該用 pong 數據包應答。
example
server sends: 2
client sends: 3
3 pong
由客戶端發送以響應 ping 數據包。
4 message
實際傳輸的消息
example 1
server sends: 4HelloWorld
client receives and calls callback socket.on('message', function (data) { console.log(data); });
example 2
client sends: 4HelloWorld
server receives and calls callback socket.on('message', function (data) { console.log(data); });
5 upgrade
在 engine.io 切換傳輸通道之前,它測試服務器和客戶端是否可以通過該傳輸進行通信。如果此測試成功,客戶端將發送一個升級包,請求服務器刷新舊傳輸上的緩存,並切換到新傳輸通道。
6 noop
一個 noop 包。主要用於建立 websocket 連接之後關閉長輪詢。
example
client connects through new transport
client sends 2probe
server receives and sends 3probe
client receives and sends 5
server flushes and closes old transport and switches to new.
3.3.2 Payload
Payload 是捆綁在一起的一系列 encoded packets。Payload 編碼格式如下:
<packet1>\x1e<packet2>\x1e<packet3>
數據包分割符使用 record separator ('\x1e'). 更多可參考: https://en.wikipedia.org/wiki/C0_and_C1_control_codes#Field_separators
當有效負載中包含二進制數據時,它將作爲 base64 編碼字符串發送。爲了解碼的目的,將標識符 b 置於包含二進制數據的分組編碼之前。可以發送任意數量的字符串和 base64 編碼字符串的組合。下面是 base 64 編碼消息的示例:
<packet1>\x1eb<packet2 data in b64>[...]
Payload 用於不支持幀的傳輸通道,例如輪詢協議。
不包含二進制的例子:
[
{
"type": "message",
"data": "hello"
},
{
"type": "message",
"data": "€"
}
]
編碼後:
4hello\x1e4€
包含二進制的例子:
[
{
"type": "message",
"data": "€"
},
{
"type": "message",
"data": buffer <01 02 03 04>
}
]
編碼後:
4€\x1ebAQIDBA==
分解:
4 => "message" packet type
€
\x1e => record separator
b => indicates a base64 packet
AQIDBA== => buffer content encoded in base64
3.4 傳輸通道
engine.io server 必須支持三種傳輸通道:
-
websocket
-
server-sent events (SSE)
-
polling
-
jsonp
-
xhr
3.4.1 Polling
輪詢傳輸包括客戶端向服務器發送週期性 GET 請求以獲取數據,以及將帶有有效負載的請求從客戶端發送到服務器以發送數據。
XHR
服務器必須支持 CORS 響應。
JSONP
服務器實現必須使用有效的 JavaScript 進行響應。在響應中需要使用 URL 中 query 中的 j 參數。j 是一個整數。
JSONP 數據包的格式。
`___eio[` <j> `]("` <encoded payload> `");`
爲了確保 payload 得到正確處理,需要對 payload 進行轉義,使得響應體是一個合法的 JavaScript。
服務器返回的 JSONP 數據幀的例子
___eio[4]("packet data");
Posting data
客戶端通過隱藏的 iframe 發送數據。數據以 URI 編碼格式發送給服務器,如下所示
d=<escaped packet payload>
除了常規的 qs 轉義之外,爲了防止瀏覽器處理的不一致,\n 在被 POSTd 之前將被轉義爲 \n。
3.4.2 Server-sent events
客戶端使用 EventSource 對象接收數據,使用 XMLHttpRequest 對象發送數據。
3.4.3 WebSocket
上面的對 payloads 的編碼方式並不用於 WebSocket 通道,WebSocket 通道本身已有輕量級的數據幀機制。
發送消息的時候,對數據包進行單獨編碼,然後依次調用 send () 進行發送。
3.5 傳輸通道升級
連接總是以輪詢(XHR 或 JSONP)開始。WebSocket 通過發送探針在側面進行測試 (2probe)。如果探測由服務器響應 (3probe),則客戶端會發送一個升級包 (5)。
爲了確保沒有消息丟失,只有在刷新現有傳輸的所有緩衝區並認爲傳輸已暫停後,纔會發送升級數據包。
當服務器收到升級包時,它必須假定這是新的傳輸通道,並將所有現有緩衝區(如果有的話)發送給它。
客戶端發送的探測器是一個 ping+probe 作爲數據發送。(2probe) 服務端發送的探測器是一個 pong+probe 作爲數據發送。(3probe)
3.6 Timeouts
客戶端必須使用握手中發送的 pingTimeout 和 pingInterval 來確定服務器是否無響應。
服務器發送一個 ping 數據包。如果在 pingTimeout 內未收到任何數據包類型,服務器將認爲套接字已斷開連接。如果收到了 pong 數據包,服務器將在等待 pingInterval 之後再次發送 ping 數據包。
由於這兩個值在服務器和客戶端之間共享,當客戶端在 pingTimeout+pingInterval 內沒有接收到任何數據時,客戶端也能探測到服務器是否變得無響應。
4 一些注意點
-
Engine.IO 是 Socket.IO 的底層傳輸通道實現。
-
Engine.IO 、 Socket.IO 在上層均有自己的協議,因此服務端和客戶端必須搭配才能使用。也就是說 Socket.IO 的客戶端必須搭配 Socket.IO 的服務端才能正常交互數據。
- 在瀏覽器中 message 中的能抓到的數據包,屬於 WebSocket 協議中的 message 類型數據,WebSocket 的 PING, PONG 是和 message 類型是並列的,因此瀏覽器中的 devTools 並不能抓到,而 Engine.IO 的心跳機制的實現(下圖中的 2 和 3),是 message 數據之上的協議定義, 是 Engine.IO 用 WebSocket 的 message 類型消息發送的。
5 一個簡單的例子
- 服務端代碼
const engine = require('engine.io');
const server = engine.listen(3000,{
cors: {
origin: "*"
}
});
server.on('listen', () => {
console.log('listening on 3000')
})
server.on('connection', socket => {
console.log('new connection')
socket.send('utf 8 string');
socket.send(Buffer.from('hello world')); // binary data
});
- 客戶端代碼
const { Socket } = require('engine.io-client');
const socket = new Socket('ws://localhost:3000');
socket.on('open', () => {
socket.emit('message from client')
socket.on('message', (data) => {
console.log('receive message: ' + data);
socket.send('ack from client.');
});
socket.on('close', (e) => {
console.log('socket close',e)
});
});
- 瀏覽器請求抓包
1、Polling 傳輸通道握手
Request:
Response:
2、發起長輪詢請求服務端數據
Request:
Response:
3、POST 方式發送數據到服務端
Request:
Request payload:
Response:
4、服務端告訴客戶端傳輸通道已升級,回覆一個 6
Request:
Response:
5、WebSocket 通道建立之後,切換爲 WebSocket 傳輸數據
Connect:
Message:
- 也可以在客戶端指定傳輸通道爲 websocket , 那麼就不會先建立 Polling 傳輸通道,直接用 WebSocket 傳輸通道進行握手。
const socket = new Socket('ws://localhost:3000',{ transports: ['websocket'] } );
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/bemT3Gz7xiLuHDwB5hMsYQ