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

寫在開頭: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/

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

  •  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。

回到正題,將客戶端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); //默認端口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也會分片傳輸)

  • 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 .....
相關文章
相關標籤/搜索