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

本文章同時發在 cpper.info

1. websocket是什麼

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

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

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

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

2. websocket的優點

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

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

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

WebSocket並不限於以Ajax(或XHR)方式通信,因爲Ajax技術需要客戶端發起請求,而WebSocket服務器和客戶端可以彼此相互推送信息;

因此從服務器角度來說,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則不同,客戶端與服務器端保持一個長連接,只有客戶端需要的數據更新時,服務器才主動將數據推送給客戶端。

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

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

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

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

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,表示協議錯誤),以關閉連接。
具體數據幀格式如下圖所示:

  • 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-rsv1、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 中文版

原文鏈接:http://www.cnblogs.com/lizhenghn/p/5155933.html


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