WebSocket探祕

daipeng7發表於2017-12-07

 首先

  長連線:一個連線上可以連續傳送多個資料包,在連線期間,如果沒有資料包傳送,需要雙方發鏈路檢查包。

  TCP/IP:TCP/IP屬於傳輸層,主要解決資料在網路中的傳輸問題,只管傳輸資料。但是那樣對傳輸的資料沒有一個規範的封裝、解析等處理,使得傳輸的資料就很難識別,所以才有了應用層協議對資料的封裝、解析等,如HTTP協議。

  HTTP:HTTP是應用層協議,封裝解析傳輸的資料。

  從HTTP1.1開始其實就預設開啟了長連線,也就是請求header中看到的Connection:Keep-alive。但是這個長連線只是說保持了(伺服器可以告訴客戶端保持時間Keep-Alive:timeout=200;max=20;)這個TCP通道,直接Request - Response,而不需要再建立一個連線通道,做到了一個效能優化。但是HTTP通訊本身還是Request - Response。

  socket:與HTTP不一樣,socket不是協議,它是在程式層面上對傳輸層協議(可以主要理解為TCP/IP)的介面封裝。

  我們知道傳輸層的協議,是解決資料在網路中傳輸的,那麼socket就是傳輸通道兩端的介面。所以對於前端而言,socket也可以簡單的理解為對TCP/IP的抽象協議。

  WebSocket

  WebSocket是包裝成了一個應用層協議作為socket,從而能夠讓客戶端和遠端服務端通過web建立全雙工通訊。websocket提供ws和wss兩種URL方案。協議英文文件中文翻譯

 WebSocket API

  使用WebSocket建構函式建立一個WebSocket連線,返回一個websocket例項。通過這個例項我們可以監聽事件,這些事件可以知道什麼時候簡歷連線,什麼時候有訊息被推過來了,什麼時候發生錯誤了,時候連線關閉。我們可以使用node搭建一個WebSocket伺服器來看看,原始碼。同樣也可以呼叫websocket.org網站的demo伺服器http://demos.kaazing.com/echo/

  事件

//建立WebSocket例項,可以使用ws和wss。第二個引數可以選填自定義協議,如果多協議,可以以陣列方式
var socket = new WebSocket('ws://demos.kaazing.com/echo');

  open

  伺服器相應WebSocket連線請求觸發

   socket.onopen = (event) => {
       socket.send('Hello Server!');
   };

  message

  伺服器有 響應資料 觸發

   socket.onmessage = (event) => {
       debugger;
       console.log(event.data);
   };

  error

  出錯時觸發,並且會關閉連線。這時可以根據錯誤資訊進行按需處理

   socket.onerror = (event) => {
       console.log('error');
   }

  close

  連線關閉時觸發,這在兩端都可以關閉。另外如果連線失敗也是會觸發的。
  針對關閉一般我們會做一些異常處理,關於異常引數:

  1. socket.readyState  
          2 正在關閉  3 已經關閉
  2. event.wasClean [Boolean]  
          true  客戶端或者伺服器端呼叫close主動關閉
       false 反之
  3. event.code [Number] 關閉連線的狀態碼。socket.close(code, reason)
  4. event.reason [String] 
          關閉連線的原因。socket.close(code, reason)
          

       socket.onclose = (event) => {
           debugger;
       }

  方法

  send

  send(data) 傳送方法

  data 可以是String/Blob/ArrayBuffer/ByteBuffer等

  需要注意,使用send傳送資料,必須是連線建立之後。一般會在onopen事件觸發後傳送:

socket.onopen = (event) => {
    socket.send('Hello Server!');
};

  如果是需要去響應別的事件再傳送訊息,也就是將WebSocket例項socket交給別的方法使用,因為在傳送時你不一定知道socket是否還連線著,所以可以檢查readyState屬性的值是否等於OPEN常量,也就是檢視socket是否還連線著。

btn.onclick = function startSocket(){
    //判斷是否連線是否還存在
    if(socket.readyState == WebSocket.OPEN){
        var message = document.getElementById("message").value;
        if(message != "") socket.send(message);
    }
}

  close

  使用close([code[,reason]])方法可以關閉連線。code和reason均為選填

// 正常關閉
socket.close(1000, "closing normally");

  常量

常量名 描述
CONNECTING 0 連線還未開啟
OPEN 1 連線開啟可以通訊
CLOSING 2 連線正在關閉中
CLOSED 3 連線已經關閉

  屬性

屬性名 值型別 描述
binaryType String 表示連線傳輸的二進位制資料型別的字串。預設為"blob"。
bufferedAmount Number 只讀。如果使用send()方法傳送的資料過大,雖然send()方法會馬上執行,但資料並不是馬上傳輸。瀏覽器會快取應用流出的資料,你可以使用bufferedAmount屬性檢查已經進入佇列但還未被傳輸的資料大小。在一定程度上可以避免網路飽和。
protocol String/Array 在建構函式中,protocol引數讓服務端知道客戶端使用的WebSocket協議。而在例項socket中就是連線建立前為空,連線建立後為客戶端和伺服器端確定下來的協議名稱。
readyState String 只讀。連線當前狀態,這些狀態是與常量相對應的。
extensions String 伺服器選擇的擴充套件。目前,這只是一個空字串或通過連線協商的擴充套件列表。

 WebSocket簡單實現

  WebSocket 協議有兩部分:握手、資料傳輸。

  其中,握手無疑是關鍵,是一切的先決條件。

  握手

  客戶端握手請求


//建立WebSocket例項,可以使用ws和wss。第二個引數可以選填自定義協議,如果多協議,可以以陣列方式
var socket = new WebSocket('ws://localhost:8081', [protocol]);

  出於WebSocket的產生原因是為了瀏覽器能實現同伺服器的全雙工通訊和HTTP協議在瀏覽器端的廣泛運用(當然也不全是為了瀏覽器,但是主要還是針對瀏覽器的)。所以WebSocket的握手是HTTP請求的升級。

  WebSocket客戶端請求頭示例:


GET /chat HTTP/1.1   //必需。
Host: server.example.com  // 必需。WebSocket伺服器主機名
Upgrade: websocket // 必需。並且值為" websocket"。有個空格
Connection: Upgrade // 必需。並且值為" Upgrade"。有個空格
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== // 必需。其值採用base64編碼的隨機16位元組長的字元序列。
Origin: http://example.com //瀏覽器必填。頭域(RFC6454)用於保護WebSocket伺服器不被未授權的執行在瀏覽器的指令碼跨源使用WebSocket API。
Sec-WebSocket-Protocol: chat, superchat //選填。可用選項有子協議選擇器。
Sec-WebSocket-Version: 13 //必需。版本。

  WebSocket客戶端將上述請求傳送到伺服器。如果是呼叫瀏覽器的WebSocket API,瀏覽器會自動完成完成上述請求頭。

  服務端握手響應

  伺服器得向客戶端證明它接收到了客戶端的WebSocket握手,為使伺服器不接受非WebSocket連線,防止攻擊者通過XMLHttpRequest傳送或表單提交精心構造的包來欺騙WebSocket伺服器。伺服器把兩塊資訊合併來形成響應。第一塊資訊來自客戶端握手頭域Sec-WebSocket-Key,如Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==。

  對於這個頭域,伺服器取頭域的值(需要先消除空白符),以字串的形式拼接全域性唯一的(GUID,[RFC4122])標識:258EAFA5-E914-47DA-95CA-C5AB0DC85B11,此值不大可能被不明白WebSocket協議的網路終端使用。然後進行SHA-1 hash(160位)編碼,再進行base64編碼,將結果作為伺服器的握手返回。具體如下:


請求頭:Sec-WebSocket-Key:dGhlIHNhbXBsZSBub25jZQ==

取值,字串拼接後得到:"dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11";

SHA-1後得到: 0xb3 0x7a 0x4f 0x2c 0xc0 0x62 0x4f 0x16 0x90 0xf6 0x46 0x06 0xcf 0x38 0x59 0x45 0xb20xbe 0xc4 0xea

Base64後得到: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

最後的結果值作為響應頭Sec-WebSocket-Accept 的值。

  最終形成WebSocket伺服器端的握手響應:

HTTP/1.1 101 Switching Protocols   //必需。響應頭。狀態碼為101。任何非101的響應都為握手未完成。但是HTTP語義是存在的。
Upgrade: websocket  // 必需。升級型別。
Connection: Upgrade //必需。本次連線型別為升級。
Sec-WebSocket-Accept:s3pPLMBiTxaQ9kYGzzhZRbK+xOo=  //必需。表明伺服器是否願意接受連線。如果接受,值就必須是通過上面演算法得到的值。

  當然響應頭還存在一些可選欄位。主要的可選欄位為Sec-WebSocket-Protocol,是對客戶端請求中所提供的Sec-WebSocket-Protocol子協議的選擇結果的響應。當然cookie什麼的也是可以的。

//handshaking.js
const crypto = require('crypto');
    const cryptoKey = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
    
    // 計算握手響應accept-key
    let challenge = (reqKey) => {
        reqKey += cryptoKey;
        // crypto.vetHashes()可以獲得支援的hash演算法陣列,我這裡得到46個
        reqKey = reqKey.replace(/\s/g,"");
        // crypto.createHash('sha1').update(reqKey).digest()得到的是一個Uint8Array的加密資料,需要將其轉為base64
        return crypto.createHash('sha1').update(reqKey).digest().toString('base64');
    }
    
    exports.handshaking = (req, socket, head) => {
        let _headers = req.headers,
            _key = _headers['sec-websocket-key'],
            resHeaders = [],
            br = "\r\n";
        resHeaders.push(
            'HTTP/1.1 101 WebSocket Protocol Handshake is OK',
            'Upgrade: websocket',
            'Connection: Upgrade',
            'Sec-WebSocket-Origin: ' + _headers.origin,
            'Sec-WebSocket-Location: ws://' + _headers.host + req.url,
        );
        let resAccept = challenge(_key);
        resHeaders.push('Sec-WebSocket-Accept: '+ resAccept + br, head);
        socket.write(resHeaders.join(br), 'binary');
    }

  握手關閉

  關閉握手可用使用TCP直接關閉連線的方法來關閉握手。但是TCP關閉握手不總是端到端可靠的,特別是出現攔截代理和其他的中間設施。也可以任何一端傳送帶有指定控制序號(比如說狀態碼1002,協議錯誤)的資料的幀來開始關閉握手,當另一方接收到這個關閉幀,就必須關閉連線。

  資料傳輸

  在WebSocket協議中,資料傳輸階段使用frame(資料幀)進行通訊,frame分不同的型別,主要有:文字資料,二進位制資料。出於安全考慮和避免網路截獲,客戶端傳送的資料幀必須進行掩碼處理後才能傳送到伺服器,不論是否是在TLS安全協議上都要進行掩碼處理。伺服器如果沒有收到掩碼處理的資料幀時應該關閉連線,傳送一個1002的狀態碼。伺服器不能將傳送到客戶端的資料進行掩碼處理,如果客戶端收到掩碼處理的資料幀必須關閉連線。

  那我們伺服器端接收到的資料幀是怎樣的呢?

  資料幀

  WebSocket的資料傳輸是要遵循特定的資料格式-資料幀(frame).

  每一列代表一個位元組,一個位元組8位,每一位又代表一個二進位制數。

  fin: 標識這一幀資料是否是該分塊的最後一幀。

    1 為最後一幀
    0 不是最後一幀。需要分為多個幀傳輸

  rsv1-3: 預設為0.接收協商擴充套件定義為非0設定。

  opcode: 操作碼,也就是定義了該資料是什麼,如果不為定義內的值則連線中斷。佔四個位,可以表示0~15的十進位制,或者一個十六進位制。

    
    %x0 表示一個繼續幀
    %x1 表示一個文字幀
    %x2 表示一個二進位制幀
    %x3-7 為以後的非控制幀保留
    %x8 表示一個連線關閉
    %x9 表示一個ping
    %x10 表示一個pong
    %x11-15 為以後的控制幀保留

  masked: 佔第二個位元組的一位,定義了masking-key是否存在。並且使用masking-key掩碼解析Payload data。


    1 客戶端傳送資料到服務端
    0 服務端傳送資料到客戶端

  payload length: 表示Payload data的總長度。佔7位,或者7+2個位元組、或者7+8個位元組。

    0-125,則是payload的真實長度
    126,則後面2個位元組形成的16位無符號整型數的值是payload的真實長度,125<資料長度<65535
    127,則後面8個位元組形成的64位無符號整型數的值是payload的真實長度,資料長度>65535

  masking key: 0或4位元組,當masked為1的時候才存在,為4個位元組,否則為0,用於對我們需要的資料進行解密

  payload data: 我們需要的資料,如果masked為1,該資料會被加密,要通過masking key進行異或運算解密才能獲取到真實資料。

  關於資料幀

  因為WebSocket服務端接收到的資料有可能是連續的資料幀,一個message可能分為多個幀傳送。但如果使用fin來做訊息邊界是有問題的。

  我傳送了一個27378個位元組的字串,伺服器端共接收到2幀,兩幀的fin都為1,而且根據規範計算出來的兩幀的payload data的長度為27372少了6個位元組。這缺少的6個位元組其實剛好等於2個固有位元組加上maskingKey的4個位元組,也就是說第二幀就是一個純粹的資料幀。這又是怎麼回事呢??

  從結果推測實現,我們接收到的第2幀的資料格式不是幀格式,說明資料沒有先分幀(分片)後再傳送的。而是將一幀分包後傳送的。

  分片

  分片的主要目的是允許當訊息開始但不必緩衝該訊息時傳送一個未知大小的訊息。如果訊息不能被分片,那麼端點將不得不緩衝整個訊息以便在首位元組發生之前統計出它的長度。對於分片,伺服器或中介軟體可以選擇一個合適大小的緩衝,當緩衝滿時,寫一個片段到網路。

  我們27378個位元組的訊息明顯是知道message長度,那麼就算這個message很大,根據規範1幀的資料長度理論上是0<資料長度<65535的,這種情況下應該1幀搞定,他也只是當做一幀來傳送,但是由於傳輸限制,所以這一個幀(我們收到的像是好幾幀一樣)會被拆分成幾塊傳送,除了第一塊是帶有fin、opcode、masked等識別符號,之後收到的塊都是純粹的資料(也就是第一塊的payload data 的後續部分),這個就是socket的將WebSocket分好的一幀資料進行了分包傳送。那麼這種一幀被socket分包傳送,導致像是分幀(分片)傳送的情況(伺服器端本應該只就收一幀),在伺服器端我暫時還沒有想到怎樣獲取狀態來處理。

  總結,客戶端傳送資料,在實現時還是需要手動進行分幀(分片),不然就按照一幀傳送,小資料量無所謂;如果是大資料量,就會被socket自動分包傳送。這個與WebSocket協議規範所標榜的自動分幀(分片),存在的差異應該是各個瀏覽器在對WebSocket協議規範的實現上偷工減料所造成的。所以我們看見socket.io等外掛會有一個客戶端介面,應該就是為了重新是實現WebSocket協議規範。從原理出發,我們接下來還是以小資料量(單幀)資料傳輸為例了。

  解析資料幀

//dataHandler.js
// 收集本次message的所有資料
getData(data, callback) {
    this.getState(data);
    // 如果狀態碼為8說明要關閉連線
    if(this.state.opcode == 8) {
        this.OPEN = false;
        this.closeSocket();
        return;
    }
    // 如果是心跳pong,回一個ping
    if(this.state.opcode == 10) {
        this.OPEN = true;
        this.pingTimes = 0;// 回了pong就將次數清零
        return;
    }
    // 收集本次資料流資料
    this.dataList.push(this.state.payloadData);

    // 長度為0,說明當前幀位最後一幀。
    if(this.state.remains == 0){
        let buf = Buffer.concat(this.dataList, this.state.payloadLength);
        //使用掩碼maskingKey解析所有資料
        let result = this.parseData(buf);
        // 資料接收完成後回撥回業務函式
        callback(this.socket, result);
        //重置狀態,表示當前message已經解析完成了
        this.resetState();
    }else{
        this.state.index++;
    }
}

// 收集本次message的所有資料
getData(data, callback) {
    this.getState(data);

    // 收集本次資料流資料
    this.dataList.push(this.state.payloadData);

    // 長度為0,說明當前幀位最後一幀。
    if(this.state.remains == 0){
        let buf = Buffer.concat(this.dataList, this.state.payloadLength);
        //使用掩碼maskingKey解析所有資料
        let result = this.parseData(buf);
        // 資料接收完成後回撥回業務函式
        callback(this.socket, result);
        //重置狀態,表示當前message已經解析完成了
        this.resetState();
    }else{
        this.state.index++;
    }
}

// 解析本次message所有資料
parseData(allData, callback){
    let len = allData.length,
        i = 0;
        for(; i < len; i++){
            allData[i] = allData[i] ^ this.state.maskingKey[ i % 4 ];// 異或運算,使用maskingKey四個位元組輪流進行計算
        }
        // 判斷資料型別,如果為文字型別
        if(this.state.opcode == 1) allData = allData.toString();

        return allData;
    }

  組裝需要傳送的資料幀


// 組裝資料幀,傳送是不需要掩碼加密
createData(data){
    let dataType = Buffer.isBuffer(data);// 資料型別
    let dataBuf, // 需要傳送的二進位制資料
        dataLength,// 資料真實長度
        dataIndex = 2; // 資料的起始長度
    let frame; // 資料幀

    if(dataType) dataBuf = data;
    else dataBuf = Buffer.from(data); // 也可以不做型別判斷,直接Buffer.form(data)
    dataLength = dataBuf.byteLength; 
    
    // 計算payload data在frame中的起始位置
    dataIndex = dataIndex + (dataLength > 65535 ? 8 : (dataLength > 125 ? 2 : 0));

    frame = new Buffer.alloc(dataIndex + dataLength);

    //第一個位元組,fin = 1,opcode = 1
    frame[0] = parseInt(10000001, 2);

    //長度超過65535的則由8個位元組表示,因為4個位元組能表達的長度為4294967295,已經完全夠用,因此直接將前面4個位元組置0
    if(dataLength > 65535){
        frame[1] = 127; //第二個位元組
        frame.writeUInt32BE(0, 2); 
        frame.writeUInt32BE(dataLength, 6);
    }else if(dataLength > 125){
        frame[1] = 126;
        frame.writeUInt16BE(dataLength, 2);
    }else{
        frame[1] = dataLength;
    }

    // 服務端傳送到客戶端的資料
    frame.write(dataBuf.toString(), dataIndex);

    return frame;
}

  心跳檢測

// 心跳檢查
sendCheckPing(){
    let _this = this;
    let timer = setTimeout(() => {
        clearTimeout(timer);
        if (_this.pingTimes >= 3) {
            _this.closeSocket();
            return;
        }
        //記錄心跳次數
        _this.pingTimes++;
        if(_this.pingTimes == 100000) _this.pingTimes = 0;
        _this.sendCheckPing();
    }, 5000);
}
// 傳送心跳ping
sendPing() {
    let ping = Buffer.alloc(2);
    ping[0] = parseInt(10001001, 2);
    ping[1] = 0;
    this.writeData(ping);
}

  關閉連線

  客戶端直接呼叫close方法,伺服器端可以使用socket.end方法。

 最後

  WebSocket在一定程度上讓前端更加的有所作為,這個無疑是令人欣喜的,但是其規範中的很多不確定也是令人很惋惜的。

  因為瀏覽器對WebSocket規範的不完全實現,還有很多需要做的優化,這篇文章只是實現以一下WebSocket,關於期間很多的安全、穩定等方面的需要在應用中進行充實。當然是用socket.io這種相對成熟的外掛也是不錯的選擇。

相關文章