WebSocket 基礎與應用系列(二)—— Engine.IO 原理了解


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 數據包,客戶端也判斷連接關閉。

服務端觸發斷連事件的原因有:

Reason Description
server namespace disconnect The socket was forcefully disconnected with socket.disconnect
client namespace disconnect The client has manually disconnected the socket using socket.disconnect()
server shutting down The server is, well, shutting down
ping timeout The client did not send a PONG packet in the pingTimeout delay
transport close The connection was closed (example: the user has lost connection, or the network was changed from WiFi to 4G)
transport error The connection has encountered an error

客戶端觸發斷連事件的原因有:

Reason Description
io server disconnect The server has forcefully disconnected the socket with socket.disconnect()
io client disconnect The socket was manually disconnected using socket.disconnect()
ping timeout The server did not send a PING within the pingInterval + pingTimeout range
transport close The connection was closed (example: the user has lost connection, or the network was changed from WiFi to 4G)
transport error The connection has encountered an error (example: the server was killed during a HTTP long-polling cycle)

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'] } );

 

 轉自https://mp.weixin.qq.com/s/bemT3Gz7xiLuHDwB5hMsYQ

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM