Websocket協議的學習、調研和實現

本文章同時發在 cpper.infohtml

1. websocket是什麼

Websocket是html5提出的一個協議規範,參考rfc6455。html5

websocket約定了一個通訊的規範,經過一個握手的機制,客戶端(瀏覽器)和服務器(webserver)之間能創建一個相似tcp的鏈接,從而方便c-s之間的通訊。在websocket出現以前,web交互通常是基於http協議的短鏈接或者長鏈接。git

WebSocket是爲解決客戶端與服務端實時通訊而產生的技術。websocket協議本質上是一個基於tcp的協議,是先經過HTTP/HTTPS協議發起一條特殊的http請求進行握手後建立一個用於交換數據的TCP鏈接,此後服務端與客戶端經過此TCP鏈接進行實時通訊。github

注意:此時再也不須要原HTTP協議的參與了web

2. websocket的優勢

之前web server實現推送技術或者即時通信,用的都是輪詢(polling),在特色的時間間隔(好比1秒鐘)由瀏覽器自動發出請求,將服務器的消息主動的拉回來,在這種狀況下,咱們須要不斷的向服務器發送請求,然而HTTP request 的header是很是長的,裏面包含的數據可能只是一個很小的值,這樣會佔用不少的帶寬和服務器資源。ajax

而最比較新的技術去作輪詢的效果是Comet – 用了AJAX。但這種技術雖然可達到全雙工通訊,但依然須要發出請求(reuqest)。算法

WebSocket API最偉大之處在於服務器和客戶端能夠在給定的時間範圍內的任意時刻,相互推送信息。 瀏覽器和服務器只須要要作一個握手的動做,在創建鏈接以後,服務器能夠主動傳送數據給客戶端,客戶端也能夠隨時向服務器發送數據。 此外,服務器與客戶端之間交換的標頭信息很小。api

WebSocket並不限於以Ajax(或XHR)方式通訊,由於Ajax技術須要客戶端發起請求,而WebSocket服務器和客戶端能夠彼此相互推送信息;
httpserver_websocketserver
所以從服務器角度來講,websocket有如下好處:數組

  1. 節省每次請求的header
    http的header通常有幾十字節
  2. Server Push
    服務器能夠主動傳送數據給客戶端

3. 歷史沿革

3.1 http協議

1996年IETF HTTP工做組發佈了HTTP協議的1.0版本,到如今廣泛使用的版本1.1,HTTP協議經歷了17年的發展。 這種分佈式、無狀態、基於TCP的請求/響應式、在互聯網盛行的今天獲得普遍應用的協議。互聯網從興起到如今,經歷了門戶網站盛行的web1.0時代,然後隨着ajax技術的出現,發展爲web應用盛行的web2.0時代,現在又朝着web3.0的方向邁進。反觀http協議,從版本1.0發展到1.1,除了默認長鏈接以外就是緩存處理、帶寬優化和安全性等方面的不痛不癢的改進。它一直保留着無狀態、請求/響應模式,彷佛曆來沒意識到這應該有所改變。瀏覽器

3.2 經過腳本發送的http請求(Ajax)

傳統的web應用要想與服務器交互,必須提交一個表單(form),服務器接收並處理傳來的表單,而後返回全新的頁面,由於先後兩個頁面的數據大部分都是相同的,這個過程傳輸了不少冗餘的數據、浪費了帶寬。因而Ajax技術便應運而生。

Ajax是Asynchronous JavaScript and 的簡稱,由Jesse James Garrett 首先提出。這種技術開創性地容許瀏覽器腳本(JS)發送http請求。Outlook Web Access小組於98年使用,並很快成爲IE4.0的一部分,可是這個技術一直很小衆,直到2005年初,google在他的goole groups、gmail等交互式應用中普遍使用此種技術,才使得Ajax迅速被你們所接受。

Ajax的出現使客戶端與服務器端傳輸數據少了不少,也快了不少,也知足了以豐富用戶體驗爲特色的web2.0時代 初期發展的須要,可是慢慢地也暴露了他的弊端。好比沒法知足即時通訊等富交互式應用的實時更新數據的要求。這種瀏覽器端的小技術畢竟仍是基於http協議,http協議要求的請求/響應的模式也是沒法改變的,除非http協議自己有所改變。

3.3 一種hack技術(Comet)

以即時通訊爲表明的web應用程序對數據的Low Latency要求,傳統的基於輪詢的方式已經沒法知足,並且也會帶來很差的用戶體驗。因而一種基於http長鏈接的「服務器推」技術便被hack出來。這種技術被命名爲Comet,這個術語由Dojo Toolkit 的項目主管Alex Russell在博文Comet: Low Latency Data for the Browser首次提出,並沿用下來。

其實,服務器推很早就存在了,在經典的client/server模型中有普遍使用,只是瀏覽器太懶了,並無對這種技術提供很好的支持。可是Ajax的出現使這種技術在瀏覽器上實現成爲可能, google的gmail和gtalk的整合首先使用了這種技術。隨着一些關鍵問題的解決(好比IE的加載顯示問題),很快這種技術獲得了承認,目前已經有不少成熟的開源Comet框架。

如下是典型的Ajax和Comet數據傳輸方式的對比,區別簡單明瞭。典型的Ajax通訊方式也是http協議的經典使用方式,要想取得數據,必須首先發送請求。在Low Latency要求比較高的web應用中,只能增長服務器請求的頻率。Comet則不一樣,客戶端與服務器端保持一個長鏈接,只有客戶端須要的數據更新時,服務器才主動將數據推送給客戶端。

ajax__comet_websocket

Comet的實現主要有兩種方式:

  • 基於Ajax的長輪詢(long-polling)方式

ajax_longpolling

  • 基於 Iframe 及 htmlfile 的流(http streaming)方式

Iframe是html標記,這個標記的src屬性會保持對指定服務器的長鏈接請求,服務器端則能夠不停地返回數據,相對於第一種方式,這種方式跟傳統的服務器推則更接近。
在第一種方式中,瀏覽器在收到數據後會直接調用JS回調函數,可是這種方式該如何響應數據呢?能夠經過在返回數據中嵌入JS腳本的方式,如「」,服務器端將返回的數據做爲回調函數的參數,瀏覽器在收到數據後就會執行這段JS腳本。

iframe_htmlfile

3.4 Websocket---將來的解決方案

若是說Ajax的出現是互聯網發展的必然,那麼Comet技術的出現則更多透露出一種無奈,僅僅做爲一種hack技術,由於沒有更好的解決方案。Comet解決的問題應該由誰來解決纔是合理的呢?瀏覽器,html標準,仍是http標準?主角應該是誰呢?本質上講,這涉及到數據傳輸方式,http協議應首當其衝,是時候改變一下這個懶惰的協議的請求/響應模式了。

W3C給出了答案,在新一代html標準html5中提供了一種瀏覽器和服務器間進行全雙工通信的網絡技術Websocket。從Websocket草案得知,Websocket是一個全新的、獨立的協議,基於TCP協議,與http協議兼容、卻不會融入http協議,僅僅做爲html5的一部分。因而乎腳本又被賦予了另外一種能力:發起websocket請求。這種方式咱們應該很熟悉,由於Ajax就是這麼作的,所不一樣的是,Ajax發起的是http請求而已。

4. websocket邏輯

與http協議不一樣的請求/響應模式不一樣,Websocket在創建鏈接以前有一個Handshake(Opening Handshake)過程,在關閉鏈接前也有一個Handshake(Closing Handshake)過程,創建鏈接以後,雙方便可雙向通訊。
在websocket協議發展過程當中前先後後就出現了多個版本的握手協議,這裏分狀況說明一下:

  • 基於flash的握手協議
    使用場景是IE的多數版本,由於IE的多數版本不都不支持WebSocket協議,以及FF、CHROME等瀏覽器的低版本,尚未原生的支持WebSocket。此處,server惟一要作的,就是準備一個WebSocket-Location域給client,沒有加密,可靠性不好。

客戶端請求:

GET /ls HTTP/1.1
Upgrade: WebSocket
Connection: Upgrade
Host: www.qixing318.com
Origin: http://www.qixing318.com

服務器返回:

HTTP/1.1 101 Web Socket Protocol Handshake
Upgrade: WebSocket
Connection: Upgrade
WebSocket-Origin: http://www.qixing318.com
WebSocket-Location: ws://www.qixing318.com/ls
  • 基於md5加密方式的握手協議
    客戶端請求:

    GET /demo HTTP/1.1
    Host: example.com
    Connection: Upgrade
    Sec-WebSocket-Key2:
    Upgrade: WebSocket
    Sec-WebSocket-Key1:

    Origin: http://www.qixing318.com
    [8-byte security key]

服務端返回:

HTTP/1.1 101 WebSocket Protocol Handshake
Upgrade: WebSocket
Connection: Upgrade
WebSocket-Origin: http://www.qixing318.com
WebSocket-Location: ws://example.com/demo
[16-byte hash response]

其中 Sec-WebSocket-Key1,Sec-WebSocket-Key2 和 [8-byte security key] 這幾個頭信息是web server用來生成應答信息的來源,依據 draft-hixie-thewebsocketprotocol-76 草案的定義。
web server基於如下的算法來產生正確的應答信息:

1. 逐個字符讀取 Sec-WebSocket-Key1 頭信息中的值,將數值型字符鏈接到一塊兒放到一個臨時字符串裏,同時統計全部空格的數量;
2. 將在第(1)步裏生成的數字字符串轉換成一個整型數字,而後除以第(1)步裏統計出來的空格數量,將獲得的浮點數轉換成整數型;
3. 將第(2)步裏生成的整型值轉換爲符合網絡傳輸的網絡字節數組;
4. 對 Sec-WebSocket-Key2 頭信息一樣進行第(1)到第(3)步的操做,獲得另一個網絡字節數組;
5. 將 [8-byte security key] 和在第(3)、(4)步裏生成的網絡字節數組合併成一個16字節的數組;
6. 對第(5)步生成的字節數組使用MD5算法生成一個哈希值,這個哈希值就做爲安全密鑰返回給客戶端,以代表服務器端獲取了客戶端的請求,贊成建立websocket鏈接
  • 基於sha加密方式的握手協議
    也是目前見的最多的一種方式,這裏的版本號目前是須要13以上的版本。
    客戶端請求:

    GET /ls HTTP/1.1
    Upgrade: websocket
    Connection: Upgrade
    Host: www.qixing318.com
    Sec-WebSocket-Origin: http://www.qixing318.com
    Sec-WebSocket-Key: 2SCVXUeP9cTjV+0mWB8J6A==
    Sec-WebSocket-Version: 13

服務器返回:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: mLDKNeBNWz6T9SxU+o0Fy/HgeSw=

其中 server就是把客戶端上報的key拼上一段GUID( 「258EAFA5-E914-47DA-95CA-C5AB0DC85B11″),拿這個字符串作SHA-1 hash計算,而後再把獲得的結果經過base64加密,最後再返回給客戶端。

4.1 Opening Handshake:

客戶端發起鏈接Handshake請求

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13

服務器端響應:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
  • Upgrade:WebSocket
    表示這是一個特殊的 HTTP 請求,請求的目的就是要將客戶端和服務器端的通信協議從 HTTP 協議升級到 WebSocket 協議。
  • Sec-WebSocket-Key
    是一段瀏覽器base64加密的密鑰,server端收到後須要提取Sec-WebSocket-Key 信息,而後加密。
  • Sec-WebSocket-Accept
    服務器端在接收到的Sec-WebSocket-Key密鑰後追加一段神奇字符串「258EAFA5-E914-47DA-95CA-C5AB0DC85B11」,並將結果進行sha-1哈希,而後再進行base64加密返回給客戶端(就是Sec-WebSocket-Key)。 好比:

    function encry($req)
      {
          $key = $this->getKey($req);
          $mask = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; 
          # 將 SHA-1 加密後的字符串再進行一次 base64 加密
          return base64_encode(sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true));
      }
    若是加密算法錯誤,客戶端在進行校檢的時候會直接報錯。若是握手成功,則客戶端側會出發onopen事件。
  • Sec-WebSocket-Protocol
    表示客戶端請求提供的可供選擇的子協議,及服務器端選中的支持的子協議,「Origin」服務器端用於區分未受權的websocket瀏覽器
  • Sec-WebSocket-Version: 13
    客戶端在握手時的請求中攜帶,這樣的版本標識,表示這個是一個升級版本,如今的瀏覽器都是使用的這個版本。
  • HTTP/1.1 101 Switching Protocols
    101爲服務器返回的狀態碼,全部非101的狀態碼都表示handshake並未完成。

4.2 Data Framing

Websocket協議經過序列化的數據幀傳輸數據。數據封包協議中定義了opcode、payload length、Payload data等字段。其中要求:

  1. 客戶端向服務器傳輸的數據幀必須進行掩碼處理:服務器若接收到未通過掩碼處理的數據幀,則必須主動關閉鏈接。
  2. 服務器向客戶端傳輸的數據幀必定不能進行掩碼處理。客戶端若接收到通過掩碼處理的數據幀,則必須主動關閉鏈接。

針對上狀況,發現錯誤的一方可向對方發送close幀(狀態碼是1002,表示協議錯誤),以關閉鏈接。
具體數據幀格式以下圖所示:

websocket_frame

  • FIN
    標識是否爲此消息的最後一個數據包,佔 1 bit
  • RSV1, RSV2, RSV3: 用於擴展協議,通常爲0,各佔1bit
  • Opcode
    數據包類型(frame type),佔4bits
    0x0:標識一箇中間數據包
    0x1:標識一個text類型數據包
    0x2:標識一個binary類型數據包
    0x3-7:保留
    0x8:標識一個斷開鏈接類型數據包
    0x9:標識一個ping類型數據包
    0xA:表示一個pong類型數據包
    0xB-F:保留
  • MASK:佔1bits
    用於標識PayloadData是否通過掩碼處理。若是是1,Masking-key域的數據便是掩碼密鑰,用於解碼PayloadData。客戶端發出的數據幀須要進行掩碼處理,因此此位是1。
  • Payload length
    Payload data的長度,佔7bits,7+16bits,7+64bits:
    • 若是其值在0-125,則是payload的真實長度。
    • 若是值是126,則後面2個字節造成的16bits無符號整型數的值是payload的真實長度。注意,網絡字節序,須要轉換。
    • 若是值是127,則後面8個字節造成的64bits無符號整型數的值是payload的真實長度。注意,網絡字節序,須要轉換。

這裏的長度表示遵循一個原則,用最少的字節表示長度(儘可能減小沒必要要的傳輸)。舉例說,payload真實長度是124,在0-125之間,必須用前7位表示;不容許長度1是126或127,而後長度2是124,這樣違反原則。

  • Payload data
    應用層數據

    server解析client端的數據

    接收到客戶端數據後的解析規則以下:

  • 1byte
    • 1bit: frame-fin,x0表示該message後續還有frame;x1表示是message的最後一個frame
    • 3bit: 分別是frame-rsv一、frame-rsv2和frame-rsv3,一般都是x0
    • 4bit: frame-opcode,x0表示是延續frame;x1表示文本frame;x2表示二進制frame;x3-7保留給非控制frame;x8表示關 閉鏈接;x9表示ping;xA表示pong;xB-F保留給控制frame
  • 2byte
    • 1bit: Mask,1表示該frame包含掩碼;0表示無掩碼
    • 7bit、7bit+2byte、7bit+8byte: 7bit取整數值,若在0-125之間,則是負載數據長度;如果126表示,後兩個byte取無符號16位整數值,是負載長度;127表示後8個 byte,取64位無符號整數值,是負載長度
    • 3-6byte: 這裏假定負載長度在0-125之間,而且Mask爲1,則這4個byte是掩碼
    • 7-end byte: 長度是上面取出的負載長度,包括擴展數據和應用數據兩部分,一般沒有擴展數據;若Mask爲1,則此數據須要解碼,解碼規則爲- 1-4byte掩碼循環和數據byte作異或操做。

示例代碼:

/// 解析客戶端數據包
/// <param name="recBytes">服務器接收的數據包</param>
/// <param name="recByteLength">有效數據長度</param> 
private static string AnalyticData(byte[] recBytes, int recByteLength)
{
    if(recByteLength < 2)
    {
        return string.Empty;
    }

    bool fin = (recBytes[0] & 0x80) == 0x80; // 1bit,1表示最後一幀
    if(!fin)
    {
        return string.Empty;// 超過一幀暫不處理
    }

    bool mask_flag = (recBytes[1] & 0x80) == 0x80; // 是否包含掩碼
    if(!mask_flag)
    {
        return string.Empty;// 不包含掩碼的暫不處理
    }

    int payload_len = recBytes[1] & 0x7F; // 數據長度

    byte[] masks = new byte[4];
    byte[] payload_data;

    if(payload_len == 126)
    {
        Array.Copy(recBytes, 4, masks, 0, 4);
        payload_len = (UInt16)(recBytes[2] << 8 | recBytes[3]);
        payload_data = new byte[payload_len];
        Array.Copy(recBytes, 8, payload_data, 0, payload_len);

    }
    else if(payload_len == 127)
    {
        Array.Copy(recBytes, 10, masks, 0, 4);
        byte[] uInt64Bytes = new byte[8];
        for(int i = 0; i < 8; i++)
        {
            uInt64Bytes[i] = recBytes[9 - i];
        }
        UInt64 len = BitConverter.ToUInt64(uInt64Bytes, 0);

        payload_data = new byte[len];
        for(UInt64 i = 0; i < len; i++)
        {
            payload_data[i] = recBytes[i + 14];
        }
    }
    else
    {
        Array.Copy(recBytes, 2, masks, 0, 4);
        payload_data = new byte[payload_len];
        Array.Copy(recBytes, 6, payload_data, 0, payload_len);

    }

    for(var i = 0; i < payload_len; i++)
    {
        payload_data[i] = (byte)(payload_data[i] ^ masks[i % 4]);
    }
    return Encoding.UTF8.GetString(payload_data);
}

server發送數據至client

服務器發送的數據以0x81開頭,緊接發送內容的長度(若長度在0-125,則1個byte表示長度;若長度不超過0xFFFF,則後2個byte 做爲無符號16位整數表示長度;若超過0xFFFF,則後8個byte做爲無符號64位整數表示長度),最後是內容的byte數組。
示例代碼:

/// 打包服務器數據
/// <param name="message">數據</param>
/// <returns>數據包</returns>
private static byte[] PackData(string message)
{
    byte[] contentBytes = null;
    byte[] temp = Encoding.UTF8.GetBytes(message);

    if(temp.Length < 126)
    {
        contentBytes = new byte[temp.Length + 2];
        contentBytes[0] = 0x81;
        contentBytes[1] = (byte)temp.Length;
        Array.Copy(temp, 0, contentBytes, 2, temp.Length);
    }
    else if(temp.Length < 0xFFFF)
    {
        contentBytes = new byte[temp.Length + 4];
        contentBytes[0] = 0x81;
        contentBytes[1] = 126;
        contentBytes[2] = (byte)(temp.Length & 0xFF);
        contentBytes[3] = (byte)(temp.Length >> 8 & 0xFF);
        Array.Copy(temp, 0, contentBytes, 4, temp.Length);
    }
    else
    {
        // 暫不處理超長內容
    }

    return contentBytes;
}

4.3 Closing Handshake

相對於Opening Handshake,Closing Handshake則簡單得多,主動關閉的一方向另外一方發送一個關閉類型的數據包,對方收到此數據包以後,再回復一個相同類型的數據包,關閉完成。

關閉類型數據包遵照封包協議,Opcode爲0x8,Payload data能夠用於攜帶關閉緣由或消息。

4.4 websocket的事件響應

以上的Opening Handshake、Data Framing、Closing Handshake三個步驟其實分別對應了websocket的三個事件:

  • onopen 當接口打開時響應
  • onmessage 當收到信息時響應
  • onclose 當接口關閉時響應

任何程序語言的websocket api都至少要提供上面三個事件的api接口, 有的可能還提供的有onerror事件的處理機制。

websocket 在任什麼時候候都會處於下面4種狀態中的其中一種:

  • CONNECTING (0):表示還沒創建鏈接;
  • OPEN (1): 已經創建鏈接,能夠進行通信;
  • CLOSING (2):經過關閉握手,正在關閉鏈接;
  • CLOSED (3):鏈接已經關閉或沒法打開;

5. 如何使用websocket

客戶端
在支持WebSocket的瀏覽器中,在建立socket以後。能夠經過onopen,onmessage,onclose即onerror四個事件實現對socket進行響應
一個簡單是示例:

var ws = new WebSocket(「ws://localhost:8080」);
ws.onopen = function()
{
  console.log(「open」);
  ws.send(「hello」);
};
ws.onmessage = function(evt)  {  console.log(evt.data); };
ws.onclose   = function(evt)  {  console.log(「WebSocketClosed!」); };
ws.onerror   = function(evt)  {  console.log(「WebSocketError!」); };

首先申請一個WebSocket對象,參數是須要鏈接的服務器端的地址,同http協議使用http://開頭同樣,WebSocket協議的URL使用ws://開頭,另外安全的WebSocket協議使用wss://開頭。

client先發起握手請求:

GET /echobot HTTP/1.1
Host: 192.168.14.215:9000
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket
Origin: http://192.168.14.215
Sec-WebSocket-Version: 13
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.101 Safari/537.36
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-CN,zh;q=0.8
Sec-WebSocket-Key: mh3xLXeRuIWNPwq7ATG9jA==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

服務端響應:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: SIEylb7zRYJAEgiqJXaOW3V+ZWQ=

交互數據:

ws.send(「hello」);   # 用於將消息發送到服務端
ws.recv($buffer);   # 用於接收服務端的消息

6. 本身如何實現websocket server和client

我分別用C++、PHP、Python語言實現了websocket server和client, 只支持基本功能,也是爲了加深理解websocket協議內容。

全部源代碼放在github上,點此查看:websocket server & client 分別用C++/PHP/Python實現, 如何使用、測試及集成本身的邏輯也在文檔中進行了說明,這裏再也不列出了。

7. reference

Ajax、Comet與Websocket

WebSocket使用教程

分析HTML5中WebSocket的原理

WebScoket 規範 + WebSocket 協議

websocket規範 RFC6455 中文版

相關文章
相關標籤/搜索