深度:手寫一個WebSocket協議 [7000字]

寫在開頭:

 

爲什麼要使用websocket協議(以下簡稱ws協議),什麼場景會使用?

 

我之前是做IM相關桌面端軟件的開發,基於TCP長鏈接自己封裝的一套私有協議,目前公司也有項目用到了ws協議,好像無論什麼行業,都會遇到這個ws協議。

 

想自己造輪子,可以參考我之前的代碼和文章:

 

原創:從零實現一個簡單版React (附源碼)

 

原創:如何自己實現一個簡單的webpack構建工具 【附源碼】

 

首先它的使用是很簡單的,在H5和Node.js中都是基於事件驅動

 

在H5中

 

 

在H5中的使用案例:

 

<!DOCTYPE HTML><html><head><meta charset="utf-8"><script type="text/javascript">function WebSocketTest() {if ("WebSocket" in window) {                alert("您的瀏覽器支持 WebSocket!");// 打開一個 web socketvar 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的實例,監聽端口9998wss.on('connection', function connection(socket)  {    socket.on('message', function incoming(message) {console.log('received: %s', message);    socket.send('Hi Client');  });//當收到消息時,在控制檯打印出來,並回復一條信息});

 

這樣你就愉快的通信了,不需要關注協議的實現,但是真正的項目場景中,可能會有UDP、TCP、FTP、SFTP等場景,你還是需要了解不同的協議實現細節,這裏我推薦一下某金的張師傅小冊《TCP協議》,看過都說好。(這裏沒收錢,就是覺得好)

 


 

正式開始:

 

爲什麼要使用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: websocket
Connection:UpgradeHost: example.com
Origin: http://example.comSec-WebSocket-Key: sN9cRrP/n9NdMgdcy2VJFQ==Sec-WebSocket-Version:13

 

服務端響應,

 

HTTP/1.1101SwitchingProtocolsUpgrade: websocket
Connection:UpgradeSec-WebSocket-Accept: fFBooB7FAkLlXgRSz0BT3v4hq5s=Sec-WebSocket-Location: ws://example.com/

 

以下是官方對這些字段的解釋:
 

  •  Connection 必須設置 Upgrade,表示客戶端希望連接升級。

  •  Upgrade 字段必須設置 Websocket,表示希望升級到 Websocket 協議。

  •  Sec-WebSocket-Key 是隨機的字符串,服務器端會用這些數據來構造出一個 SHA-1 的信息摘要。把 “Sec-WebSocket-Key” 加上一個特殊字符串 “258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然後計算 SHA-1 摘要,之後進行 BASE-64 編碼,將結果做爲 “Sec-WebSocket-Accept” 頭的值,返回給客戶端。如此操作,可以儘量避免普通 HTTP 請求被誤認爲 Websocket 協議。

  •  Sec-WebSocket-Version 表示支持的 Websocket 版本。RFC6455 要求使用的版本是 13,之前草案的版本均應當棄用。

  •  Origin 字段是可選的,通常用來表示在瀏覽器中發起此 Websocket 連接所在的頁面,類似於 Referer。但是,與 Referer 不同的是,Origin 只包含了協議和主機名稱。

  •  其他一些定義在 HTTP 協議中的字段,如 Cookie 等,也可以在 Websocket 中使用。

 

這裏得先看這張圖

 

 

在第一次Http握手階段,觸發服務端的upgrade事件,我們把瀏覽器端的ws地址改成我們的自己實現的端口地址

 

websocket的協議特點:

 

  • 建立在 TCP 協議之上,服務器端的實現比較容易。

     

  • 與 HTTP 協議有着良好的兼容性。默認端口也是80和443,並且握手階段採用 HTTP 協議,因此握手時不容易屏蔽,能通過各種 HTTP 代理服務器。

     

  • 數據格式比較輕量,性能開銷小,通信高效。

     

  • 可以發送文本,也可以發送二進制數據。

     

  • 沒有同源限制,客戶端可以與任意服務器通信。

     

  • 協議標識符是ws(如果加密,則爲wss),服務器網址就是 URL。

 

如果你想系統學習TCP通信協議,我給你一個優惠碼,可以系統學習一下,將來如果你是去做一些有技術深度要求的工作,是很需要這個知識的(回扣我都會用來公衆號抽獎送禮物,並非盈利性質,是真的覺得這個學習資料好才推薦

 

 

回到正題,將客戶端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;

 

我們可以看到,監聽到了協議請求升級事件,而且可以拿到請求頭部。上面提到過:

 

 

  •  Sec-WebSocket-Key 是隨機的字符串,服務器端會用這些數據來構造出一個 SHA-1 的信息摘要。把 “Sec-WebSocket-Key” 加上一個特殊字符串 “258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然後計算 SHA-1 摘要,之後進行 BASE-64 編碼,將結果做爲 “Sec-WebSocket-Accept” 頭的值,返回給客戶端。如此操作,可以儘量避免普通 HTTP 請求被誤認爲 Websocket 協議。

 

說人話

 

就是要給一個特定的響應頭,告訴瀏覽器,這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); //默認端口8080this.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也會分片傳輸)

 

  • RSV1,RSV2,Rsv3(每個佔1位),必須是0,除非一個擴展協商爲非零值定義的

  • Opcode表示幀的類型(4位),例如這個傳輸的幀是文本類型還是二進制類型,二進制類型傳輸的數據可以是圖片或者語音之類的。(這4位轉換成16進制值表示的意思如下):

  • 0x0 表示附加數據幀

  • 0x1 表示文本數據幀

  • 0x2 表示二進制數據幀

  • 0x3-7 暫時無定義,爲以後的非控制幀保留

  • 0x8 表示連接關閉

  • 0x9 表示ping

  • 0xA 表示pong

  • 0xB-F 暫時無定義,爲以後的控制幀保留

 

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,現在最重要的一個通信長鏈接和頭部已經實現,只剩下兩點:

 

  • 進行與掩碼異或運行拿到真實數據

  • 處理真實數據(根據opcode)

 

提示:如果這兩點你看不懂沒關係,只是一個運算過程,當你自己基於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 .....

 轉自https://mp.weixin.qq.com/s/N_Z8379xLnC4H3v1nEdfZg

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章