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 種傳輸通道實現

2.1.1、HTTP long-polling

HTTP long-polling transport (也簡稱 "polling") 由連續的 HTTP requests 組成:

基於 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
}

2.3、升級機制

默認的情況下,客戶端先建立 HTTP long-polling 通信通道。

爲什麼呢?

WebSocket 無疑是最好的雙向通道,但是由於公司的代理、個人的防火牆、殺毒軟件等,它並不是在什麼情況下都能成功建立。

從用戶的角度來看,如果 WebSocket 連接建立失敗,那麼用戶至少要等 10S 才能開始真正的數據傳輸,這無疑傷害了用戶的體驗。

總的來說,Engine.IO 首先關注可靠性和用戶體驗,其次纔是服務器性能。

升級的時候,客戶端會做如下動作:

可以在瀏覽器抓包看到如下網絡連接:

2.4、斷連檢測

當以下情況出現時,Engine.IO 的連接會判斷爲關閉。

服務端會以 pingInterval 的間隔發送 PING 數據包,客戶端收到後在 pingTimeout 時間之內需要發送 PONG 數據包給服務端,如果服務端在 pingTimeout 時間內沒有收到,那麼就認爲這條連接關閉了。相反,客戶端如果在 pingInterval + pingTimeout 時間內沒有收到 PING 數據包,客戶端也判斷連接關閉。

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

mPfFcG

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

eest1X

3、Engine.IO 的協議

3.1 一次 Engine.IO 會話

會話例子

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 是用來防止瀏覽器緩存請求.

服務端執行 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.

客戶端執行: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
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>]

3.3 編碼

有兩種不同類型的編碼

3.3.1 Packet

一個編碼的數據包可以是 UTF-8 字符串或者二進制數據。字符串的數據包編碼格式如下:

<packet type id>[<data>]

example:

4hello

對於二進制數據,不包括數據包類型(packet type),因爲只有 “message” 數據包類型可以包括二進制數據。

packet type

新傳輸通道建立的時候,從服務端發送 Sent from the server when a new transport is opened (recheck)

請求關閉此傳輸,但不關閉連接本身。

由服務器發送。客戶應該用 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 必須支持三種傳輸通道:

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 一些注意點

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:

const socket = new Socket('ws://localhost:3000',{ transports: ['websocket'] } );

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/bemT3Gz7xiLuHDwB5hMsYQ