JS實時通訊三把斧系列之二: socket.io

風神修羅使發表於2019-08-12

介紹完上一篇文章websocket,我們把視線轉移到第二個RTC利器:socket.io。估計有童鞋就會問,websocketsocket.io有啥區別啊?

在瞭解socket.io之前,我們先聊聊websocket(長連線)的實現背景。

1、長連線的實現背景

在現實產品中,並不是所有的客戶端都支援長連線的,或者換句話說,在websocket協議出來之前,是有兩種方式去實現websocket類似的功能的。

  1. Flash: 使用Flash是一種簡單的方法。不過很明顯的缺點就是Flash並不會安裝在所有客戶端上,比如iPhone/iPad。
  2. AJAX Long-Polling:AJAX長輪詢已經被用來模擬websocket有一段時間了。這是一種有效的技術,但並沒有對訊息傳送進行優化。雖然我不會把AJAX長輪詢當做一種hack技術,但它確實不是一個最優方法

那麼如果單純地使用websocket的話,那些不支援的客戶端怎麼辦呢?難道直接放棄掉?當然不是。Guillermo Rauch大神寫了socket.io這個庫,對websocket進行封裝,從而讓長連線滿足所有的場景,不過當然得配合使用對應的客戶端程式碼。

socket.io將會使用特性檢測的方式來決定以websocket/ajax長輪詢/flash等方式建立連線,那麼socket.io是如何做到這些的呢?我們帶著以下幾個問題去學習:

  1. socket.io到底有什麼新特性?
  2. socket.io是怎麼實現特性檢測的?
  3. socket.io有哪些坑呢?
  4. socket.io的實際應用是怎樣的,需要注意些什麼?

如果有童鞋對上述問題已經清楚,想必就沒有往下讀的必要了。

2、socket.io的介紹

讀過第一篇文章的童鞋都知道了websocket的功能,那麼socket.io相對於websocket,在此基礎上封裝了一些什麼新東西呢?

socket.io其實是有一套封裝了websocket的協議,叫做engine.io協議,在此協議上實現了一套底層雙向通訊的引擎Engine.io

socket.io則是建立在engine.io上的一個應用層框架而已。所以我們研究的重點便是engine.io協議。

socket.io的README中提到了其實現的一些新特性(問題一):

  1. 可靠性 連線依然可以建立即使應用環境存在: 代理或者負載均衡器 個人防火牆或者反病毒軟體
  2. 支援自動連線: 除非特別指定,否則一個斷開的客戶端會一直重連伺服器直到伺服器恢復可用狀態
  3. 斷開連線檢測:在Engine.io層實現了一個心跳機制,這樣允許客戶端和伺服器知道什麼時候其中的一方不能響應。該功能是通過設定在服務端和客戶端的定時器實現的,在連線握手的時候,伺服器會主動告知客戶端心跳的間隔時間以及超時時間
  4. 二進位制的支援:任何序列化的資料結構都可以用來傳送
  5. 跨瀏覽器的支援:該庫甚至支援到IE8
  6. 支援複用:為了在應用程式中將建立的關注點隔離開來,Socket.io允許你建立多個namespace,這些namespace擁有單獨的通訊通道,但將共享相同的底層連線
  7. 支援Room:在每一個namespace下,你可以定義任意數量的通道,我們稱之為"房間",你可以加入或者離開房間,甚至廣播訊息到指定的房間。

Note

Socket.IO不是websocket的實現,雖然 Socket.IO確實在可能的情況下會去使用Websocket作為一個transport,但是它新增了很多後設資料到每一個報文中:報文的型別以及namespace和ack Id。這也是為什麼websocket客戶端不能夠成功連線上 Socket.IO 伺服器,同樣一個 Socket.IO 客戶端也連線不上Websocket伺服器的原因。

3、engine.io協議的介紹

完整的engine.io協議的握手過程如下圖:
在這裡插入圖片描述
當前engine.io協議的版本是3,我們根據上圖來大致介紹engine.io協議

3.1、協議請求欄位

我們看到的是請求的urlwebsocket不大一樣,解釋一下:

  1. EIO=3: 表示的是使用的是Engine.io協議版本3
  2. transport=polling/websocket: 表示使用的長連線方式是輪詢還是websocket
  3. t=xxxxx: 程式碼中使用yeast根據時間戳生成一個唯一的字串
  4. sid=xxxx: 客戶端和伺服器建立連線之後獲取到的session id,客戶端拿到之後必須在每次請求中追加這個欄位

除了上述的3個欄位,協議還描述了下面幾個欄位:

  1. j: 如果transportpolling,但是要求有一個JSONP的響應,那麼j就應該設定為JSONP響應的索引值
  2. b64: 如果客戶端不支援XHR,那麼客戶端應該設定b64=1傳給伺服器,告知伺服器所有的二進位制資料應該以base64編碼後再傳送。

另外engine.io預設的path/engine.iosocket.io在初始化的時候設定為了/socket.io,所以大家看到的path就都是/socket.io

function Server(srv, opts){
  if (!(this instanceof Server)) return new Server(srv, opts);
  if ('object' == typeof srv && srv instanceof Object && !srv.listen) {
    opts = srv;
    srv = null;
  }
  opts = opts || {};
  this.nsps = {};
  this.parentNsps = new Map();
  this.path(opts.path || '/socket.io');

3.2、資料包編碼要求

engine.io協議的資料包編碼有自己的一套格式,在協議介紹上engine.io-protocol,定義了兩種編碼型別:

  1. packet
  2. payload

3.2.1、packet

一個編碼過的packet是下面這種格式:

<packet type id>[<data>]

然後協議定義了下面幾種packet type(採用數字進行標識):

  1. 0(open): 當開始一個新的transport的時候,服務端會傳送該型別的packet
  2. 1(close): 請求關閉這個transport但是不要自己關閉關閉連線
  3. 2(ping): 由客戶端傳送的ping包,服務端必須回應一個包含相同資料的pong
  4. 3(pong): 響應ping包,服務端傳送
  5. 4(message): 實際訊息,在客戶端和服務端都可以監聽message事件獲取訊息內容
  6. 5(upgrade):在engine.io切換transport之前,它會用來測試服務端和客戶端是否在該transport上通訊。如果測試成功,客戶端會傳送一個upgrade包去讓伺服器重新整理它的快取並切換到新的transport
  7. 6(noop): 主要用來強制一個輪詢迴圈當收到一個websocket連線的時候

3.2.2、payload

payload也有對應的格式要求:

  1. 如果當只有傳送string並且不支援XHR的時候,其編碼格式是:<length1>:<packet1>[<length2>:<packet2>[...]]
  2. 當不支援XHR2並且傳送二進位制資料,但是使用base64編碼字串的時候,其編碼格式是:<length of base64 representation of the data + 1 (for packet type)>:b<packet1 type><packet1 data in b64>[...]
  3. 當支援XHR2的時候,所有的資料都被編碼成二進位制,格式是:<0 for string data, 1 for binary data><Any number of numbers between 0 and 9><The number 255><packet1 (first type, then data)>[...]
  4. 如果傳送的內容混雜著UTF-8的字元和二進位制資料,字串的每個字元被寫成一個字元編碼,用1個位元組表示。

TIPS: payload的編碼要求不適用於websocket的通訊

針對上面的編碼要求,我們隨便舉個例子,之前在第一條polling請求的時候,服務端編碼傳送了這個資料:

97:0{"sid":"Peed250dk55pprwgAAAA","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":60000}2:40

根據上面的知識,我們知道第一次服務端會傳送一個open的資料包,所以組裝出來的packet是:

0

然後服務端會告知客戶端去嘗試升級到websocket,並且告知對應的sid,於是整合後便是:

0{"sid":"Peed250dk55pprwgAAAA","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":60000}

接著根據payload的編碼格式,因為是string,且長度是97個位元組,所以是:

97:0{"sid":"Peed250dk55pprwgAAAA","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":60000}

接著第二部分資料是message包型別,並且資料是0,所以是40,長度為2位元組,所以是2:40,最後就拼成剛才大家看到的結果。

Tips

ping/pong的間隔時間是服務端告知客戶的:"pingInterval":25000,"pingTimeout":60000,也就是說心跳時間預設是25秒,並且等待pong響應的時間預設是60s

3.3、升級協議的必備過程

協議定義了transport升級到websocket需要經歷一個必須的過程,如下圖:
在這裡插入圖片描述websocket的測試開始於傳送probe,如果伺服器也響應probe的話,客戶端就必須傳送一個upgrade包。

為了確保不會丟包,只有在當前transport的所有buffer被重新整理並且transport被認為paused的時候才可以傳送upgrade包。服務端收到upgrade包的時候,服務端必須假設這是一個新的通道併傳送所有已存的快取到這個通道上

Chrome上的效果如下:
在這裡插入圖片描述

4、engine.io的程式碼實現

熟悉了engine.io協議之後,我們看看程式碼是怎麼實現主流程的。

客戶端的engine.io的主要實現流程我們在上面文字介紹了,結合程式碼engine.io,畫了這麼一個客戶端流程圖:
在這裡插入圖片描述服務端的程式碼和客戶端非常相似,其實現流程圖如下:
在這裡插入圖片描述

5、socket.io的應用以及坑

5.1、搭配nginx使用

在實際應用中,socket.io伺服器都會部署在nginx後面,所以我們需要配置nginx幾個配置:

  1. 需要新增下面兩行配置:
     proxy_set_header Upgrade $http_upgrade;
     proxy_set_header Connection "Upgrade";
    

如果有多個例項啟動的話,需要保證某個ip連線到某個例項之後,一直保持和該例項的連線,而不是被負載均衡隨機分配例項,還需要配置下面一行:

upstream {
  ip_hash; // 主要這行,該行還必須在ip:port之前,否則會有警告出現
  ip:port;
  ip:port;
  ....
}

二者有任何一個沒有配置,客戶端都會出現下圖中類似的錯誤:
在這裡插入圖片描述

5.2、io.use中介軟體的詭異行為

在實際應用中發現如下注釋的行為,請參考demo程式碼io.js

// socket.io這邊有個很奇怪的,如果我使用io.use的話,那麼只有連線根ns的客戶端才會收到這個錯誤的packet
// 而不會讓某個ns的客戶端收到,但是這個中介軟體的函式卻是監聽所有的socket的。

io.use((socket, next) => {
  console.log('middleware has triggered.......')
  // if (socket.request.headers.cookie) return next();
  next(new Error('Authentication error'));
})

現象: 在頁面中點選連線到根ns,測試服務端的根中介軟體的問題按鈕是會收到錯誤訊息,但是點選其他ns卻不會收到錯誤訊息。

雖然每個ns下是有提供了ns的中介軟體,但是我們更希望有一個普遍的中介軟體去使用,但是很明顯socket.io目前是沒有這樣實現的。關於這個問題如果也影響了大家的實現的話,這個只能改動原始碼,具體改哪裡,童鞋們自行去思考吧。

5.3、根ns

另外需要注意的是,使用socket.io的話,是有預設的namespace(/),所以無論客戶端連線的ns是哪一個,都會先進入根ns的,並且會記錄下這個客戶端的。換句話說,如果你有2個客戶端連線ns1,3個客戶端連線ns2的話,那麼在/下就會有5個客戶端,並且在根ns下監聽connection事件也是會進入的。

相關文章