實現一個websocket伺服器-實踐篇

瀟湘待雨發表於2017-12-11

早點時候翻譯了篇實現一個websocket伺服器-理論篇 ,簡單介紹了下理論基礎,本來打算放在一起,但是感覺太長了大家可能都看不下去。不過發現如果拆開的話,還是不可避免的要提及理論部分。用到的地方就簡要回顧一下好了。

Websockt 基本通訊流程

在具體程式碼實現之前,我們需要大概理一下思路。回顧一下websocket的理論部分。簡單的websocket流程如下(這裡就不談詳細的過程了,大概描述一下)

  1. 客戶端傳送握手請求
  2. 伺服器響應、處理握手並返回
  3. 客戶端驗證通過後,傳送資料
  4. 伺服器接收、處理資料,然後返回給客戶端
  5. 客戶端接收伺服器的推送

作為一個伺服器而言,我們主要的精力需要放在2,4這兩個步驟。

響應並處理握手

雖然websocket可以實現伺服器推送,前提在於該連線已經建立。客戶端仍然需要發起一個Websocket握手請求。 既然要響應該握手請求,我們需要了解一下該請求。

客戶端握手請求

客戶端的握手請求是一個標準的HTTP請求,大概像下面的例子。

GET / HTTP/1.1  //HTTP版本必須1.1及以上,請求方式為GET
Host: localhost:8081 //本地專案
Connection: Upgrade 
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket //指定websocket協議
Origin: http://192.168.132.170:8000
Sec-WebSocket-Version: 13 //版本
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: optimizelyEndUserId=oeu1505722530441r0.5993643212774391; _ga=GA1.1.557695983.1505722531
Sec-WebSocket-Key: /2R6uuzPqLT/6z8fnZfN3w==   //握手返回基於該金鑰
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
  
複製程式碼

上面列出了實際例子中的請求頭,內容由瀏覽器生成,需要注意的部分如下。

  • HTTP版本必須1.1及以上,請求方式為GET
  • Connection: Upgrade
  • Upgrade: websocket //指定websocket
  • Sec-WebSocket-Key 金鑰 伺服器處理握手的依據

我們伺服器處理握手時需要關注的就是上面四點。

響應握手請求

伺服器根據是否websocket的必須請求頭,分下面兩種情況:

  1. 不滿足,作為http請求來響應。
  2. 滿足,解析處理按照websocket規定的資料格式來響應

返回格式

    HTTP/1.1 101 Switching Protocols
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
複製程式碼

請注意每一個header以\r\n結尾並且在最後一個後面加入額外的\r\n。

這裡的Sec-WebSocket-Accept 就是基於請求頭中Sec-WebSocket-Key來生成。規則如下:
Sec-WebSocket-Key 和"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"連結,通過SHA-1 hash獲得結果,然後返回該結果的base64編碼。 程式碼如下:

// 指定拼接字元
var ws_key = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
// 生成相應key
function getAccpectKey(rSWKey) {
    return crypto.createHash('sha1').update(rSWKey + ws_key).digest('base64')
}
function handShake(socket, headers) {
    var reqSWKey = headers['Sec-WebSocket-Key'],
        resSWKey = getAccpectKey(reqSWKey)
    socket.write('HTTP/1.1 101 Switching Protocols\r\n');
    socket.write('Upgrade: websocket\r\n');
    socket.write('Connection: Upgrade\r\n');
    socket.write('Sec-WebSocket-Accept: ' + resSWKey + '\r\n');
    socket.write('\r\n');
}
複製程式碼

這樣我們的握手協議就算完成了,此時會觸發客戶端websocket的onopen事件,即websocket開啟,可以進行通訊

解析資料

客戶端傳送幀格式

握手協議完成之後,我們就該解析資料了,還是要把這張幀格式拿出來。

    幀格式:  
​​
      0                   1                   2                   3
      0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
     +-+-+-+-+-------+-+-------------+-------------------------------+
     |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
     |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
     |N|V|V|V|       |S|             |   (if payload len==126/127)   |
     | |1|2|3|       |K|             |                               |
     +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
     |     Extended payload length continued, if payload len == 127  |
     + - - - - - - - - - - - - - - - +-------------------------------+
     |                               |Masking-key, if MASK set to 1  |
     +-------------------------------+-------------------------------+
     | Masking-key (continued)       |          Payload Data         |
     +-------------------------------- - - - - - - - - - - - - - - - +
     :                     Payload Data continued ...                :
     + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
     |                     Payload Data continued ...                |
     +---------------------------------------------------------------+

複製程式碼

每個從客戶端傳送到伺服器的資料幀都遵循上面的格式。

  1. MASK位:只表明資訊是否已進行掩碼處理。來自客戶端的訊息必須經過處理,因此我們應該將其置為1

  2. opcode欄位定義如何解析有效的資料:

    • 0x0 繼續處理
    • 0x1 text(必須是UTF-8編碼)
    • 0x2 二進位制 和其他叫做控制程式碼的資料。
    • 0x3-0x7 0xB-0xF 該版本的WebSockets無意義
  3. FIN 表明是否是資料集合的最後一段訊息,如果為0,伺服器繼續監聽訊息,以待訊息剩餘的部分。否則伺服器認為訊息已經完全傳送。

  4. Payload len:有效資料長度

    • Payload len<126, 即為真實長度
    • 126,說明真實長度大於125,後面2個位元組的值為真實長度
    • 127,真實長度大於65535,後面8位元組值為真實長度

解析資料

所謂解析資料,肯定是基於上面的格式按照一定規則來進行處理。下面就是處理的規則。

  1. 獲取有效資料長度
  2. 獲取掩碼並依據規則進行反序列化資料

直接看程式碼應該更加清晰。

// 解析接受的資料幀
function decodeFrame(buffer) {
    /**
     * >>> 7 右移操作,即位元組右移7位,目的是為了即只取第一位的值
     * 10010030  ====>   00000001
     * & 按位與  同1為1    
     * 15二進位制表示為:00001111  ,運算之後前四位即為0,得到後四位的值
     * 11011000 & 00001111  ===》  00001000
     *  
     */
    var fBite = buffer[0],
        /**
         * 獲取Fin的值,
         * 1傳輸結束
         * 0 繼續監聽 
         */
        Fin = fBite >>> 7,
        /**
         * 獲取opcode的值,opcode為fBite的4-7位
         * & 按位與  同1為1    
         * 15二進位制表示為:00001111  ,運算之後前四位即為0,得到後四位的值
         */
        opcode = buffer[0] & 15,
        /**
         * 獲取有效資料長度 
         */
        len = buffer[1] & 127,
        // 是否進行掩碼處理,客戶端請求必須為1
        Mask = buffer[1] >>> 7,
        maskKey = null
    // 獲取資料長度
    //真實長度大於125,讀取後面2位元組
    if (len == 126) {
        len = buffer.readUInt16BE(2)
    } else if (len == 127) {
        // 真實長度大於65535,讀取後面8位元組
        len = buffer.readUInt64BE(2)
    }
    // 判斷是否進行掩碼處理
    Mask && (maskKey = buffer.slice(2,5))
    /**
     * 反掩碼處理 
     * 迴圈遍歷加密的位元組(octets,text資料的單位)並且將其與第(i%4)位掩碼位元組(即i除以4取餘)進行異或運算
     */
    if(Mask){
        for (var i = 2;i<len ;i++){
            buffer[i] = maskKey[(i - 2) % 4] ^ buffer[i];
        }
    }
    var data = buffer.slice(2)
    return {
        Fin:Fin,
        opcode:opcode,
        data:data
    }
}
複製程式碼

傳送資料

處理完接收到的資料之後,下面就是傳送響應了。 響應資料不需要進行掩碼運算,只需要根據幀的格式(即上面的幀),將資料進行組裝就好

// 加密傳送資料
function encodeFrame(data){
    var len = Buffer.byteLength(data),
        // 2的64位
        payload_len = len > 65535 ?10:(len > 125 ? 4 : 2),
        buf = new Buffer(len+payload_len)
    /**
     * 首個位元組,0x81 = 10000001 
     *對應的Fin 為1 opcode為001 mask 為0 
     * 即表明 返回資料為txt文字已經結束並未使用掩碼處理
     */
    buf[0] = 0x81  
    /**
     * 根據真實資料長度設定payload_len位
     */        
    if(payload_len == 2){
        buf[1] = len
    }else if(payload_len == 4){
        buf[1] = 126;
        buf.writeUInt16BE(payload_len, 2);
    }else {
        buf[1] = 127;
        buf.writeUInt32BE(payload_len >>> 32, 2);
        buf.writeUInt32BE(payload_len & 0xFFFFFFFF, 6);
    }  
    buf.write(data, payload_len);
    return buf;
}    
複製程式碼

心跳響應

當收到opcode 為 9時即ping請求,直接返回具有完全相同有效資料的pong即可。 Pings的opcode為0x9,pong是0xA,所以可以直接如下

// ping請求
if(opcode == 9){
   console.log("ping相應");
   /**
    * ping pong最大長度為125,所以可以直接拼接
    * 前兩位資料為10001010+資料長度
    * 即傳輸完畢的pong響應,資料肯定小於125
    */
    socke.write(Buffer.concat([new Buffer([0x8A, data.length]), data]))
}
複製程式碼

結束語

至此,一個websocket伺服器的簡單實現就完成了更多細節請檢視。當然成熟的websocket庫處理各種情況是比較完善的,更推薦大家使用,這裡只是簡單實踐,更多的是滿足一下自己的好奇心,知其然,也要知其所以然,希望大家共同學習和進步

相關文章