學習WebSocket協議—從頂層到底層的實現原理(修訂版)

從RealTime說起

自從即時Web的概念提出後,RealTime便成爲了web開發者們津津樂道的話題。實時化的web應用,憑藉其響應迅速、無需刷新、節省網絡流量的特性,不僅讓開發者們眼前一亮,更是爲用戶帶來絕佳的網絡體驗。

近年來關於RealTime的實現,主要還是基於Ajax的拉取和Comet的推送。大家都知道Ajax,這是一種藉助瀏覽器端JavaScript實現的異步無刷新請求功能:要客戶端按需向服務器發出請求,並異步獲取來自服務器的響應,然後按照邏輯更新當前頁面的相應內容。但是這僅僅是拉取啊,這並不是真正的RealTime:缺少服務器端的自動推送!因此,我們不得不使用另一種略複雜的技術Comet,只有當這兩者配合起來,這個web應用才勉強算是個RealTime應用!

Hello WebSocket!

 

不過隨着HTML5草案的不斷完善,越來越多的現代瀏覽器開始全面支持WebSocket技術了。至於WebSocket,我想大家或多或少都聽說過。

這個WebSocket是一種全新的協議。它將TCP的Socket(套接字)應用在了web page上,從而使通信雙方建立起一個保持在活動狀態連接通道,並且屬於全雙工(雙方同時進行雙向通信)。

其實是這樣的,WebSocket協議是借用HTTP協議的101 switch protocol來達到協議轉換的,從HTTP協議切換成WebSocket通信協議。

再簡單點來說,它就好像將Ajax和Comet技術的特點結合到了一起,只不過性能要高並且使用起來要方便的多(當然是之指在客戶端方面。。)

設計哲學

RFC草案中已經說明,WebSocket的目的就是爲了在基礎上保證傳輸的數據量最少。
這個協議是基於Frame而非Stream的,也就是說,數據的傳輸不是像傳統的流式讀寫一樣按字節發送,而是採用一幀一幀的Frame,並且每個Frame都定義了嚴格的數據結構,因此所有的信息就在這個Frame載體中。(後面會詳細介紹這個Frame)

特點

  • 基於TCP協議
  • 具有命名空間
  • 可以和HTTP Server共享同一port

打開連接-握手

下面我先用自然語言描述一下WebSocket的工作原理:
若要實現WebSocket協議,首先需要瀏覽器主動發起一個HTTP請求。

這個請求頭包含“Upgrade”字段,內容爲“websocket”(注:upgrade字段用於改變HTTP協議版本或換用其他協議,這裏顯然是換用了websocket協議),還有一個最重要的字段“Sec-WebSocket-Key”,這是一個隨機的經過base64編碼的字符串,像密鑰一樣用於服務器和客戶端的握手過程。一旦服務器君接收到來自客戶端的upgrade請求,便會將請求頭中的“Sec-WebSocket-Key”字段提取出來,追加一個固定的“魔串”:258EAFA5-E914-47DA-95CA-C5AB0DC85B11,並進行SHA-1加密,然後再次經過base64編碼生成一個新的key,作爲響應頭中的“Sec-WebSocket-Accept”字段的內容返回給瀏覽器。一旦瀏覽器接收到來自服務器的響應,便會解析響應中的“Sec-WebSocket-Accept”字段,與自己加密編碼後的串進行匹配,一旦匹配成功,便有建立連接的可能了(因爲還依賴許多其他因素)。

這是一個基本的Client請求頭:(我只寫了關鍵的幾個字段)

Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: ************==
Sec-WebSocket-Version: **

Server正確接收後,會返回一個響應頭:(同樣只有關鍵的)

Upgrade:websocket
Connnection: Upgrade
Sec-WebSocket-Accept: ******************

這表示雙方握手成功了,之後就是全雙工的通信。

安全性限制

當你看完上面一節後一定會質疑該協議的保密性和安全性,看上去任何客戶端都能夠很容易的向WS服務器發起請求或僞裝截獲數據。WebSocket協議規定在連接建立時檢查Upgrade請求中的某些字段(如Origin),對於不符合要求的請求立即截斷;在通信過程中,也對Frame中的控制位做了很多限制,以便禁止異常連接。

對於握手階段的檢查,這種限制僅僅是在瀏覽器中,對於特殊的客戶端(non-browser,如編碼構造正確的請求頭髮送連接請求),這種源模型就失效了。

(後面會介紹通信過程中的連接關閉種類與流程。)

除此之外,WebSocket也規定了加密數據傳輸方法,允許使用TLS/SSL對通信進行加密,類似HTTPS。默認情況下,ws協議使用80端口進行普通連接,加密的TLS連接默認使用443端口。

和TCP、HTTP協議的關係

WebSocket是基於TCP的獨立的協議。
和HTTP的唯一關聯就是HTTP服務器需要發送一個“Upgrade”請求,即101 Switching Protocol到HTTP服務器,然後由服務器進行協議轉換。

ws的子協議

客戶端向服務器發起握手請求的header中可能帶有“Sec-WebSocket-Protocol”字段,用來指定一個特定的子協議,一旦這個字段有設置,那麼服務器需要在建立連接的響應頭中包含同樣的字段,內容就是選擇的子協議之一。

子協議的命名應該是註冊過的(有一套規範)。
爲了避免潛在的衝突,建議子協議的源(發起者)使用ASCII編碼的域名。
例子:
一個註冊過的子協議叫“chat.xxx.com”,另一個叫“chat.xxx.org”。這兩個子協議都會被server同時實現,server會動態的選擇使用哪個子協議(取決於客戶端發送過來的值)。

Extensions

擴展是用來增加ws協議一些新特性的,這裏就不詳細說了。

建立連接部分代碼

上面說的僅僅是個概述,重要的是該如何在我們的web應用中使用或者說該如何建立一個基於WebSocket的應用呢?

我直說了,客戶端使用WebSocket簡直易如反掌,服務端實現WebSocket真是難的一B啊!尤其是我們現在還沒有學過計算機網絡,對一些網絡底層的(如TCP/IP協議)知識瞭解的太少,理解並實現WebSocket確實不太容易。所以這次我先把WebSocket用提供一部分接口的高級語言來實現。

Node.js的異步I/O模型實在是太適合這種類型的應用了,因此我選擇它作爲I/O編程的首選。來看下面的JavaScript代碼~:
Note:以下代碼僅用於闡明原理,不可用於生產環境!

      var http = require('http');
    var crypto = require('crypto');

    var MAGIC_STRING = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";

    // HTTP服務器部分
    var server = http.createServer(function (req, res) {
      res.end('websocket test\r\n');
    });

    // Upgrade請求處理
    server.on('upgrade', callback);

    function callback(req, socket) {
      // 計算返回的key
      var resKey = crypto.createHash('sha1')
        .update(req.headers['sec-websocket-key'] + MAGIC_STRING)
        .digest('base64');

      // 構造響應頭
      resHeaders = ([
        'HTTP/1.1 101 Switching Protocols',
        'Upgrade: websocket',
        'Connection: Upgrade',
        'Sec-WebSocket-Accept: ' + resKey
      ]).concat('', '').join('\r\n');

      // 添加通信數據處理
      socket.on('data', function (data) {
        // ...
      });

      // 響應給客戶端
      socket.write(resHeaders);
    }

    server.listen(3000);

上面的代碼是等待客戶端與之握手,當有客戶端發出請求時,會按照“加密-編碼-返回”的流程與之建立通信通道。既然連接已建立,接下來就是雙方的通信了。爲了讓大家明白WebSocket的全程使用,在此之前有必要提一下支持WebSocket的底層協議的實現。

協議

協議這種東西就像某種魔法,賦予了計算機之間各種神奇的通信能力,但對用戶來說卻是透明的。
不過對於WebSocket協議,我們可以透過IETF的RFC規範,看到關於實現WebSocket細節的每次變更與修正。

Frame

前面已經說過了WebSocket在客戶端與服務端的“Hand-Shaking”實現,所以這裏講數據傳輸。
WebSocket傳輸的數據都是以Frame(幀)的形式實現的,就像TCP/UDP協議中的報文段Segment。下面就是一個Frame:(以bit爲單位表示)

  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 ...                |
 +---------------------------------------------------------------+

按照RFC中的描述:

  • FIN: 1 bit

    表示這是一個消息的最後的一幀。第一個幀也可能是最後一個。  
    %x0 : 還有後續幀  
    %x1 : 最後一幀
    
  • RSV1、2、3: 1 bit each

    除非一個擴展經過協商賦予了非零值以某種含義,否則必須爲0
    如果沒有定義非零值,並且收到了非零的RSV,則websocket鏈接會失敗
    
  • Opcode: 4 bit

    解釋說明 “Payload data” 的用途/功能
    如果收到了未知的opcode,最後會斷開鏈接
    定義了以下幾個opcode值:
        %x0 : 代表連續的幀
        %x1 : text幀
        %x2 : binary幀
        %x3-7 : 爲非控制幀而預留的
        %x8 : 關閉握手幀
        %x9 : ping幀
    %xA :  pong幀
    %xB-F : 爲非控制幀而預留的
    
  • Mask: 1 bit

    定義“payload data”是否被添加掩碼
    如果置1, “Masking-key”就會被賦值
    所有從客戶端發往服務器的幀都會被置1
    
  • Payload length: 7 bit | 7+16 bit | 7+64 bit

    “payload data” 的長度如果在0~125 bytes範圍內,它就是“payload length”,
    如果是126 bytes, 緊隨其後的被表示爲16 bits的2 bytes無符號整型就是“payload length”,
    如果是127 bytes, 緊隨其後的被表示爲64 bits的8 bytes無符號整型就是“payload length”
    
  • Masking-key: 0 or 4 bytes

    所有從客戶端發送到服務器的幀都包含一個32 bits的掩碼(如果“mask bit”被設置成1),否則爲0 bit。一旦掩碼被設置,所有接收到的payload data都必須與該值以一種算法做異或運算來獲取真實值。(見下文)
    
  • Payload data: (x+y) bytes

    它是"Extension data"和"Application data"的總和,一般擴展數據爲空。
    
  • Extension data: x bytes

    除非擴展被定義,否則就是0
    任何擴展必須指定其Extension data的長度
    
  • Application data: y bytes

    佔據"Extension data"之後的剩餘幀的空間
    

注意:這些數據都是以二進制形式表示的,而非ascii編碼字符串

構造Frame

Frame的結構已經清楚了,我們就構造一個Frame。
在構造時,我們可以把Frame分成兩段:控制位數據位。其中控制位就是Frame的前兩字節,包含FIN、Opcode等與該Frame的元信息。

Note:網絡中使用大端次序(Big endian)表示大於一字節的數據,稱之爲網絡字節序。
Node.js中提供了Buffer對象,專門用來彌補JavaScript在處理字節數據上的不足,這裏正好可以用它來完成這個任務:

  // 控制位: FIN, Opcode, MASK, Payload_len
  var preBytes = [], 
      payBytes = new Buffer('test websocket'), 
      mask = 0;
      masking_key = Buffer.randomByte(4);

  var dataLength = payBytes.length;

  // 構建Frame的第一字節
  preBytes.push((frame['FIN'] << 7) + frame['Opcode']);

  // 處理不同長度的dataLength,構建Frame的第二字節(或第2~第8字節)
  // 注意這裏以大端字節序構建dataLength > 126的dataLenght
  if (dataLength < 126) {
    preBytes.push((frame['MASK'] << 7) + dataLength);
  } else if (dataLength < 65536) {
    preBytes.push(
      (frame['MASK'] << 7) + 126, 
      (dataLength & 0xFF00) >> 8,
      dataLength & 0xFF
    );
  } else {
    preBytes.push(
      (frame['MASK'] << 7) + 127,
      0, 0, 0, 0,
      (dataLength & 0xFF000000) >> 24,
      (dataLength & 0xFF0000) >> 16,
      (dataLength & 0xFF00) >> 8,
      dataLength & 0xFF
    );
  }

  preBytes = new Buffer(preBytes);

  // 如果有掩碼,就對數據進行加密,並構建之後的控制位
  if (mask) {
    preBytes = Buffer.concat([preBytes, masking_key]);
    for (var i = 0; i < dataLength; i++) 
      payBytes[i] ^= masking_key[i % 4];
  }

  // 生成一個Frame
  var frame = Buffer.concat([preBytes, payBytes]);

按照這種格式,就定義好了一個幀,客戶端或者服務器就可以用這個幀來互傳數據了。既然數據已經接收,接下來看看如何處理這些數據。

Masking

規範裏解釋了Masking-key掩碼的作用了:就是當mask字段的值爲1時,payload-data字段的數據需要經這個掩碼進行解密。

在處理數據之前,我們要清楚一件事:服務器推送到客戶端的消息中,mask字段是0,也就是說Masking-key爲空。這樣的話,數據的解析就不涉及到掩碼,直接使用就行。

但是我們前面提到過,如果消息是從客戶端發送到服務器,那麼mask一定是1,Masking-key一定是一個32bit的值。下面我們來看看數據是如何解析的:

當消息到達服務器後,服務器程序就開始以字節爲單位逐步讀取這個幀,當讀取到payload-data時,首先將數據按byte依次與Masking-key中的4個byte按照如下算法做異或:

      //假設我們發送的"Payload data"以變量`data`表示,字節(byte)數爲len;
      //masking_key爲4byte的mask掩碼組成的數組
    //offset:跳過的字節數

    for (var i = 0; i < len; i++) {
        var j = i % 4;
        data[offset + i] ^= masking_key[j];
    }

上面的JavaScript代碼給出了掩碼Masking-key是如何解密Payload-data的:先對i取模來獲得要使用的masking-key的索引,然後用data[offset + i]masking_key[j]做異或,從而得到真實的byte數據。

控制幀

控制幀用來說明WebSocket的狀態信息,用來控制分片、連接的關閉等等。所有的控制幀必須有一個小於等於125字節的payload,並且control Frames不允許被分片Opcode0x0(持續的幀),0x8(關閉連接),0x9(Ping幀)和0xA(Pong幀)代表控制幀。

一般Ping Frame用來對一個有超時機制的套接字keepalive或者驗證對方是否有響應。Pong Frame就是對Ping的迴應。

數據幀

前面我們總是談到“控制幀”和“非控制幀”,想必大家已經看出來一些門路。其實數據幀就是非控制幀。因爲這個幀並不是用來提供協議連接狀態信息的。數據幀由最高符號位是0的Opcode確定,現在可用的幾個數據幀的Opcode是0x1(utf-8文本)、0x2(二進制數據)。

分片(Fragment)

理論上來說,每個幀(Frame)的大小是沒有限制的,因爲payload-data在整個幀的最後。但是發送的數據有不能太大,否則 WebSocket 很可能無法高效的利用網絡帶寬。那如果我們想傳點大數據該怎麼辦呢?WebSocket協議給我們提供了一個方法:分片,將原本一個大的幀拆分成數個小的幀。下面是把一個大的Frame分片的圖示:

  編號:      0  1  ....  n-2 n-1
  分片:     |——|——|......|——|——|
  FIN:      0  0  ....   0  1
  Opcode:   !0 0  ....   0  0

由圖可知,第一個分片的FIN爲0,Opcode爲非0值(0x1或0x2),最後一個分片的FIN爲1,Opcode爲0。中間分片的FINOpcode二者均爲0。

Note1:消息的分片必須由發送者按給定的順序發送給接收者。

Note2:控制幀禁止分片

Note3:接受者不必按順序緩存整個frame來處理

關閉連接

正常的連接關閉流程

  1. 發送關閉連接請求(Close Handshake)
    即發送Close Frame(Opcode爲0x8)。一旦一端發送/接收了一個Close Frame,就開始了Close Handshake,並且連接狀態變爲Closing
    Close Frame中如果包含Payload data,則data的前2字節必須爲兩字節的無符號整形,(同樣遵循網絡字節序:BE)用於表示狀態碼,如果2byte之後仍有內容,則應包含utf-8編碼的關閉理由
    如果一端在之前未發送過Close Frame,則當他收到一個Close Frame時,必須回覆一個Close Frame。但如果它正在發送數據,則可以推遲到當前數據發送完,再發送Close Frame。比如Close Frame在分片發送時到達,則要等到所有剩餘分片發送完之後,纔可以作出回覆。
  2. 關閉WebSocket連接
    當一端已經收到Close Frame,並已發送了Close Frame時,就可以關閉連接了,close handshake過程結束。這時丟棄所有已經接收到的末尾字節。
  3. 關閉TCP連接
    當底層TCP連接關閉時,連接狀態變爲Closed

clean closed

如果TCP連接在Close handshake完成之後關閉,就表示WebSocket連接已經clean closed(徹底關閉)了。
如果WebSocket連接並未成功建立,狀態也爲連接已關閉,但並不是clean closed

正常關閉

正常關閉過程屬於clean close,應當包含close handshake

通常來講,應該由服務器關閉底層TCP連接,而客戶端應該等待服務器關閉連接,除非等待超時的話,那麼自己關閉底層TCP連接。

服務器可以隨時關閉WebSocket連接,而客戶端不可以主動斷開連接。

異常關閉

  1. 由於某種算法或規定,一端直接關閉連接。(特指在open handshake(打開連接)階段)
  2. 底層連接丟失導致的連接中斷。

連接失敗

由於某種算法或規範要求指定連接失敗。這時,客戶端和服務器必須關閉WebSocket連接。當一端得知連接失敗時,不準再處理數據,包括響應close frame

從異常關閉中恢復

爲了防止海量客戶端同時發起重連請求(reconnect),客戶端應該推遲一個隨機時間後重新連接,可以選擇回退算法來實現,比如截斷二進制指數退避算法

關於補充

這兩篇blog裏主要用自然語言講了WebSocket的實現。代碼的細節操作(例如:處理數據、安全處理等)並沒有給出,因爲核心實現原理已經闡明。

因爲近期寫了一個比較完整的WebSocket庫RocketEngine,在編碼過程中發現了好多需要注意的問題,特此加以補充和修正,增加了部分章節,改正了一些不精確的說法,同時將兩篇日誌合併。

如需詳細學習,請戳=> RocketEngine(附詳細註釋與wiki)

轉自https://github.com/abbshr/abbshr.github.io/issues/22

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章