WebSocket探祕

首先

長鏈接:一個鏈接上能夠連續發送多個數據包,在鏈接期間,若是沒有數據包發送,須要雙方發鏈路檢查包。前端

TCP/IP:TCP/IP屬於傳輸層,主要解決數據在網絡中的傳輸問題,只管傳輸數據。可是那樣對傳輸的數據沒有一個規範的封裝、解析等處理,使得傳輸的數據就很難識別,因此纔有了應用層協議對數據的封裝、解析等,如HTTP協議。node

HTTP:HTTP是應用層協議,封裝解析傳輸的數據。 從HTTP1.1開始其實就默認開啓了長鏈接,也就是請求header中看到的Connection:Keep-alive。可是這個長鏈接只是說保持了(服務器能夠告訴客戶端保持時間Keep-Alive:timeout=200;max=20;)這個TCP通道,直接Request - Response,而不須要再建立一個鏈接通道,作到了一個性能優化。可是HTTP通信自己仍是Request - Response。git

socket:與HTTP不同,socket不是協議,它是在程序層面上對傳輸層協議(能夠主要理解爲TCP/IP)的接口封裝。 咱們知道傳輸層的協議,是解決數據在網絡中傳輸的,那麼socket就是傳輸通道兩端的接口。因此對於前端而言,socket也能夠簡單的理解爲對TCP/IP的抽象協議。github

WebSocket: WebSocket是包裝成了一個應用層協議做爲socket,從而可以讓客戶端和遠程服務端經過web創建全雙工通訊。websocket提供ws和wss兩種URL方案。協議英文文檔中文翻譯web

WebSocket API


使用WebSocket構造函數建立一個WebSocket鏈接,返回一個websocket實例。經過這個實例咱們能夠監聽事件,這些事件能夠知道何時簡歷鏈接,何時有消息被推過來了,何時發生錯誤了,時候鏈接關閉。咱們可使用node搭建一個WebSocket服務器來看看,github。一樣也能夠調用websocket.org網站的demo服務器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 協議有兩部分:握手、數據傳輸。

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

握手

  1. 客戶端握手請求

    //建立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,瀏覽器會自動完成完成上述請求頭。

  2. 服務端握手響應

    服務器得向客戶端證實它接收到了客戶端的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');
     	}
    複製代碼
  3. 握手關閉

    關閉握手可用使用TCP直接關閉鏈接的方法來關閉握手。可是TCP關閉握手不老是端到端可靠的,特別是出現攔截代理和其餘的中間設施。也能夠任何一端發送帶有指定控制序號(好比說狀態碼1002,協議錯誤)的數據的幀來開始關閉握手,當另外一方接收到這個關閉幀,就必須關閉鏈接。

數據傳輸

在WebSocket協議中,數據傳輸階段使用frame(數據幀)進行通訊,frame分不一樣的類型,主要有:文本數據,二進制數據。出於安全考慮和避免網絡截獲,客戶端發送的數據幀必須進行掩碼處理後才能發送到服務器,不管是否是在TLS安全協議上都要進行掩碼處理。服務器若是沒有收到掩碼處理的數據幀時應該關閉鏈接,發送一個1002的狀態碼。服務器不能將發送到客戶端的數據進行掩碼處理,若是客戶端收到掩碼處理的數據幀必須關閉鏈接。

那咱們服務器端接收到的數據幀是怎樣的呢?

  1. 數據幀

    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進行異或運算解密才能獲取到真實數據。

  2. 關於數據幀

    由於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協議規範。從原理出發,咱們接下來仍是以小數據量(單幀)數據傳輸爲例了。

  3. 解析數據幀

    //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;
     }
    複製代碼
  4. 組裝須要發送的數據幀

    // 組裝數據幀,發送是不須要掩碼加密
     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;
     }
    複製代碼
  5. 心跳檢測

    // 心跳檢查
     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這種相對成熟的插件也是不錯的選擇。

相關文章
相關標籤/搜索