天天看點

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

    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浏覽器

  • 用戶端在握手時的請求中攜帶,這樣的版本辨別,表示這個是一個更新版本,現在的浏覽器都是使用的這個版本。
  • 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 中文版

繼續閱讀