Node.js – 200 多行程式碼實現 Websocket 協議

  • 2019 年 10 月 10 日
  • 筆記

溫馨提示:因微信中外鏈都無法點擊,請通過文末的」 「閱讀原文」 到技術部落格中完整查閱版;(本文整理自技術部落格)

A、預備工作

1、序

最近正在研究 Websocket 相關的知識,想著如何能自己實現 Websocket 協議。到網上搜羅了一番資料後用 Node.js 實現該協議,倒也沒有想像中那麼複雜,除去注釋語句和 console 語句後,大約 200 行程式碼左右。本文記錄了實現過程中的經驗和總結。

如果你想要寫一個 WebSocket 伺服器,首先需要讀懂對應的網路協議 RFC6455,不過這對於一般人來說有些 「晦澀」,英文且不說,還得咬文嚼字理解 網路編程 含義。

好在 WebSocket 技術出現比較早,所以可以搜到 RFC6455 中文版,網上也有很多針對該協議的剖析文章,很多文章里還有現成的實現程式碼可以參考,所以說實現一個簡單的 Websocket 服務並非難事。

本文更偏向實戰(in action),會從知識儲備、具體程式碼分析以及注意事項角度去講解如何用 Node.js 實現一個簡單的 Websocket 服務,至於 Websocket 概念、定義、解釋和用途等基礎知識不會涉及,因為這些知識在本文所列的參考文章中輕鬆找到。(也可以自行網上隨便一搜,就能找到很多)

2、知識儲備

如果要自己寫一個 Websocket 服務,主要有兩個難點:

  1. 熟練掌握 Websocket 的協議,這個需要多讀現有的解讀類文章;(下面會給出參考文章)
  2. 操作二進位數據流,在 Node.js 中需要對 Buffer 這個類稍微熟悉些。

同時還需要具備兩個基礎知識點:

  • 網路編程中使用 大端次序(Big endian)表示大於一位元組的數據,稱之為 網路位元組序 (不曉得大小端的,推薦閱讀 什麼是大小端?)
  • 了解最高有效位(MSB, Most Significant Bit),不太清楚的,可以參考 LSB最低有效位和MSB最高有效位

具體的做法如下,推薦先閱讀以下幾篇參考文章:

  • 學習WebSocket協議—從頂層到底層的實現原理(修訂版):作者本身自己就用 Node.js 實現過一遍,知識點講解挺透徹的,適合前端同學優先閱讀
  • WebSocket詳解(一):初步認識WebSocket技術:是一系列的文章,從淺入深,配有豐富的圖文
  • WebSocket:5分鐘從入門到精通:全文以 Q&A 的方式組織而成,協議的要點都解讀到了,除此之外還很全面, 涉及了WebSocket如何建立連接、交換數據的細節、數據幀的格式以及網路安全等。
  • MDN – Writing WebSocket servers:MDN 官方教程,讀一遍沒啥壞處。

然後開始寫程式碼,在實現過程中的大部分程式碼可以從下面 3 篇文章中找到並借鑒(copy):

  • nodejs 實現:簡化版本的從這兒借鑒過來的
  • 學習WebSocket協議—從頂層到底層的實現原理(修訂版)
  • WebSocket協議解析:雖然是 C++ 寫的,但不影響程式碼邏輯的理解

閱讀完上面的文章,你會有發現一個共同點,就是在實現 WebSockets 過程中,最最核心的部分就是 解析 或者 生成 Frame(幀),就是下面這結構:

幀結構標準

截圖來自規範Base Framing Protocol

想要理解 frame 各個欄位的含義,可參考 WebSocket詳解(三):深入WebSocket通訊協議細節,文中作者繪製了一副圖來解釋這個 frame 結構;

而在程式碼層面,frame 的解析或生成可以在 RocketEngine – parser 或者 _processBuffer 中找到。

在完成上面幾個方面的知識儲備之後,而且大多有現成的程式碼,所以自己邊抄邊寫一個 Websocket 伺服器並不算太難。

對於 Websocket 初學者,請務必閱讀以上參考文章,對 Websocket 協議有大概的了解之後再繼續本文剩下部分的閱讀,否則很有可能會覺得我寫得雲里霧裡,不知所云。

B、 實戰

實現程式碼放在自己的 demos 倉庫的 micro-ws 的目錄 了,git clone 後本地運行,執行

node index.js  

將會在 http://127.0.0.1:3000 創建服務。運行服務之後,打開控制台就能看到效果:

將會在 http://127.0.0.1:3000 創建服務。運行服務之後,打開控制台就能看到效果:

實戰效果圖

動圖中瀏覽器 console 所執行的 js 程式碼步驟如下:

1.先建立連接

var ws = new WebSocket("ws://127.0.0.1:3000");  ws.onmessage = function(evt) {    console.log( "Received Message: " + evt.data);  };  

2.然後發送消息:(注意一定要在建立連接之後再執行該語句,否則發不出消息的)

ws.send('hello world');  

從效果可見,我們已經實現 Websocket 最基本的通訊功能了。

接下來我們詳細看一下具體實現的細節。

1、調用所寫的 Websocket 類

站在使用者的角度,假設我們已經完成 Websocket 類了,那麼應該怎麼使用?

客戶端通過 HTTP Upgrade 請求,即 101 Switching Protocol 到 HTTP 伺服器,然後由伺服器進行協議轉換。

在 Node.js 中我們通過 http.createServer 獲取 http.server 實例,然後監聽 upgrade 事件,在處理這個事件:

// HTTP伺服器部分  var server = http.createServer(function(req, res) {    res.end('websocket testrn');  });    // Upgrade請求處理  server.on('upgrade', function(req, socket, upgradeHead){    // 初始化 ws    var ws = new WebSocket(req, socket, upgradeHead);      // ... ws 監聽 data、error 的邏輯等    });  

這裡監聽 upgrade 事件的回調函數中第二個參數 socket 是 net.Socket 實例,這個類是 TCP 或 UNIX Socket 的抽象,同時一個 net.Socket 也是一個 duplex stream,所以它能被讀或寫,並且它也是一個 EventEmitter。

我們就利用這個 socket 對象上進行 Websocket 類實例的初始化工作;

2、構造函數

所以不難理解 Websocket 的構造函數就是下面這個樣子:

class WebSocket extends EventEmitter {    constructor(req, socket, upgradeHead){      super(); // 調用 EventEmitter 構造函數        // 1. 構造響應頭 resHeaders 部分        // 2. 監聽 socket 的 data 事件,以及 error 事件        // 3. 初始化成員屬性      }  }  

注意,我們需要繼承內置的 EventEmitter ,這樣生成的實例才能監聽、綁定事件;

Node.js 採用事件驅動、非同步編程,天生就是為了網路服務而設計的,繼承 EventEmitter 就能享受到非阻塞模式的 IO 處理;

講一下其中 響應頭的構造事件監聽 部分。

2.1、返迴響應頭(Response Header)

根據協議規範,我們能寫出響應頭的內容:

  1. Sec-WebSocket-Key258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接。
  2. 通過 SHA1 計算出摘要,並轉成 base64 字元串。

具體程式碼如下:

    var resKey = hashWebSocketKey(req.headers['sec-websocket-key']);        // 構造響應頭      var resHeaders = [        'HTTP/1.1 101 Switching Protocols',        'Upgrade: websocket',        'Connection: Upgrade',        'Sec-WebSocket-Accept: ' + resKey      ]        .concat('', '')        .join('rn');      socket.write(resHeaders);  

當執行 socket.write(resHeaders); 到後就和客戶端建立起 WebSocket 連接了,剩下去就是數據的處理。

2.2、監聽事件

socket 就是 TCP 協議的抽象,直接在上面監聽已有的 data 事件和 close 事件這兩個事件。

還有其他事件,比如 errorend 等,詳細參考 net.Socket 文檔

    socket.on('data', data => {        this.buffer = Buffer.concat([this.buffer, data]);        while (this._processBuffer()) {} // 循環處理返回的 data 數據      });        socket.on('close', had_error => {        if (!this.closed) {          this.emit('close', 1006);          this.closed = true;        }      });  

close 的事件邏輯比較簡單,比較重要的是 data 的事件監聽部分。核心就是 this._processBuffer() 這個方法,用於處理客戶端傳送過來的數據(即 Frame 數據) 。注意該方法是放在 while 循環語句里,處理好邊界情況,防止死循環。

3、Frame 幀數據的處理

WebSocket 客戶端、服務端通訊的最小單位是幀(frame),由1個或多個幀組成一條完整的消息(message)。 這 this._processBuffer() 部分程式碼邏輯就是用來解析幀數據的,所以它是實現 Websocket 程式碼的關鍵;(該方法裡面用到了大量的位操作符以及 Buffer 類的操作)

幀數據結構詳細定義可參考 RFC6455 5.2節,上面羅列的參考文章都有詳細的解讀,我在這兒也不啰嗦講細節了,直接看程式碼比聽我用文字講要好。

這裡就其中兩個細節需要鋪墊一下,方便更好地理解程式碼。

3.1、操作碼(Opcode)

Opcode操作程式碼,Opcode 的值決定了應該如何解析後續的數據載荷(data payload)

根據 Opcode 我們可以大致將數據幀分成兩大類:數據幀控制幀

  • 數據幀:目前只有 3 種,對應的 opcode 是:
  • 0x0:數據延續幀
  • 0x1:utf-8文本
  • 0x2:二進位數據;
  • 0x30x7:目前保留,用於後續定義的非控制幀。
  • 控制幀:除了上述 3 種數據幀之外,剩下的都是控制幀
  • 0x8:表示連接斷開
  • 0x9:表示 ping 操作
  • 0xA:表示 pong 操作
  • 0xB – 0xF:目前保留,用於後續定義的控制幀

在程式碼里,我們會先從幀數據中提取操作碼:

var opcode = byte1 & 0x0f; //截取第一個位元組的後 4 位,即 opcode 碼  

然後根據協議獲取到真正的數據載荷(data payload),然後將這兩部分傳給 _handleFrame 方法:

this._handleFrame(opcode, payload); // 處理操作碼  

該方法會根據不同的 opcode 做出不同的操作:

_handleFrame(opcode, buffer) {      var payload;      switch (opcode) {        case OPCODES.TEXT:          payload = buffer.toString('utf8'); //如果是文本需要轉化為utf8的編碼          this.emit('data', opcode, payload); //Buffer.toString()默認utf8 這裡是故意指示的          break;        case OPCODES.BINARY: //二進位文件直接交付          payload = buffer;          this.emit('data', opcode, payload);          break;        case OPCODES.PING: // 發送 pong 做響應          this._doSend(OPCODES.PONG, buffer);          break;        case OPCODES.PONG: //不做處理          console.log('server receive pong');          break;        case OPCODES.CLOSE: // close有很多關閉碼          let code, reason; // 用於獲取關閉碼和關閉原因          if (buffer.length >= 2) {            code = buffer.readUInt16BE(0);            reason = buffer.toString('utf8', 2);          }          this.close(code, reason);          this.emit('close', code, reason);          break;        default:          this.close(1002, 'unhandle opcode:' + opcode);      }    }  

3.2、分片(Fragment)

規範文檔:5.4 – Fragmentation

一旦 WebSocket 客戶端、服務端建立連接後,後續的操作都是基於數據幀的傳遞。理論上來說,每個幀(Frame)的大小是沒有限制的。

對於大塊的數據,Websocket 協議建議對數據進行分片(Fragment)操作。

分片的意義主要是兩方面:

  • 主要目的是允許當消息開始但不必緩衝該消息時發送一個未知大小的消息。如果消息不能被分片,那麼端點將不得不緩衝整個消息以便在首位元組發生之前統計出它的長度。對於分片,伺服器或中間件可以選擇一個合適大小的緩衝,當緩衝滿時,再寫一個片段到網路。
  • 另一方面分片傳輸也能更高效地利用多路復用提高頻寬利用率,一個邏輯通道上的一個大消息獨佔輸出通道是不可取的,因此多路復用需要可以分割消息為更小的分段來更好的共享輸出通道。參考文檔 I/O多路復用技術(multiplexing)是什麼?

WebSocket 協議提供的分片方法,是將原本一個大的幀拆分成數個小的幀。下面是把一個大的Frame分片的圖示:

分片圖示

根據 FIN 的值來判斷,是否已經收到消息的最後一個數據幀。由圖可知,第一個分片的 FIN 為 0,Opcode 為非0值(0x1 或 0x2),最後一個分片的FIN為1,Opcode為 0。中間分片的 FINopcode 二者均為 0。

  • `FIN=1` 表示當前數據幀為消息的最後一個數據幀,此時接收方已經收到完整的消息,可以對消息進行處理。
  • `FIN=0`,則接收方還需要繼續監聽接收其餘的數據幀。
  • opcode在數據交換的場景下,表示的是數據的類型。
    • `0x01` 表示文本,永遠是 `utf8` 編碼的
    • `0x02` 表示二進位
    • 而 `0x00` 比較特殊,表示 延續幀(continuation frame),顧名思義,就是完整消息對應的數據幀還沒接收完。

程式碼里,我們需要檢測 FIN 的值,如果為 0 說明有分片,需要記錄第一個 FIN 為 0 時的 opcode 值,快取到 this.frameOpcode 屬性中,將載荷快取到 this.frames 屬性中:

    var FIN = byte1 & 0x80; // 如果為0x80,則標誌傳輸結束,獲取高位 bit      // 如果是 0 的話,說明是延續幀,需要保存好 opCode      if (!FIN) {        this.frameOpcode = opcode || this.frameOpcode; // 確保不為 0;      }        //....      // 有可能是分幀,需要拼接數據      this.frames = Buffer.concat([this.frames, payload]); // 保存到 frames 中  

當接收到最後一個 FIN 幀的時候,就可以組裝後給 _handleFrame 方法:

    if (FIN) {        payload = this.frames.slice(0); // 獲取所有拼接完整的數據        opcode = opcode || this.frameOpcode; // 如果是 0 ,則保持獲取之前保存的 code        this.frames = Buffer.alloc(0); // 清空 frames        this.frameOpcode = 0; // 清空 opcode        this._handleFrame(opcode, payload); // 處理操作碼      }  

3.3、發送數據幀

上面講的都是接收並解析來自客戶端的數據幀,當我們想給客戶端發送數據幀的時候,也得按協議來。

這部分操作相當於是上述 _processBuffer 方法的逆向操作,在程式碼里我們使用 encodeMessage 方法(為了簡單起見,我們發送給客戶端的數據沒有經過掩碼處理)將發送的數據分裝成數據幀的格式,然後調用 socket.write 方法發送給客戶端;

  _doSend(opcode, payload) {      // 1. 考慮數據分片      this.socket.write(        encodeMessage(count > 0 ? OPCODES.CONTINUE : opcode, payload)      ); //編碼後直接通過socket發送  

為了考慮分片場景,特意設置 MAX_FRAME_SIZE 來對每次發送的數據長度做截斷做分片:

    // ...      var len = Buffer.byteLength(payload);      // 分片的距離邏輯      var count = 0;      // 這裡可以針對 payload 的長度做分片      while (len > MAX_FRAME_SIZE) {        var framePayload = payload.slice(0, MAX_FRAME_SIZE);        payload = payload.slice(MAX_FRAME_SIZE);        this.socket.write(          encodeMessage(            count > 0 ? OPCODES.CONTINUE : opcode,            framePayload,            false          )        ); //編碼後直接通過socket發送        count++;        len = Buffer.byteLength(payload);      }    // ...  

至此已經實現 Websocket 協議的關鍵部分,所組裝起來的程式碼就能和客戶端建立 Websocket 連接並進行數據交互了。

4、Q&A

4.1、字元串 「258EAFA5-E914-47DA-95CA-C5AB0DC85B11」 怎麼來的?

這個標誌性字元串是專門標示 Websocket 協議的 UUID;UUID 是長度為 16-byte(128-bit)的ID,一般以形如f81d4fae-7dec-11d0-a765-00a0c91e6bf6的字元串作為 URN(Uniform Resource Name,統一資源名稱)

UUID 可以移步到 UUID原理 和 RFC 4122 獲取更多知識

為啥選擇這個字元串?

在規範的第七頁已經有明確的說明了:

專用於 websocket 的 uuid 標識符

之所以選用這個 UUID ,主要該 ID 極大不太可能被其他不了解 Websocket 協議的網路終端所使用;

我也不曉得該怎麼翻譯。。。總之就說這個 ID 就相當於 Websocket 協議的 「身份證號」 了。

4.2、Websocket 和 HTTP 什麼關係?

HTTP、WebSocket 等應用層協議,都是基於 TCP 協議來傳輸數據的,我們可以把這些高級協議理解成對 TCP 的封裝。

既然大家都使用 TCP 協議,那麼大家的連接和斷開,都要遵循 TCP 協議中的三次握手和四次握手 ,只是在連接之後發送的內容不同,或者是斷開的時間不同

對於 WebSocket 來說,它必須依賴 HTTP 協議進行一次握手 ,握手成功後,數據就直接從 TCP 通道傳輸,與 HTTP 無關了

4.3、瀏覽器中 Websocket 會自動分片么?

答案是:看具體瀏覽器的實現

WebSocket是一個 message based 的協議,它可以自動將數據分片,並且自動將分片的數據組裝。 每個 message 可以是一個或多個分片。message 不記錄長度,分片才記錄長度;

根據協議 websocket 協議中幀長度上限為 2^63 byte(為 8388608 TB),可以認為沒有限制,很明顯按協議的最大上限來傳輸數據是不靠譜的。所以在實際使用中 websocket 消息長度限制取決於具體的實現。關於哲方面,找了兩篇參考文章:

  • Websocket需要像TCP Socket那樣進行邏輯數據包的分包與合包嗎?:WebSocket是一個message-based的協議,它可以自動將數據分片,並且自動將分片的數據組裝;;
  • websocket長文本問題?:這裡給出了長文本 ws 傳輸實踐總結。

在文章 WebSocket探秘 中,作者就做了一個實驗,作者發送 27378 個位元組,結果被迫分包了;如果是大數據量,就會被socket自動分包發送。

而經過我本人試驗,發現 Chrome 瀏覽器(版本 68.0.3440.106 – 64bit)會針對 131072(=2^17)bytes 大小進行自動分包。我是通過以下測試程式碼驗證:

var ws = new WebSocket("ws://127.0.0.1:3000");  ws.onmessage = function(evt) {    console.log( "Received Message: " + evt.data);  };  var myArray = new ArrayBuffer(131072 * 2 + 1);  ws.send(myArray);  

服務端日誌:

server detect fragment, sizeof payload: 131072  server detect fragment, sizeof payload: 131072  receive data: 2 262145  

客戶端日誌:

Received Message: good job  

截圖如下:

chrome 瀏覽器會自動分片

而以同樣的方式去測試一些自己機器上的瀏覽器:

  • Firefox(62.0,64bit)
  • safari (11.1.2 – 13605.3.8)
  • IE 11

這些客戶端上的 Websocket 幾乎沒有大小的分片(隨著數據量增大,發送會減緩,但並沒有發現分片現象)。

5、總結

從剛開始決定閱讀 Websocket 協議,到自己使用 Node.js 實現一套簡單的 Websocket 協議,到這篇文章的產出,前後耗費大約 1 個月時間(拖延症。。。)。 感謝文中所提及的參考文獻所給予的幫助,讓我實現過程中事半功倍。

之所以能夠使用較少的程式碼實現 Websocket,是因為 Node.js 體系本身了很好的基礎,比如其所提供的 EventEmitter 類自帶事件循環,http 模組讓你直接使用封裝好的 socket 對象,我們只要按照 Websocket 協議實現 Frame(幀)的解析和組裝即可。

在使用 Node.js 實現一遍 Websocket 協議後,就能較為深刻地理解以下知識點(理解起來一切都是那麼自然而然):

  • Websocket 是一種應用層協議,是為了提供 Web 應用程式和服務端全雙工通訊而專門制定的;
  • WebSocket 和 HTTP 都是基於 TCP 協議實現的
  • WebSocket和 HTTP 的唯一關聯就是 HTTP 伺服器需要發送一個 「Upgrade」 請求,即 101 Switching Protocol 到 HTTP 伺服器,然後由伺服器進行協議轉換。
  • WebSocket使用 HTTP 來建立連接,但是定義了一系列新的 header 域,這些域在 HTTP 中並不會使用;
  • WebSocket 可以和 HTTP Server 共享同一 port
  • WebSocket 的 數據幀有序

本文僅僅是協議的簡單實現,對於 Websocket 的其實還有很多事情可以做(比如支援 命名空間流式 API 等),有興趣的可以參考業界流行的 Websocket 倉庫,去練習鍛造一個健壯的 Websocket 工具庫輪子:

  • socketio/socket.io:43.5k star,不多說,業界權威龍頭老大。(不過這實際上不是一個 WebSocket 庫,而是一個實時 pub/sub 框架。簡單地說,Socket.IO 只是包含 WebSocket 功能的一個框架,如果要使用該庫作為 server 端的服務,則 client 也必須使用該庫,因為它不是標準的 WebSocket 協議,而是基於 WebSocket 再包裝的消息通訊協議)
  • websockets/ws:9k star,強大易用的 websocket 服務端、客戶端實現,還有提供很多強大的特性
  • uNetworking/uWebSockets:9.5k star,小巧高性能的 websocket實現,C++ 寫的,想更多了解 Websocket 的底層實現,該庫是不錯的案例。
  • theturtle32/WebSocket-Node:2.3k star,大部分使用 JavaScript,性能關鍵部分使用 C++ node-gyp 實現的庫。其所列的 測試用例 有挺好的參考價值