摘要:最終形成服務器端的握手響應必需。出于安全考慮和避免網絡截獲,客戶端發送的數據幀必須進行掩碼處理后才能發送到服務器,不論是否是在安全協議上都要進行掩碼處理。服務器如果沒有收到掩碼處理的數據幀時應該關閉連接,發送一個的狀態碼。
首先
長連接:一個連接上可以連續發送多個數據包,在連接期間,如果沒有數據包發送,需要雙方發鏈路檢查包。
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構造函數創建一個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實例,可以使用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 = " "; 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這種相對成熟的插件也是不錯的選擇。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/90246.html
摘要:為了更加高效的網絡層,它需要不僅僅只是扮演套接字管理員的角色。用套接字池來組織套接字,以源來分組套接字,每個套接字池強制限制其連接數和安全約束。協商是一個為計算機網絡提供通信安全的加密協議。 原文請查閱這里,略有改動,本文采用知識共享署名 4.0 國際許可協議共享,BY Troland。 本系列持續更新中,Github 地址請查閱這里。 這是 JavaScript 工作原理的第十二章...
摘要:為了更加高效的網絡層,它需要不僅僅只是扮演套接字管理員的角色。用套接字池來組織套接字,以源來分組套接字,每個套接字池強制限制其連接數和安全約束。協商是一個為計算機網絡提供通信安全的加密協議。 原文請查閱這里,略有改動,本文采用知識共享署名 4.0 國際許可協議共享,BY Troland。 本系列持續更新中,Github 地址請查閱這里。 這是 JavaScript 工作原理的第十二章...
摘要:為了更加高效的網絡層,它需要不僅僅只是扮演套接字管理員的角色。用套接字池來組織套接字,以源來分組套接字,每個套接字池強制限制其連接數和安全約束。協商是一個為計算機網絡提供通信安全的加密協議。 原文請查閱這里,略有改動,本文采用知識共享署名 4.0 國際許可協議共享,BY Troland。 本系列持續更新中,Github 地址請查閱這里。 這是 JavaScript 工作原理的第十二章...
摘要:預備工作序最近正在研究相關的知識,想著如何能自己實現協議。監聽事件就是協議的抽象,直接在上面監聽已有的事件和事件這兩個事件。表示當前數據幀為消息的最后一個數據幀,此時接收方已經收到完整的消息,可以對消息進行處理。 A、預備工作 1、序 最近正在研究 Websocket 相關的知識,想著如何能自己實現 Websocket 協議。到網上搜羅了一番資料后用 Node.js 實現該協議,倒也沒...
閱讀 3490·2021-11-12 10:36
閱讀 2864·2021-09-22 15:35
閱讀 2814·2021-09-04 16:41
閱讀 1167·2019-08-30 15:55
閱讀 3580·2019-08-29 18:43
閱讀 2075·2019-08-23 18:24
閱讀 1419·2019-08-23 18:10
閱讀 1924·2019-08-23 11:31