七千字深度:手寫一個WebSocket協議


七千字深度:手寫一個WebSocket協議

作者:Peter譚金傑

轉發鏈接:https://segmentfault.com/a/1190000022481875

寫在開頭:

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

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

內容同步更新在我的:前端巔峰微信工作公眾號

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

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

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

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

在H5中

七千字深度:手寫一個WebSocket協議

在H5中的使用案例:

Node.js中的服務端搭建:

<code>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');
    }); //當收到消息時,在控制檯打印出來,並回復一條信息
});
/<code>

這樣你就愉快的通信了,不需要關注協議的實現,但是真正的項目場景中,可能會有UDP、TCP、FTP、SFTP等場景,你還是需要了解不同的協議實現細節

正式開始:

為什麼要使用ws協議?

傳統的Ajax輪詢(即一直不聽發請求去後端拿數據)或長輪詢的操作太過於粗暴,性能更不用說。

ws協議在目前瀏覽器中支持已經非常好了,另外這裡說一句,它也是一個應用層協議,成功升級ws協議,是101狀態碼,像webpack熱更新這些都有用ws協議

**這就是連接了本地的ws服務器**

現在開始,我們實現服務端的ws協議,就是自己實現一個websocket類,並且繼承Node.js的自定義事件模塊,還要一個起一個進程佔用端口,那麼就要用到http模塊

<code>const { EventEmitter } = require('events');
const { createServer } = require('http');
class MyWebsocket extends EventEmitter {}
module.exports = MyWebsocket;
/<code>

這是一個基礎的類,我們繼承了自定義事件模塊,還引入了http的createServer方法,此時先實現端口占用

<code>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;
/<code>

接下來,要先分析下請求ws協議的請求頭、響應頭

七千字深度:手寫一個WebSocket協議

正常一個ws協議成功建立分下面這幾個步驟

客戶端請求升級協議

GET / HTTP/1.1Upgrade: websocketConnection:UpgradeHost: example.comOrigin: 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 中使用。

**這裡得先看這張圖**

七千字深度:手寫一個WebSocket協議

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

websocket的協議特點:

  • 建立在 TCP 協議之上,服務器端的實現比較容易。
  • 與 HTTP 協議有著良好的兼容性。默認端口也是80和443,並且握手階段採用 HTTP 協議,因此握手時不容易屏蔽,能通過各種 HTTP 代理服務器。
  • 數據格式比較輕量,性能開銷小,通信高效。
  • 可以發送文本,也可以發送二進制數據。
  • 沒有同源限制,客戶端可以與任意服務器通信。
  • 協議標識符是ws(如果加密,則為wss),服務器網址就是 URL。

回到正題,將客戶端ws協議連接地址選擇我們的服務器地址,然後改造服務端代碼,監聽upgrade事件看看

<code>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;
/<code>

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

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

說人話

就是要給一個特定的響應頭,告訴瀏覽器,這ws協議請求升級,我同意了。

代碼實現:

<code>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;
/<code>

看看network面板,狀態碼已經變成了101,到這一步,我們已經把協議升級成功,並且寫入了響應頭

七千字深度:手寫一個WebSocket協議

剩下的就是數據交互了,既然ws是長鏈接+雙工通訊,而且是應用層,建立在TCP之上封裝的,這張圖應該能很好的解釋(來自阮一峰老師的博客

七千字深度:手寫一個WebSocket協議

網絡鏈路已經通了,協議已經打通,剩下一個長鏈接+數據推送了,但是我們目前還是一個普通的http服務器

這是一個websocket的基本幀協議(其實websocket可以看成基於TCP封裝的私有協議,只不過大家採用了某個標準達成了共識,有興趣的可以看看微服務架構的相關內容,設計私有協議,端到端加密等)

七千字深度:手寫一個WebSocket協議

其中FIN代表是否為消息的最後一個數據幀(類似TCP的FIN,TCP也會分片傳輸)

七千字深度:手寫一個WebSocket協議

  • 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的私有雙工長鏈接協議,也要開啟這個選項

<code>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;
/<code> 

OK,現在最重要的一個通信長鏈接和頭部已經實現,只剩下兩點:

  • 進行與掩碼異或運行拿到真實數據
  • 處理真實數據(根據opcode)

提示:如果這兩點你看不懂沒關係,只是一個運算過程,當你自己基於TCP設計私有協議時候,也要考慮這些,msgType、payloadLength、服務端發包粘包、客戶端收包粘包、斷線重傳、timeout、心跳、發送隊列等

給socket對象掛載事件,我們已經繼承了EventEmitter模塊

<code>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;
    }/<code>

每次接受到了data,觸發事件,解析Buffer,進行運算

<code>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/<code> 

如果FIN不為0,那麼意味著分片結束,可以解析Buffer。

處理mask掩碼(客戶端發過來的是1,服務端發的是0)得到真正到數據

<code>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;
}
/<code>

根據opcode(接受到的數據是字符串還是Buffer)進行處理:

<code>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);
      }
    }/<code>

如果是Buffer就轉換為utf8的字符串(如果是protobuffer協議,那麼還要根據pb文件進行解析)

接受數據已經搞定,傳輸數據無非兩種,字符串和二進制,那麼發送也是。

下面把發送搞定

<code>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發送
  }/<code> 

首先把要發送的數據都轉換成二進制,然後進行數據幀格式拼裝

<code>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;
}
/<code>

服務端發送的數據,Mask的值為0

此時在外面監聽事件,像平時一樣使用ws協議一樣即可。

<code>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);
});
/<code>

本文倉庫地址源碼:

<code>https://github.com/JinJieTan/my-websocket/<code>

推薦WebSocket學習相關文章

如何在小程序中實現 WebSocket 通信

Vue合理配置WebSocket並實現群聊

手把手教你用Canvas WebSocket Redis 實現一個視頻彈幕【實踐】

Go語言實現的WebSocket

「譯」 Go 實現百萬 WebSocket 連接


作者:Peter譚金傑

轉發鏈接:https://segmentfault.com/a/1190000022481875


分享到:


相關文章: