寫在開頭:javascript
爲何要使用websocket協議(如下簡稱ws協議),什麼場景會使用?html
我以前是作IM相關桌面端軟件的開發,基於TCP長連接本身封裝的一套私有協議,目前公司也有項目用到了ws協議,好像不管什麼行業,都會遇到這個ws協議。前端
內容同步更新在個人:前端巔峯
微信工做公衆號vue
想本身造輪子,能夠參考我以前的代碼和文章:java
原創:從零實現一個簡單版React (附源碼) react
原創:如何本身實現一個簡單的webpack構建工具 【附源碼】 webpack
首先它的使用是很簡單的,在H5和Node.js中都是基於事件驅動git
在H5中github
在H5中的使用案例:web
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <script type="text/javascript"> function WebSocketTest() { if ('WebSocket' in window) { alert('您的瀏覽器支持 WebSocket!'); // 打開一個 web socket var ws = new WebSocket('ws://localhost:9998'); ws.onopen = function() { // Web Socket 已鏈接上,使用 send() 方法發送數據 ws.send('發送數據'); alert('數據發送中...'); }; ws.onmessage = function(evt) { var received_msg = evt.data; alert('數據已接收...'); }; ws.onclose = function() { // 關閉 websocket alert('鏈接已關閉...'); }; } else { // 瀏覽器不支持 WebSocket alert('您的瀏覽器不支持 WebSocket!'); } } </script> </head> <body> <div id="sse"> <a href="javascript:WebSocketTest()">運行 WebSocket</a> </div> </body> </html>
Node.js中的服務端搭建:
const { Server } = require('ws'); //引入模塊 const wss = new Server({ port: 9998 }); //建立一個WebSocketServer的實例,監聽端口9998 wss.on('connection', function connection(socket) { socket.on('message', function incoming(message) { console.log('received: %s', message); socket.send('Hi Client'); }); //當收到消息時,在控制檯打印出來,並回復一條信息 });
這樣你就愉快的通訊了,不須要關注協議的實現,可是真正的項目場景中,可能會有UDP、TCP、FTP、SFTP等場景,你仍是須要了解不一樣的協議實現細節
正式開始:
爲何要使用ws協議?
傳統的Ajax輪詢(即一直不聽發請求去後端拿數據)或長輪詢的操做太過於粗暴,性能更不用說。
ws協議在目前瀏覽器中支持已經很是好了,另外這裏說一句,它也是一個應用層協議,成功升級ws協議,是101狀態碼,像webpack熱更新這些都有用ws協議
**這就是鏈接了本地的ws服務器
**
如今開始,咱們實現服務端的ws協議,就是本身實現一個websocket類,而且繼承Node.js的自定義事件模塊,還要一個起一個進程佔用端口,那麼就要用到http模塊
const { EventEmitter } = require('events'); const { createServer } = require('http'); class MyWebsocket extends EventEmitter {} module.exports = MyWebsocket;
這是一個基礎的類,咱們繼承了自定義事件模塊,還引入了http的createServer方法,此時先實現端口占用
const { EventEmitter } = require('events'); const { createServer } = require('http'); class MyWebsocket extends EventEmitter { constructor(options) { super(options); this.options = options; this.server = createServer(); options.port ? this.server.listen(options.port) : this.server.listen(8080); //默認端口8080 } } module.exports = MyWebsocket;
接下來,要先分析下請求ws協議的請求頭、響應頭
正常一個ws協議成功創建分下面這幾個步驟
客戶端請求升級協議
GET / HTTP/1.1Upgrade: websocketConnection:UpgradeHost: example.com
Origin: http://example.comSec-WebSocket-Key: sN9cRrP/n9NdMgdcy2VJFQ==Sec-WebSocket-Version:13
服務端響應,
HTTP/1.1101SwitchingProtocolsUpgrade: websocketConnection:UpgradeSec-WebSocket-Accept: fFBooB7FAkLlXgRSz0BT3v4hq5s=Sec-WebSocket-Location: ws://example.com/
如下是官方對這些字段的解釋:
**這裏得先看這張圖
**
**在第一次Http握手階段,觸發服務端的upgrade事件,咱們把瀏覽器端的ws地址改爲咱們的本身實現的端口地址
**
websocket的協議特色:
創建在 TCP 協議之上,服務器端的實現比較容易。
與 HTTP 協議有着良好的兼容性。默認端口也是80和443,而且握手階段採用 HTTP 協議,所以握手時不容易屏蔽,能經過各類 HTTP 代理服務器。
數據格式比較輕量,性能開銷小,通訊高效。
能夠發送文本,也能夠發送二進制數據。
沒有同源限制,客戶端能夠與任意服務器通訊。
回到正題,將客戶端ws協議鏈接地址選擇咱們的服務器地址,而後改造服務端代碼,監聽upgrade事件看看
const { EventEmitter } = require('events'); const { createServer } = require('http'); class MyWebsocket extends EventEmitter { constructor(options) { super(options); this.options = options; this.server = createServer(); options.port ? this.server.listen(options.port) : this.server.listen(8080); //默認端口8080 // 處理協議升級請求 this.server.on('upgrade', (req, socket, header) => { this.socket = socket; console.log(req.headers); socket.write('hello'); }); } } module.exports = MyWebsocket;
咱們能夠看到,監聽到了協議請求升級事件,並且能夠拿到請求頭部。上面提到過:
說人話:
就是要給一個特定的響應頭,告訴瀏覽器,這ws協議請求升級,我贊成了。
代碼實現:
const { EventEmitter } = require('events'); const { createServer } = require('http'); const crypto = require('crypto'); const MAGIC_STRING = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; // 固定的字符串 function hashWebSocketKey(key) { const sha1 = crypto.createHash('sha1'); // 拿到sha1算法 sha1.update(key + MAGIC_STRING, 'ascii'); return sha1.digest('base64'); } class MyWebsocket extends EventEmitter { constructor(options) { super(options); this.options = options; this.server = createServer(); options.port ? this.server.listen(options.port) : this.server.listen(8080); //默認端口8080 this.server.on('upgrade', (req, socket, header) => { this.socket = socket; console.log(req.headers['sec-websocket-key'], 'key'); const resKey = hashWebSocketKey(req.headers['sec-websocket-key']); // 對瀏覽器生成的key進行加密 // 構造響應頭 const resHeaders = [ 'HTTP/1.1 101 Switching Protocols', 'Upgrade: websocket', 'Connection: Upgrade', 'Sec-WebSocket-Accept: ' + resKey, ] .concat('', '') .join('\r\n'); console.log(resHeaders, 'resHeaders'); socket.write(resHeaders); // 返回響應頭部 }); } } module.exports = MyWebsocket;
看看network面板,狀態碼已經變成了101,到這一步,咱們已經把協議升級成功,而且寫入了響應頭
剩下的就是數據交互了,既然ws是長連接+雙工通信,並且是應用層,創建在TCP之上封裝的,這張圖應該能很好的解釋(來自阮一峯老師的博客)
網絡鏈路已經通了,協議已經打通,剩下一個長連接+數據推送了,可是咱們目前仍是一個普通的http服務器
這是一個websocket的基本幀協議(其實websocket能夠當作基於TCP封裝的私有協議,只不過你們採用了某個標準達成了共識,有興趣的能夠看看微服務架構的相關內容,設計私有協議,端到端加密等)
其中FIN表明是否爲消息的最後一個數據幀(相似TCP的FIN,TCP也會分片傳輸)
Mask(佔1位):表示是否通過掩碼處理, 1 是通過掩碼的,0是沒有通過掩碼的。若是Mask位爲1,表示這是客戶端發送過來的數據,由於客戶端發送的數據要進行掩碼加密;若是Mask爲0,表示這是服務端發送的數據。
payload length (7位+16位,或者 7位+64位),定義負載數據的長度。
1.若是數據長度小於等於125的話,那麼該7位用來表示實際數據長度。
2.若是數據長度爲126到65535(2的16次方)之間,該7位值固定爲126,也就是 1111110,日後擴展2個字節(16爲,第三個區塊表示),用於存儲數據的實際長度。
3.若是數據長度大於65535, 該7位的值固定爲127,也就是 1111111 ,日後擴展8個字節(64位),用於存儲數據實際長度。
Masking-key(0或者4個字節),該區塊用於存儲掩碼密鑰,只有在第二個子節中的mask爲1,也就是消息進行了掩碼處理時纔有,不然沒有,因此服務器端向客戶端發送消息就沒有這一塊。
Payload data 擴展數據,是0字節,除非已經協商了一個擴展。
如今咱們須要保持長連接
⚠️:若是你是使用Node.js開啓基於TCP的私有雙工長連接協議,也要開啓這個選項
const { EventEmitter } = require('events'); const { createServer } = require('http'); const crypto = require('crypto'); const MAGIC_STRING = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; // 固定的字符串 function hashWebSocketKey(key) { const sha1 = crypto.createHash('sha1'); // 拿到sha1算法 sha1.update(key + MAGIC_STRING, 'ascii'); return sha1.digest('base64'); } class MyWebsocket extends EventEmitter { constructor(options) { super(options); this.options = options; this.server = createServer(); options.port ? this.server.listen(options.port) : this.server.listen(8080); //默認端口8080 this.server.on('upgrade', (req, socket, header) => { this.socket = socket; socket.setKeepAlive(true); console.log(req.headers['sec-websocket-key'], 'key'); const resKey = hashWebSocketKey(req.headers['sec-websocket-key']); // 對瀏覽器生成的key進行加密 // 構造響應頭 const resHeaders = [ 'HTTP/1.1 101 Switching Protocols', 'Upgrade: websocket', 'Connection: Upgrade', 'Sec-WebSocket-Accept: ' + resKey, ] .concat('', '') .join('\r\n'); console.log(resHeaders, 'resHeaders'); socket.write(resHeaders); // 返回響應頭部 }); } } module.exports = MyWebsocket;
OK,如今最重要的一個通訊長連接和頭部已經實現,只剩下兩點:
提示:若是這兩點你看不懂不要緊,只是一個運算過程,當你本身基於TCP設計私有協議時候,也要考慮這些,msgType、payloadLength、服務端發包粘包、客戶端收包粘包、斷線重傳、timeout、心跳、發送隊列等
給socket對象掛載事件,咱們已經繼承了EventEmitter模塊
socket.on('data', (data) => { // 監聽客戶端發送過來的數據,該數據是一個Buffer類型的數據 this.buffer = data; // 將客戶端發送過來的幀數據保存到buffer變量中 this.processBuffer(); // 處理Buffer數據 }); socket.on('close', (error) => { // 監聽客戶端鏈接斷開事件 if (!this.closed) { this.emit('close', 1006, 'timeout'); this.closed = true; }
每次接受到了data,觸發事件,解析Buffer,進行運算
processBuffer() { let buf = this.buffer; let idx = 2; // 首先分析前兩個字節 // 處理第一個字節 const byte1 = buf.readUInt8(0); // 讀取buffer數據的前8 bit並轉換爲十進制整數 // 獲取第一個字節的最高位,看是0仍是1 const str1 = byte1.toString(2); // 將第一個字節轉換爲二進制的字符串形式 const FIN = str1[0]; // 獲取第一個字節的後四位,讓第一個字節與00001111進行與運算,便可拿到後四位 let opcode = byte1 & 0x0f; //截取第一個字節的後4位,即opcode碼, 等價於 (byte1 & 15) // 處理第二個字節 const byte2 = buf.readUInt8(1); // 從第一個字節開始讀取8位,即讀取數據幀第二個字節數據 const str2 = byte2.toString(2); // 將第二個字節轉換爲二進制的字符串形式 const MASK = str2[0]; // 獲取第二個字節的第一位,判斷是否有掩碼,客戶端必需要有 let length = parseInt(str2.substring(1), 2); // 獲取第二個字節除第一位掩碼以後的字符串並轉換爲整數 if (length === 126) { // 說明125<數據長度<65535(16個位能描述的最大值,也就是16個1的時候) length = buf.readUInt16BE(2); // 就用第三個字節及第四個字節表示數據的長度 idx += 2; // 偏移兩個字節 } else if (length === 127) { // 說明數據長度已經大於65535,16個位也已經不足以描述數據長度了,就用第三到第十個字節這八個字節來描述數據長度 const highBits = buf.readUInt32BE(2); // 從第二個字節開始讀取32位,即4個字節,表示後8個字節(64位)用於表示數據長度,其中高4字節是0 if (highBits != 0) { // 前四個字節必須爲0,不然數據異常,須要關閉鏈接 this.close(1009, ''); //1009 關閉代碼,說明數據太大;協議裏是支持 63 位長度,不過這裏咱們本身實現的話,只支持 32 位長度,防止數據過大; } length = buf.readUInt32BE(6); // 獲取八個字節中的後四個字節用於表示數據長度,即從第6到第10個字節,爲真實存放的數據長度 idx += 8; } let realData = null; // 保存真實數據對應字符串形式 if (MASK) { // 若是存在MASK掩碼,表示是客戶端發送過來的數據,是加密過的數據,須要進行數據解碼 const maskDataBuffer = buf.slice(idx, idx + 4); //獲取掩碼數據, 其中前四個字節爲掩碼數據 idx += 4; //指針前移到真實數據段 const realDataBuffer = buf.slice(idx, idx + length); // 獲取真實數據對應的Buffer realData = handleMask(maskDataBuffer, realDataBuffer); //解碼真實數據 console.log(`realData is ${realData}`); } let realDataBuffer = Buffer.from(realData); // 將真實數據轉換爲Buffer this.buffer = buf.slice(idx + length); // 清除已處理的buffer數據 if (FIN) { // 若是第一個字節的第一位爲1,表示是消息的最後一個分片,即所有消息結束了(發送的數據比較少,一次發送完成) this.handleRealData(opcode, realDataBuffer); // 處理操做碼 } }
若是FIN不爲0,那麼意味着分片結束,能夠解析Buffer。
處理mask掩碼(客戶端發過來的是1,服務端發的是0)獲得真正到數據
function handleMask(maskBytes, data) { const payload = Buffer.alloc(data.length); for (let i = 0; i < data.length; i++) { // 遍歷真實數據 payload[i] = maskBytes[i % 4] ^ data[i]; // 掩碼有4個字節依次與真實數據進行異或運算便可 } return payload; }
根據opcode(接受到的數據是字符串仍是Buffer)進行處理:
const OPCODES = { CONTINUE: 0, TEXT: 1, BINARY: 2, CLOSE: 8, PING: 9, PONG: 10, }; // 處理客戶端發送過來的真實數據 handleRealData(opcode, realDataBuffer) { switch (opcode) { case OPCODES.TEXT: this.emit('data', realDataBuffer.toString('utf8')); // 服務端WebSocket監聽data事件便可拿到數據 break; case OPCODES.BINARY: //二進制文件直接交付 this.emit('data', realDataBuffer); break; default: this.close(1002, 'unhandle opcode:' + opcode); } }
若是是Buffer就轉換爲utf8的字符串(若是是protobuffer協議,那麼還要根據pb文件進行解析)
接受數據已經搞定,傳輸數據無非兩種,字符串和二進制,那麼發送也是。
下面把發送搞定
send(data) { let opcode; let buffer; if (Buffer.isBuffer(data)) { // 若是是二進制數據 opcode = OPCODES.BINARY; // 操做碼設置爲二進制類型 buffer = data; } else if (typeof data === 'string') { // 若是是字符串 opcode = OPCODES.TEXT; // 操做碼設置爲文本類型 buffer = Buffer.from(data, 'utf8'); // 將字符串轉換爲Buffer數據 } else { throw new Error('cannot send object.Must be string of Buffer'); } this.doSend(opcode, buffer); } // 開始發送數據 doSend(opcode, buffer) { this.socket.write(encodeMessage(opcode, buffer)); //編碼後直接經過socket發送 }
首先把要發送的數據都轉換成二進制,而後進行數據幀格式拼裝
function encodeMessage(opcode, payload) { let buf; // 0x80 二進制爲 10000000 | opcode 進行或運算就至關因而將首位置爲1 let b1 = 0x80 | opcode; // 若是沒有數據了將FIN置爲1 let b2; // 存放數據長度 let length = payload.length; console.log(`encodeMessage: length is ${length}`); if (length < 126) { buf = Buffer.alloc(payload.length + 2 + 0); // 服務器返回的數據不須要加密,直接加2個字節便可 b2 = length; // MASK爲0,直接賦值爲length值便可 buf.writeUInt8(b1, 0); //從第0個字節開始寫入8位,即將b1寫入到第一個字節中 buf.writeUInt8(b2, 1); //讀8―15bit,將字節長度寫入到第二個字節中 payload.copy(buf, 2); //複製數據,從2(第三)字節開始,將數據插入到第二個字節後面 } return buf; }
服務端發送的數據,Mask的值爲0
此時在外面監聽事件,像平時同樣使用ws協議同樣便可。
const MyWebSocket = require('./ws'); const ws = new MyWebSocket({ port: 8080 }); ws.on('data', data => { console.log('receive data:' + data); ws.send('this message from server'); }); ws.on('close', (code, reason) => { console.log('close:', code, reason); });
本文倉庫地址源碼:
https://github.com/JinJieTan/my-websocket
歷史的文章源碼:
手寫mini-react: https://github.com/JinJieTan/mini-react 手寫mini-webpack: https://github.com/JinJieTan/react-webpack 手寫靜態資源服務器 : https://github.com/JinJieTan/util-static-server 手寫微前端框架、vue .....