細說WebSocket - Node篇

本文同步自個人博客園:http://hustskyking.cnblogs.com
P.S:文章代碼格式錯亂,也不知道是什麼緣由,還望@segmentFault的兄弟看下~html

在上一篇提升到了 web 通訊的各類方式,包括 輪詢、長鏈接 以及各類 HTML5 中提到的手段。本文將詳細描述 WebSocket協議 在 web通信 中的實現。node

1、WebSocket 協議

1. 概述

websocket協議容許不受信用的客戶端代碼在可控的網絡環境中控制遠程主機。該協議包含一個握手和一個基本消息分幀、分層經過TCP。簡單點說,經過握手應答以後,創建安全的信息管道,這種方式明顯優於前文所說的基於 XMLHttpRequest 的 iframe 數據流和長輪詢。該協議包括兩個方面,握手連接(handshake)和數據傳輸(data transfer)。git

2. 握手鍊接

這部分比較簡單,就像路上遇到熟人問好。github

Client:嘿,大哥,有火沒?(煙遞了過去)
Server:哈,有啊,來~ (點上)
Client:火柴啊,也行!(煙點上,驗證完畢)

握手鍊接中,client 先主動伸手:web

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

客戶端發了一串 Base64 加密的密鑰過去,也就是上面你看到的 Sec-WebSocket-Key。
Server 看到 Client 打招呼以後,悄悄地告訴 Client 他已經知道了,順便也打個招呼。算法

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

Server 返回了 Sec-WebSocket-Accept 這個應答,這個應答內容是經過必定的方式生成的。生成算法是:api

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

key 和 mask 串接以後通過 SHA-1 處理,處理後的數據再通過一次 Base64 加密。分解動做:數組

1. t = "GhlIHNhbXBsZSBub25jZQ==" + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
   -> "GhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
2. s = sha1(t) 
   -> 0xb3 0x7a 0x4f 0x2c 0xc0 0x62 0x4f 0x16 0x90 0xf6 
      0x46 0x06 0xcf 0x38 0x59 0x45 0xb2 0xbe 0xc4 0xea
3. base64(s) 
   -> "s3pPLMBiTxaQ9kYGzzhZRbK"

上面 Server 端返回的 HTTP 狀態碼是 101,若是不是 101 ,那就說明握手一開始就失敗了~瀏覽器

下面就來個 demo,跟服務器握個手:安全

var crypto = require('crypto');

    require('net').createServer(function(o){
        var key;
        o.on('data',function(e){
            if(!key){
                // 握手
                // 應答部分,代碼先省略
                console.log(e.toString());
            }else{

            };
        });
    }).listen(8000);

客戶端代碼:

var ws=new WebSocket("ws://127.0.0.1:8000");
    ws.onerror=function(e){
      console.log(e);
    };

上面固然是一串不完整的代碼,目的是演示握手過程當中,客戶端給服務端打招呼。在控制檯咱們能夠看到:
shake-1
看起來很熟悉吧,其實就是發送了一個 HTTP 請求,這個咱們在瀏覽器的 Network 中也能夠看到:
shake-1-b

可是 WebSocket協議 並非 HTTP 協議,剛開始驗證的時候借用了 HTTP 的頭,鏈接成功以後的通訊就不是 HTTP 了,不信你用 fiddler2 抓包試試,確定是拿不到的,後面的通訊部分是基於 TCP 的鏈接。

服務器要成功的進行通訊,必須有應答,往下看:

//服務器程序
    var crypto = require('crypto');
    var WS = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
    require('net').createServer(function(o){
        var key;
        o.on('data',function(e){
            if(!key){
                //握手
                key = e.toString().match(/Sec-WebSocket-Key: (.+)/)[1];
                key = crypto.createHash('sha1').update(key + WS).digest('base64');
                o.write('HTTP/1.1 101 Switching Protocols\r\n');
                o.write('Upgrade: websocket\r\n');
                o.write('Connection: Upgrade\r\n');
                o.write('Sec-WebSocket-Accept: ' + key + '\r\n');
                o.write('\r\n');
            }else{
                console.log(e);
            };
        });
    }).listen(8000);

關於crypto模塊,能夠看看官方文檔,上面的代碼應該是很好理解的,服務器應答以後,Client 拿到 Sec-WebSocket-Accept ,而後本地作一次驗證,若是驗證經過了,就會觸發 onopen 函數。

//客戶端程序
var ws=new WebSocket("ws://127.0.0.1:8000/");
ws.onopen=function(e){
    console.log("握手成功");
};

能夠看到
shake-2-b

3. 數據幀格式

官方文檔提供了一個結構圖

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

第一眼瞟到這張圖恐怕是要吐血,若是大學修改計算機網絡這門課應該不會對這東西陌生,數據傳輸協議嘛,是須要定義字節長度及相關含義的。

FIN      1bit 表示信息的最後一幀,flag,也就是標記符
RSV 1-3  1bit each 之後備用的 默認都爲 0
Opcode   4bit 幀類型,稍後細說
Mask     1bit 掩碼,是否加密數據,默認必須置爲1 (這裏很蛋疼)
Payload  7bit 數據的長度
Masking-key      1 or 4 bit 掩碼
Payload data     (x + y) bytes 數據
Extension data   x bytes  擴展數據
Application data y bytes  程序數據

每一幀的傳輸都是聽從這個協議規則的,知道了這個協議,那麼解析就不會太難了,這裏我就直接拿了次碳酸鈷同窗的代碼。

4. 數據幀的解析和編碼

數據幀的解析代碼:

function decodeDataFrame(e){
      var i=0,j,s,frame={
        //解析前兩個字節的基本數據
        FIN:e[i]>>7,Opcode:e[i++]&15,Mask:e[i]>>7,
        PayloadLength:e[i++]&0x7F
      };
      //處理特殊長度126和127
      if(frame.PayloadLength==126)
        frame.length=(e[i++]<<8)+e[i++];
      if(frame.PayloadLength==127)
        i+=4, //長度通常用四字節的整型,前四個字節一般爲長整形留空的
        frame.length=(e[i++]<<24)+(e[i++]<<16)+(e[i++]<<8)+e[i++];
      //判斷是否使用掩碼
      if(frame.Mask){
        //獲取掩碼實體
        frame.MaskingKey=[e[i++],e[i++],e[i++],e[i++]];
        //對數據和掩碼作異或運算
        for(j=0,s=[];j<frame.PayloadLength;j++)
          s.push(e[i+j]^frame.MaskingKey[j%4]);
      }else s=e.slice(i,frame.PayloadLength); //不然直接使用數據
      //數組轉換成緩衝區來使用
      s=new Buffer(s);
      //若是有必要則把緩衝區轉換成字符串來使用
      if(frame.Opcode==1)s=s.toString();
      //設置上數據部分
      frame.PayloadData=s;
      //返回數據幀
      return frame;
    }

數據幀的編碼:

//NodeJS
    function encodeDataFrame(e){
      var s=[],o=new Buffer(e.PayloadData),l=o.length;
      //輸入第一個字節
      s.push((e.FIN<<7)+e.Opcode);
      //輸入第二個字節,判斷它的長度並放入相應的後續長度消息
      //永遠不使用掩碼
      if(l<126) s.push(l);
      else if(l<0x10000) s.push(126,(l&0xFF00)>>2,l&0xFF);
      else s.push(
        127, 0,0,0,0, //8字節數據,前4字節通常沒用留空
        (l&0xFF000000)>>6,(l&0xFF0000)>>4,(l&0xFF00)>>2,l&0xFF
      );
      //返回頭部分和數據部分的合併緩衝區
      return Buffer.concat([new Buffer(s),o]);
    }

有些童鞋可能沒有明白,應該解析哪些數據。這的解析任務主要是服務端處理,客戶端送過去的數據是二進制流形式的,好比:

var ws = new WebSocket("ws://127.0.0.1:8000/");
    ws.onopen = function(){
        ws.send("握手成功");
    };

Server 收到的信息是這樣的:
transfer-1
一個放在Buffer格式的二進制流。而當咱們輸出的時候解析這個二進制流:

//服務器程序
    var crypto = require('crypto');
    var WS = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
    require('net').createServer(function(o){
        var key;
        o.on('data',function(e){
            if(!key){
                //握手
                key = e.toString().match(/Sec-WebSocket-Key: (.+)/)[1];
                key = crypto.createHash('sha1').update(key + WS).digest('base64');
                o.write('HTTP/1.1 101 Switching Protocols\r\n');
                o.write('Upgrade: websocket\r\n');
                o.write('Connection: Upgrade\r\n');
                o.write('Sec-WebSocket-Accept: ' + key + '\r\n');
                o.write('\r\n');
            }else{
                // 輸出以前解析幀
                console.log(decodeDataFrame(e));
            };
        });
    }).listen(8000);

那輸出的就是一個幀信息十分清晰的對象了:
transfer-2

5. 鏈接的控制

上面我買了個關子,提到的Opcode,沒有詳細說明,官方文檔也給了一張表:

|Opcode  | Meaning                             | Reference |
-+--------+-------------------------------------+-----------|
 | 0      | Continuation Frame                  | RFC 6455  |
-+--------+-------------------------------------+-----------|
 | 1      | Text Frame                          | RFC 6455  |
-+--------+-------------------------------------+-----------|
 | 2      | Binary Frame                        | RFC 6455  |
-+--------+-------------------------------------+-----------|
 | 8      | Connection Close Frame              | RFC 6455  |
-+--------+-------------------------------------+-----------|
 | 9      | Ping Frame                          | RFC 6455  |
-+--------+-------------------------------------+-----------|
 | 10     | Pong Frame                          | RFC 6455  |
-+--------+-------------------------------------+-----------|

次碳酸鈷給出的解析函數,獲得的數據格式是:

{
        FIN: 1,
        Opcode: 1,
        Mask: 1,
        PayloadLength: 4,
        MaskingKey: [ 159, 18, 207, 93 ],
        PayLoadData: 'test'
    }

那麼能夠對應上面查看,此幀的做用就是發送文本,爲文本幀。由於鏈接是基於 TCP 的,直接關閉 TCP 鏈接,這個通道就關閉了,不過 WebSocket 設計的還比較人性化,關閉以前還跟你打一聲招呼,在服務器端,能夠判斷frame的Opcode:

var frame=decodeDataFrame(e);
    console.log(frame);
    if(frame.Opcode==8){
        o.end(); //斷開鏈接
    }

客戶端和服務端交互的數據(幀)格式都是同樣的,只要客戶端發送 ws.close(), 服務器就會執行上面的操做。相反,若是服務器給客戶端也發送一樣的關閉幀(close frame):

o.write(encodeDataFrame({
        FIN:1,
        Opcode:8,
        PayloadData:buf
    }));

客戶端就會相應 onclose 函數,這樣的交互還算是有規有矩,不容易出錯。

2、注意事項

1. WebSocket URIs

不少人可能只是到 ws://text.com:8888,但事實上 websocket 協議地址是能夠加 path 和 query 的。

ws-URI = "ws:" "//" host [ ":" port ] path [ "?" query ]
    wss-URI = "wss:" "//" host [ ":" port ] path [ "?" query ]

若是使用的是 wss 協議,那麼 URI 將會以安全方式鏈接。 這裏的 wss 大小寫不敏感。

2. 協議中多餘的部分(吐槽)

握手請求中包含Sec-WebSocket-Key字段,明眼人一下就能看出來是websocket鏈接,並且這個字段的加密方式在服務器也是固定的,若是別人想黑你,不會太難。

再就是那個mask掩碼,既然強制加密了,還有必要讓開發者處理這個東西麼?直接封裝到內部不就好了?

3. 與 TCP 和 HTTP 之間的關係

WebSocket協議是一個基於TCP的協議,就是握手連接的時候跟HTTP相關(發了一個HTTP請求),這個請求被Server切換到(Upgrade)websocket協議了。websocket把 80 端口做爲默認websocket鏈接端口,而websocket的運行使用的是443端口。

3、參考資料

相關文章
相關標籤/搜索