一個 WebSocket 服務器是如何開發出來的?

  • 2019 年 10 月 4 日
  • 筆記

WebSocket 協議是為了解決 http 協議的無狀態、短連接(通常是)和服務端無法主動給客戶端推送數據等問題而開發的新型協議,其通信基礎也是基於 TCP。由於較舊的瀏覽器可能不支持 WebSocket 協議,所以使用 WebSocket 協議的通信雙方在進行 TCP 三次握手之後,還要再額外地進行一次握手,這一次的握手通信雙方的報文格式是基於 HTTP 協議改造的。

WebSocket 握手過程

TCP 三次握手的過程我們就不在這裡贅述了,任何一本網絡通信書籍上都有詳細的介紹。我們這裡來介紹一下 WebSocket 通信最後一次的握手過程。

握手開始後,一方給另外一方發送一個 http 協議格式的報文,這個報文格式大致如下:

GET /realtime HTTP/1.1rn  Host: 127.0.0.1:9989rn  Connection: Upgradern  Pragma: no-cachern  Cache-Control: no-cachern  User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)rn  Upgrade: websocketrn  Origin: http://xyz.comrn  Sec-WebSocket-Version: 13rn  Accept-Encoding: gzip, deflate, brrn  Accept-Language: zh-CN,zh;q=0.9,en;q=0.8rn  Sec-WebSocket-Key: IqcAWodjyPDJuhGgZwkpKg==rn  Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bitsrn  rn  

對這個格式有如下要求:

  • 握手必須是一個有效的 HTTP 請求;
  • 請求的方法必須為 GET,且 HTTP 版本必須是 1.1;
  • 請求必須包含 Host 字段信息;
  • 請求必須包含 Upgrade字段信息,值必須為 websocket;
  • 請求必須包含 Connection 字段信息,值必須為 Upgrade;
  • 請求必須包含 Sec-WebSocket-Key 字段,該字段值是客戶端的標識編碼成 base64 格式
  • 請求必須包含 Sec-WebSocket-Version 字段信息,值必須為 13;
  • 請求必須包含 Origin 字段;
  • 請求可能包含 Sec-WebSocket-Protocol 字段,規定子協議;
  • 請求可能包含 Sec-WebSocket-Extensions字段規定協議擴展;
  • 請求可能包含其他字段,如 cookie 等。

對端收到該數據包後如果支持 WebSocket 協議,會回復一個 http 格式的應答,這個應答報文的格式大致如下:

HTTP/1.1 101 Switching Protocolsrn  Upgrade: websocketrn  Connection: Upgradern  Sec-WebSocket-Accept: 5wC5L6joP6tl31zpj9OlCNv9Jy4=rn  rn  

上面列出了應答報文中必須包含的幾個字段和對應的值,即 UpgradeConnectionSec-WebSocket-Accept,注意:第一行必須是 HTTP/1.1 101 Switching Protocolsrn

對於字段 Sec-WebSocket-Accept 字段,其值是根據對端傳過來的 Sec-WebSocket-Key 的值經過一定的算法計算出來的,這樣應答的雙方才能匹配。算法如下:

  1. 將 Sec-WebSocket-Key 值與固定字符串「258EAFA5-E914-47DA-95CA-C5AB0DC85B11」 進行拼接;
  2. 將拼接後的字符串進行 SHA-1 處理,然後將結果再進行 base64 編碼。

算法公式:

mask  = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";  // 這是算法中要用到的固定字符串  accept = base64( sha1( Sec-WebSocket-Key + mask ) );  

我用 C++ 實現了該算法:

namespace uWS {    struct WebSocketHandshake {      template <int N, typename T>      struct static_for {          void operator()(uint32_t *a, uint32_t *b) {              static_for<N - 1, T>()(a, b);              T::template f<N - 1>(a, b);          }      };        template <typename T>      struct static_for<0, T> {          void operator()(uint32_t *a, uint32_t *hash) {}      };        template <int state>      struct Sha1Loop {          static inline uint32_t rol(uint32_t value, size_t bits) {return (value << bits) | (value >> (32 - bits));}          static inline uint32_t blk(uint32_t b[16], size_t i) {              return rol(b[(i + 13) & 15] ^ b[(i + 8) & 15] ^ b[(i + 2) & 15] ^ b[i], 1);          }            template <int i>          static inline void f(uint32_t *a, uint32_t *b) {              switch (state) {              case 1:                  a[i % 5] += ((a[(3 + i) % 5] & (a[(2 + i) % 5] ^ a[(1 + i) % 5])) ^ a[(1 + i) % 5]) + b[i] + 0x5a827999 + rol(a[(4 + i) % 5], 5);                  a[(3 + i) % 5] = rol(a[(3 + i) % 5], 30);                  break;              case 2:                  b[i] = blk(b, i);                  a[(1 + i) % 5] += ((a[(4 + i) % 5] & (a[(3 + i) % 5] ^ a[(2 + i) % 5])) ^ a[(2 + i) % 5]) + b[i] + 0x5a827999 + rol(a[(5 + i) % 5], 5);                  a[(4 + i) % 5] = rol(a[(4 + i) % 5], 30);                  break;              case 3:                  b[(i + 4) % 16] = blk(b, (i + 4) % 16);                  a[i % 5] += (a[(3 + i) % 5] ^ a[(2 + i) % 5] ^ a[(1 + i) % 5]) + b[(i + 4) % 16] + 0x6ed9eba1 + rol(a[(4 + i) % 5], 5);                  a[(3 + i) % 5] = rol(a[(3 + i) % 5], 30);                  break;              case 4:                  b[(i + 8) % 16] = blk(b, (i + 8) % 16);                  a[i % 5] += (((a[(3 + i) % 5] | a[(2 + i) % 5]) & a[(1 + i) % 5]) | (a[(3 + i) % 5] & a[(2 + i) % 5])) + b[(i + 8) % 16] + 0x8f1bbcdc + rol(a[(4 + i) % 5], 5);                  a[(3 + i) % 5] = rol(a[(3 + i) % 5], 30);                  break;              case 5:                  b[(i + 12) % 16] = blk(b, (i + 12) % 16);                  a[i % 5] += (a[(3 + i) % 5] ^ a[(2 + i) % 5] ^ a[(1 + i) % 5]) + b[(i + 12) % 16] + 0xca62c1d6 + rol(a[(4 + i) % 5], 5);                  a[(3 + i) % 5] = rol(a[(3 + i) % 5], 30);                  break;              case 6:                  b[i] += a[4 - i];              }          }      };        /**       * sha1 函數的實現       */      static inline void sha1(uint32_t hash[5], uint32_t b[16]) {          uint32_t a[5] = {hash[4], hash[3], hash[2], hash[1], hash[0]};          static_for<16, Sha1Loop<1>>()(a, b);          static_for<4, Sha1Loop<2>>()(a, b);          static_for<20, Sha1Loop<3>>()(a, b);          static_for<20, Sha1Loop<4>>()(a, b);          static_for<20, Sha1Loop<5>>()(a, b);          static_for<5, Sha1Loop<6>>()(a, hash);      }        /**       * base64 編碼函數       */      static inline void base64(unsigned char *src, char *dst) {          const char *b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";          for (int i = 0; i < 18; i += 3) {              *dst++ = b64[(src[i] >> 2) & 63];              *dst++ = b64[((src[i] & 3) << 4) | ((src[i + 1] & 240) >> 4)];              *dst++ = b64[((src[i + 1] & 15) << 2) | ((src[i + 2] & 192) >> 6)];              *dst++ = b64[src[i + 2] & 63];          }          *dst++ = b64[(src[18] >> 2) & 63];          *dst++ = b64[((src[18] & 3) << 4) | ((src[19] & 240) >> 4)];          *dst++ = b64[((src[19] & 15) << 2)];          *dst++ = '=';      }    public:      /**       * 生成 Sec-WebSocket-Accept 算法       * @param input 對端傳過來的Sec-WebSocket-Key值       * @param output 存放生成的 Sec-WebSocket-Accept 值       */      static inline void generate(const char input[24], char output[28]) {          uint32_t b_output[5] = {              0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0          };          uint32_t b_input[16] = {              0, 0, 0, 0, 0, 0, 0x32353845, 0x41464135, 0x2d453931, 0x342d3437, 0x44412d39,              0x3543412d, 0x43354142, 0x30444338, 0x35423131, 0x80000000          };            for (int i = 0; i < 6; i++) {              b_input[i] = (input[4 * i + 3] & 0xff) | (input[4 * i + 2] & 0xff) << 8 | (input[4 * i + 1] & 0xff) << 16 | (input[4 * i + 0] & 0xff) << 24;          }          sha1(b_output, b_input);          uint32_t last_b[16] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 480};          sha1(b_output, last_b);          for (int i = 0; i < 5; i++) {              uint32_t tmp = b_output[i];              char *bytes = (char *) &b_output[i];              bytes[3] = tmp & 0xff;              bytes[2] = (tmp >> 8) & 0xff;              bytes[1] = (tmp >> 16) & 0xff;              bytes[0] = (tmp >> 24) & 0xff;          }          base64((unsigned char *) b_output, output);      }  };  

握手完成之後,通信雙方就可以保持連接並相互發送數據了。

WebSocket 協議格式

WebSocket 協議格式的 RFC 文檔可以參見:[]https://tools.ietf.org/html/rfc6455。

常聽人說 WebSocket 協議是基於 http 協議的,因此我在剛接觸 WebSocket 協議時總以為每個 WebSocket 數據包都是 http 格式,其實不然,WebSocket 協議除了上文中提到的這次握手過程中使用的數據格式是 http 協議格式,之後的通信雙方使用的是另外一種自定義格式。每一個 WebSocket 數據包我們稱之為一個 Frame(幀),其格式圖如下:

我們來逐一介紹一下上文中各字段的含義:

第一個位元組內容:

  • FIN 標誌,占第一個位元組中的第一位(bit),即一位元組中的最高位(一位元組等於 8 位),該標誌置 0 時表示當前包未結束後續有該包的分片,置 1 時表示當前包已結束後續無該包的分片。我們在解包時,如果發現該標誌為 1,則需要將當前包的「包體」數據(即圖中 Payload Data)緩存起來,與後續包分片組裝在一起,才是一個完整的包體數據。
  • RSV1RSV2RSV3 每個佔一位,一共三位,這三個位是保留字段(默認都是 0),你可以用它們作為通信的雙方協商好的一些特殊標誌;
  • opCode 操作類型,佔四位,目前操作類型及其取值如下: // 4 bits enum OpCode { //表示後續還有新的 Frame CONTINUATION_FRAME = 0x0, //包體是文本類型的Frame TEXT_FRAME = 0x1, //包體是二進制類型的 Frame BINARY_FRAME = 0x2, //保留值 RESERVED1 = 0x3, RESERVED2 = 0x4, RESERVED3 = 0x5, RESERVED4 = 0x6, RESERVED5 = 0x7, //建議對端關閉的 Frame CLOSE = 0x8, //心跳包中的 ping Frame PING = 0x9, //心跳包中的 pong Frame PONG = 0xA, //保留值 RESERVED6 = 0xB, RESERVED7 = 0xC, RESERVED8 = 0xD, RESERVED9 = 0xE, RESERVED10 = 0xF };

第二個位元組內容:

  • mask 標誌,佔一位,該標誌為 1 時,表明該 Frame 在包體長度字段後面攜帶 4 個位元組的 masking-key 信息,為 0 時則沒有 masking-key 信息。masking-key 信息下文會介紹。
  • Payload len,佔七位,該字段表示包體的長度信息。由於 Payload length 值使用了一個位元組的低七位(7 bit),因此其能表示的長度範圍是 0 ~ 127,其中 126127 被當做特殊標誌使用。 當該字段值是 0~125 時,表示跟在 masking-key 字段後面的就是包體內容長度;當該值是 126 時,接下來的 2 個位元組內容表示跟在 masking-key 字段後面的包體內容的長度(即圖中的 Extended Payload Length)。由於 2 個位元組最大表示的無符號整數是 0xFFFF(十進制是 65535, 編譯器提供了一個宏 UINT16_MAX 來表示這個值)。如果包體長度超過 65535,包長度就記錄不下了,此時應該將 Payload length 設置為 127,以使用更多的位元組數來表示包體長度。 當 Payload length127 時,接下來則用 8 個位元組內容表示跟在 masking-key 字段後面的包體內容的長度(Extended Payload Length)。

總結起來,Payload length = 0 ~ 125,Extended Payload Length 不存在, 0 位元組;Payload length = 126, Extended Payload Length 占 2 位元組;Payload length = 127 時,Extended Payload Length 占 8 位元組。 另外需要注意的是,當 Payload length = 125 或 126 時接下來存儲實際包長的 2 位元組或 8 位元組,其值必須轉換為網絡位元組序(Big Endian)。

  • Masking-key ,如果前面的 mask 標誌設置成 1,則該字段存在,占 4 個位元組;反之,則 Frame 中不存在存儲 masking-key 字段的位元組。

網絡上一些資料說,客戶端(主動發起握手請求的一方)給服務器(被動接受握手的另一方)發的 frame 信息(包信息),mask 標誌必須是 1,而服務器給客戶端發送的 frame 信息中 mask 標誌是 0。因此,客戶端發給服務器端的數據幀中存在 4 位元組的 masking-key,而服務器端發給客戶端的數據幀中不存在 masking-key 信息。 我在 Websocket 協議的 RFC 文檔中並沒有看到有這種強行規定,另外在研究了一些 websocket 庫的實現後發現,此結論並不一定成立,客戶端發送的數據也可能沒有設置 mask 標誌。

如果存在 masking-key 信息,則數據幀中的數據(圖中 Payload Data)都是經過與 masking-key 進行運算後的內容。無論是將原始數據與 masking-key 運算後得到傳輸的數據,還是將傳輸的數據還原成原始數據,其算法都是一樣的。算法如下:

假設:  original-octet-i:為原始數據的第 i 位元組。  transformed-octet-i:為轉換後的數據的第 i 位元組。  j:為i mod 4的結果。  masking-key-octet-j:為 mask key 第 j 位元組。  

算法描述為:original-octet-i 與 masking-key-octet-j 異或後,得到 transformed-octet-i。

  j  = i MOD 4  transformed-octet-i = original-octet-i XOR masking-key-octet-j  

我用 C++ 實現了該算法:

  /**     * @param src 函數調用前是原始需要傳輸的數據,函數調用後是mask或者unmask後的內容     * @param maskingKey 四位元組     */    void maskAndUnmaskData(std::string& src, const char* maskingKey)    {        char j;        for (size_t n = 0; n < src.length(); ++n)        {            j = n % 4;            src[n] = src[n] ^ maskingKey[j];        }    }  

使用上面的描述可能還不是太清楚,我們舉個例子,假設有一個客戶端發送給服務器的數據包,那麼 mask = 1,即存在 4 位元組的 masking-key,當包體數據長度在 0 ~ 125 之間時,該包的結構:

第 1 個位元組第 0 位    => FIN  第 1 個位元組第 1 ~ 3位 => RSV1 + RSV2 + RSV3  第 1 個位元組第 4 ~ 7位 => opcode  第 2 個位元組第 0 位    => mask(等於 1)  第 2 個位元組第 1 ~ 7位 => 包體長度  第 3 ~ 6 個位元組      =>  masking-key  第 7 個位元組及以後     =>  包體內容  

這種情形,包頭總共 6 個位元組。

當包體數據長度大於125 且小於等於 UINT16_MAX 時,該包的結構:

第 1 個位元組第 0 位    => FIN  第 1 個位元組第 1 ~ 3位 => RSV1 + RSV2 + RSV3  第 1 個位元組第 4 ~ 7位 => opcode  第 2 個位元組第 0 位    => mask(等於 1)  第 2 個位元組第 1 ~ 7位 => 開啟擴展包頭長度標誌,值為 126  第 3 ~ 4 個位元組      =>  包頭長度  第 5 ~ 8 個位元組      =>  masking-key  第 9 個位元組及以後     =>  包體內容  

這種情形,包頭總共 8 個位元組。

當包體數據長度大於 UINT16_MAX 時,該包的結構:

第 1 個位元組第 0 位    => FIN  第 1 個位元組第 1 ~ 3位 => RSV1 + RSV2 + RSV3  第 1 個位元組第 4 ~ 7位 => opcode  第 2 個位元組第 0 位    => mask(等於 1)  第 2 個位元組第 1 ~ 7位 => 開啟擴展包頭長度標誌,值為 127  第 3 ~ 10 個位元組      =>  包頭長度  第 11 ~ 14 個位元組     =>  masking-key  第 15 個位元組及以後     =>  包體內容  

這種情形,包頭總共 14 個位元組。由於存儲包體長度使用 8 位元組存儲(無符號),因此最大包體長度是 0xFFFFFFFFFFFFFFFF,這是一個非常大的數字,但實際開發中,我們用不到這麼長的包體,且當包體超過一定值時,我們就應該分包(分片)了。

分包的邏輯經過前面的分析也很簡單,假設將一個包分成 3 片,那麼應將第一個和第二個包片的第一個位元組的第一位 FIN 設置為 0,OpCode 設置為 CONTINUATION_FRAME(也是 0);第三個包片 FIN 設置為 1,表示該包至此就結束了,OpCode 設置為想要的類型(如 TEXT_FRAME、BINARY_FRAME 等)。對端收到該包時,如果發現標誌 FIN = 0 或 OpCode = 0,將該包包體的數據暫存起來,直到收到 FIN = 1,OpCode ≠ 0 的包,將該包的數據與前面收到的數據放在一起,組裝成一個完整的業務數據。示例代碼如下:

//某次解包後得到包體 payloadData,根據 FIN 標誌判斷,  //如果 FIN = true,則說明一個完整的業務數據包已經收完整,  //調用 processPackage() 函數處理該業務數據  //否則,暫存於 m_strParsedData 中  //每次處理完一個完整的業務包數據,即將暫存區m_strParsedData中的數據清空  if (FIN)  {      m_strParsedData.append(payloadData);      processPackage(m_strParsedData);      m_strParsedData.clear();  }  else  {      m_strParsedData.append(payloadData);  }  

WebSocket 壓縮格式

WebSocket 對於包體也支持壓縮的,是否需要開啟壓縮需要通信雙方在握手時進行協商。讓我們再看一下握手時主動發起一方的包內容:

GET /realtime HTTP/1.1rn  Host: 127.0.0.1:9989rn  Connection: Upgradern  Pragma: no-cachern  Cache-Control: no-cachern  User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)rn  Upgrade: websocketrn  Origin: http://xyz.comrn  Sec-WebSocket-Version: 13rn  Accept-Encoding: gzip, deflate, brrn  Accept-Language: zh-CN,zh;q=0.9,en;q=0.8rn  Sec-WebSocket-Key: IqcAWodjyPDJuhGgZwkpKg==rn  Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bitsrn  rn  

在該包中 Sec-WebSocket-Extensions 字段中有一個值 permessage-deflate,如果發起方支持壓縮,在發起握手時將包中帶有該標誌,對端收到後,如果也支持壓縮,則在應答的包也帶有該字段,反之不帶該標誌即表示不支持壓縮。例如:

HTTP/1.1 101 Switching Protocolsrn  Upgrade: websocketrn  Connection: Upgradern  Sec-WebSocket-Accept: 5wC5L6joP6tl31zpj9OlCNv9Jy4=rn  Sec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover  rn  

如果雙方都支持壓縮,此後通信的包中的包體部分都是經過壓縮後的,反之是未壓縮過的。在解完包得到包體(即 Payload Data) 後,如果有握手時有壓縮標誌並且乙方也回復了支持壓縮,則需要對該包體進行解壓;同理,在發數據組裝 WebSocket 包時,需要先將包體(即 Payload Data)進行壓縮。

收到包需要解壓示例代碼:

bool MyWebSocketSession::processPackage(const std::string& data)  {      std::string out;      //m_bClientCompressed在握手確定是否支持壓縮      if (m_bClientCompressed)      {          //解壓          if (!ZlibUtil::inflate(data, out))          {              LOGE("uncompress failed, dataLength: %d", data.length());              return false;          }        }      else          out = data;        //如果不需要解壓,則out=data,反之則out是解壓後的數據      LOGI("receid data: %s", out.c_str());          return Process(out);  }  

對包進行壓縮的算法:

size_t dataLength = data.length();  std::string destbuf;  if (m_bClientCompressed)  {      //按需壓縮      if (!ZlibUtil::deflate(data, destbuf))      {          LOGE("compress buf error, data: %s", data.c_str());          return;      }  }  else      destbuf = data;    LOGI("destbuf.length(): %d", destbuf.length());  

壓縮和解壓算法即 gzip 壓縮算法。

文章轉載自公眾號 高性能服務器開發 , 作者 張小方