Node.js を使用した手書きの WebSocket プロトコル

目次

序章

http から websockt に切り替える

Sec-WebSocket-Key と Sec-WebSocket-Accept

新しいバイナリプロトコル

WebSocket サーバーを自分で実装する

受信したバッファをプロトコル形式に従って解析します。

オペコードを取り出します

MASKとペイロード長を取り出します

マスクキーに従ってデータを読み取る

データの種類に応じて処理する

フレームフレーム

データ送信

完全なコード

要約する


序章

        http は質疑応答モードであることがわかっており、クライアントは http リクエストをサーバーに送信し、サーバーは http レスポンスを返します。このモードは、リソースとデータを読み込むには十分ですが、データのプッシュが必要なシナリオには適していません。

        学生の中には、http2 にはサーバー プッシュがないのではないかと言う人もいました。これはリソースをプッシュするためだけのものです。

写真

        たとえば、ブラウザが HTML をリクエストした場合、サーバーは CSS を一緒にブラウザにプッシュできます。ブラウザは受け入れるかどうかを決定できます。インスタント メッセージングなどのリアルタイム要件が高いシナリオでは、WebSocket が必要です。

http から websockt に切り替える

        厳密に言えば、WebSocket は http とは関係がなく、別のプロトコル形式です。ただし、http から websockt への切り替えプロセスが必要です。

写真

切り替えプロセスの詳細は次のとおりです。

1. リクエストするときは、次のヘッダーを持参してください。

Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: Ia3dQjfWrAug/6qm7mTZOg==

        最初の 2 つは理解しやすいもので、WebSocket プロトコルにアップグレードすることを意味します。3 番目のヘッダーはセキュリティのためのキーです。

2. サーバーは次のヘッダーを返します。

HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: JkE58n3uIigYDMvC+KsBbGZsp1A=

        リクエスト ヘッダーと同様に、Sec-WebSocket-Accept は、リクエストによってもたらされた Sec-WebSocket-Key を処理した結果です。

        このヘッダーの検証は、相手が WebSocket 機能を持っている必要があることを確認するために追加されます。そうでない場合、接続は確立されても相手からのニュースがない場合、待つのは時間の無駄になります。

Sec-WebSocket-Key と Sec-WebSocket-Accept

        Sec-WebSocket-Accept を取得するために Sec-WebSocket-Key を処理するにはどうすればよいですか? ノードで実装したところ、こんな感じになりました。

const crypto = require('crypto');

function hashKey(key) {
  const sha1 = crypto.createHash('sha1');
  sha1.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11');
  return sha1.digest('base64');
}

        それは、クライアントから送られてきた鍵を利用し、固定文字列を追加し、sha1で暗号化した上でbase64に変換するというものです。

この文字列 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 は修正されています。信じられない場合は、次のように検索してください。

写真

Zhihu などの Web ソケットを備えた Web サイトを見つけてください。

写真

        ws タイプのリクエストをフィルターで除外し、これらのヘッダーが上記のものであるかどうかを確認します。この Sec-WebSocket-Key は wk60yiym2FEwCAMVZE3FgQ== です。

写真

そして、応答 Sec-WebSocket-Accept は XRfPnS+8xl11QWZherej/dkHPHM= です。

写真

計算してみましょう:

写真

        同じことですか!WebSocketのプロトコルアップグレード時のSec-WebSocket-Keyに対応するSec-WebSocket-Acceptの計算処理です。

このステップの後、新しいプロトコルである WebSocket プロトコルに切り替えます。

新しいバイナリプロトコル

メッセージ列をチェックして、テキストまたはバイナリの送信メッセージを確認します。

写真

真新しい契約ですか?それはどのような合意ですか? そのような:

写真

誰もが使い慣れている http プロトコルは、ヘッダーが key:value で、本文が次のようになります。

写真

        これはテキスト プロトコルであり、各ヘッダーは人間が判読できる文字です。これは理解するのは簡単ですが、送信にはスペースがかかりすぎます。そして、websocket はバイナリ プロトコルであり、1 バイトを使用して多くの情報を保存できます。

写真

        たとえば、プロトコルの最初のバイト (8 バイナリ ビット) には、FIN (終了フラグ)、オペコード (コンテンツ タイプがバイナリかテキストか) などの情報が格納されます。

        2バイト目にはマスク(暗号化の有無)、ペイロード(データ長)が格納されます。

        たった 2 バイトに、どれほど多くの情報が保存されているのでしょうか。この点では、バイナリ プロトコルがテキスト プロトコルよりも優れています。

        私たちが目にするwebosketのメッセージ送受信は、実際には最下層でこのような形式で綴られています。

写真

ブラウザがこの形式のプロトコル データを解析するだけです。

        これがwebosketのプロセス全体です。実際、これは非常に明確で、プロトコルを切り替えてからバイナリ Webosket プロトコルを送受信するプロセスです。

WebSocket サーバーを自分で実装する

それではNode.jsを使ってWebSocketサーバーを自分で実装してみましょう!

1. MyWebsocket クラスを定義します。

const { EventEmitter } = require('events');
const http = require('http');

class MyWebsocket extends EventEmitter {
  constructor(options) {
    super(options);

    const server = http.createServer();
    server.listen(options.port || 8080);

    server.on('upgrade', (req, socket) => {
      
    });
  }
}

EventEmitter を継承する目的は、emit を使用していくつかのイベントを送信することであり、外部の世界はこのイベントをリッスンして処理することができます。

        コンストラクタ内に http サービスを作成しましたが、ungrade イベントが発生した場合、つまり Connection: upgrade のヘッダを受信した場合に、スイッチング プロトコルのヘッダが返されます。

返されるヘッダーは以前にも見られましたが、sec-websocket-key を処理するためのものです。

server.on('upgrade', (req, socket) => {
  this.socket = socket;
  socket.setKeepAlive(true);

  const resHeaders = [
    'HTTP/1.1 101 Switching Protocols',
    'Upgrade: websocket',
    'Connection: Upgrade',
    'Sec-WebSocket-Accept: ' + hashKey(req.headers['sec-websocket-key']),
    '',
    ''
  ].join('\r\n');
  socket.write(resHeaders);

  socket.on('data', (data) => {
    console.log(data)
  });
  socket.on('close', (error) => {
      this.emit('close');
  });
});

ソケットを取得して上記のヘッダーを返します。キーの処理は前に説明したアルゴリズムです。

function hashKey(key) {
  const sha1 = crypto.createHash('sha1');
  sha1.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11');
  return sha1.digest('base64');
}

以上でプロトコルの切り替えは完了です。私たちが試してみると信じないでください。実装した ws サーバーを導入して実行します。

const MyWebSocket = require('./ws');
const ws = new MyWebSocket({ port: 8080 });

ws.on('data', (data) => {
  console.log('receive data:' + data);
});

ws.on('close', (code, reason) => {
  console.log('close:', code, reason);
});

写真

次に、次のような新しい HTML を作成します。

<!DOCTYPE HTML>
<html>
<body>
    <script>
        const ws = new WebSocket("ws://localhost:8080");

        ws.onopen = function () {
            ws.send("发送数据");
            setTimeout(() => {
                ws.send("发送数据2");
            }, 3000)
        };

        ws.onmessage = function (evt) {
            console.log(evt)
        };

        ws.onclose = function () {
        };
    </script>
</body>

</html>

ブラウザの WebSocket API を使用して接続を確立し、メッセージを送信します。

npx http-server を使用し、静的サービスを開始します。次に、ブラウザは次の HTML にアクセスします。

次に、devtools を開くと、プロトコルの切り替えが成功したことがわかります。

写真

これら 3 つのヘッダーと 101 ステータス コードはすべて、弊社から返されます。送信されたメッセージはメッセージでも確認できます。

写真

もう一度サーバーにアクセスすると、次のメッセージも受け取りました。

写真

それは単なるバッファ、つまりバイナリです。

受信したバッファをプロトコル形式に従って解析します。

オペコードを取り出します

        次に、Buffer をプロトコル形式に従って解析し、応答形式のプロトコルデータ Buffer を生成して返すだけで、WebSocket データの送受信が可能になります。

この部分はさらに厄介なので、少し見てみましょう。

写真

最初のバイトの最後の 4 ビット、つまりオペコードが必要です。

次のように書きます。

const byte1 = bufferData.readUInt8(0);
let opcode = byte1 & 0x0f; 

8 ビット符号なし整数の内容、つまりバイトの内容を読み取ります。パラメータはオフセットのバイトで、ここでは 0 です。

最後の4ビットをビット演算で取り出したものがオペコードです。

MASKとペイロード長を取り出します

次に、2 番目のバイトを処理します。

写真

最初のビットはマスク フラグ ビット、最後の 7 ビットはペイロード長です。

次のように取得できます。

const byte2 = bufferData.readUInt8(1);
const str2 = byte2.toString(2);
const MASK = str2[0];
let payloadLength = parseInt(str2.substring(1), 2);

バイトの内容を読み取るには引き続きbuffer.readUInt8を使用します。

まずバイナリ文字列に変換し、次に最初の桁がマスクになり、次に最後の 7 桁の部分文字列をインターセプトして、parseInt を数値に変換します。これがペイロードの長さになります。

このようにして、最初の 2 バイトのプロトコルの内容が解析されます。

学生の中には、なぜ後でペイロード長が 2 つあるのかと疑問に思う人もいるかもしれません。

写真

これは、データの長さが必ずしも長いとは限らず、16 ビットの記憶長が必要な場合もあれば、32 ビットが必要な場合もあるからです。

そのため、WebSocket プロトコルでは、7 ビットのコンテンツが 125 を超えない場合、それがペイロード長になると規定されています。

7 ビット コンテンツが 126 の場合は、それを使用せず、次の 16 ビット コンテンツをペイロード長として使用します。

7 ビット コンテンツが 127 の場合は、それを使用せず、後者の 64 ビット コンテンツをペイロード長として使用します。

実はわかりやすいのが、if else の 3 つです。

コードでは次のように記述されます。

let payloadLength = parseInt(str2.substring(1), 2);

let curByteIndex = 2;

if (payloadLength === 126) {
  payloadLength = bufferData.readUInt16BE(2);
  curByteIndex += 2;
} else if (payloadLength === 127) {
  payloadLength = bufferData.readBigUInt64BE(2);
  curByteIndex += 8;
}

ここでの curByteIndex には、現在処理されているバイト数が格納されます。

126 の場合は、3 バイト目から開始して 2 バイト (16 ビット長) を読み取り、buffer.readUInt16BE メソッドを使用します。

127 の場合は、3 バイト目から開始して 8 バイト (64 ビット長) を読み取り、buffer.readBigUInt64BE メソッドを使用します。

写真

この方法で、ペイロードの長さを取得し、この長さを使用してコンテンツを傍受できます。

マスクキーに従ってデータを読み取る

ただし、データを読み取る前に、コンテンツを復号化するために使用されるマスクが処理される必要があります。

写真

マスクキーである4バイトを読み取ります。

後者はペイロードの長さに応じて読み取ることができます。

let realData = null;

if (MASK) {
  const maskKey = bufferData.slice(curByteIndex, curByteIndex + 4);  
  curByteIndex += 4;
  const payloadData = bufferData.slice(curByteIndex, curByteIndex + payloadLength);
  realData = handleMask(maskKey, payloadData);
} else {
  realData = bufferData.slice(curByteIndex, curByteIndex + payloadLength);;
}

次に、マスク キーを使用してデータを復号化します。

このアルゴリズムも修正されており、データの各バイトと各ビットのマスク キーを使用してビット単位の XOR を実行するだけです。

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];
  }
  return payload;
}

このようにして、最終的なデータが完成しました。

データの種類に応じて処理する

ただし、コンテンツはいくつかのタイプに分割されているため、ハンドラーに渡す前に、タイプに従って処理する必要があります。つまり、オペコードには複数の値があります。

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'));
        break;
      case OPCODES.BINARY:
        this.emit('data', realDataBuffer);
        break;
      default:
        this.emit('close');
        break;
    }
}

テキストは utf-8 文字列に変換され、バイナリ データが直接バッファ データとして使用されます。

このようにして、処理プログラム内で解析されたデータを取得することができます。

試してみましょう:

これまでに、webosket プロトコルのコンテンツのバッファーを取得できました。

写真

これで、データを正しく解析できるようになりました。

写真

これまでのところ、WebSocket プロトコルの分析は成功しています。

フレームフレーム

このようなプロトコル形式のデータはフレームと呼ばれます。

写真

データ送信

分析がOKであれば、データの送信を実現します。

送信も同様のフレームフォーマットで構成されます。

このような送信メソッドを定義します。

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');
    } else {
      console.error('暂不支持发送的数据类型')
    }
    this.doSend(opcode, buffer);
}

doSend(opcode, bufferDatafer) {
   this.socket.write(encodeMessage(opcode, bufferDatafer));
}

コンテンツは、テキスト データかバイナリ データが送信されたかどうかに応じて処理されます。

次に、WebSocket のフレームを構築します。

function encodeMessage(opcode, payload) {
  //payload.length < 126
  let bufferData = Buffer.alloc(payload.length + 2 + 0);;
  
  let byte1 = parseInt('10000000', 2) | opcode; // 设置 FIN 为 1
  let byte2 = payload.length;

  bufferData.writeUInt8(byte1, 0);
  bufferData.writeUInt8(byte2, 1);

  payload.copy(bufferData, 2);
  
  return bufferData;
}

データ長が 125 未満の場合のみ処理します。

最初のバイトはオペコードで、最初のビットを 1 に設定し、ビットごとの OR で書き込みます。

写真

サーバーはクライアントにメッセージを送り返すためにマスクを必要としないため、2 番目のバイトがペイロードの長さになります。

データの最初の 2 バイトをそれぞれバッファに書き込み、異なるオフセットを指定します。

bufferData.writeUInt8(byte1, 0);
bufferData.writeUInt8(byte2, 1);

次に、ペイロード データを後ろに置きます。

 payload.copy(bufferData, 2);

このような WebSocket フレームが構築されます。

やってみよう:

写真

クライアントからメッセージを受信すると、2 秒ごとにメッセージが返されます。

写真

メッセージの送受信が成功しました。

完全なコード

このようにして、WebSocketサーバーを自社で実装し、WebSocketプロトコルの解析と生成を実現しました!

完全なコードは次のとおりです。

MyWebSocket:

//ws.js
const { EventEmitter } = require('events');
const http = require('http');
const crypto = require('crypto');

function hashKey(key) {
  const sha1 = crypto.createHash('sha1');
  sha1.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11');
  return sha1.digest('base64');
}

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];
  }
  return payload;
}

const OPCODES = {
  CONTINUE: 0,
  TEXT: 1,
  BINARY: 2,
  CLOSE: 8,
  PING: 9,
  PONG: 10,
};

function encodeMessage(opcode, payload) {
  //payload.length < 126
  let bufferData = Buffer.alloc(payload.length + 2 + 0);;
  
  let byte1 = parseInt('10000000', 2) | opcode; // 设置 FIN 为 1
  let byte2 = payload.length;

  bufferData.writeUInt8(byte1, 0);
  bufferData.writeUInt8(byte2, 1);

  payload.copy(bufferData, 2);
  
  return bufferData;
}

class MyWebsocket extends EventEmitter {
  constructor(options) {
    super(options);

    const server = http.createServer();
    server.listen(options.port || 8080);

    server.on('upgrade', (req, socket) => {
      this.socket = socket;
      socket.setKeepAlive(true);

      const resHeaders = [
        'HTTP/1.1 101 Switching Protocols',
        'Upgrade: websocket',
        'Connection: Upgrade',
        'Sec-WebSocket-Accept: ' + hashKey(req.headers['sec-websocket-key']),
        '',
        ''
      ].join('\r\n');
      socket.write(resHeaders);

      socket.on('data', (data) => {
        this.processData(data);
        // console.log(data);
      });
      socket.on('close', (error) => {
          this.emit('close');
      });
    });
  }

  handleRealData(opcode, realDataBuffer) {
    switch (opcode) {
      case OPCODES.TEXT:
        this.emit('data', realDataBuffer.toString('utf8'));
        break;
      case OPCODES.BINARY:
        this.emit('data', realDataBuffer);
        break;
      default:
        this.emit('close');
        break;
    }
  }

  processData(bufferData) {
    const byte1 = bufferData.readUInt8(0);
    let opcode = byte1 & 0x0f; 
    
    const byte2 = bufferData.readUInt8(1);
    const str2 = byte2.toString(2);
    const MASK = str2[0];

    let curByteIndex = 2;
    
    let payloadLength = parseInt(str2.substring(1), 2);
    if (payloadLength === 126) {
      payloadLength = bufferData.readUInt16BE(2);
      curByteIndex += 2;
    } else if (payloadLength === 127) {
      payloadLength = bufferData.readBigUInt64BE(2);
      curByteIndex += 8;
    }

    let realData = null;
    
    if (MASK) {
      const maskKey = bufferData.slice(curByteIndex, curByteIndex + 4);  
      curByteIndex += 4;
      const payloadData = bufferData.slice(curByteIndex, curByteIndex + payloadLength);
      realData = handleMask(maskKey, payloadData);
    } 
    
    this.handleRealData(opcode, realData);
  }

  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');
    } else {
      console.error('暂不支持发送的数据类型')
    }
    this.doSend(opcode, buffer);
  }

  doSend(opcode, bufferDatafer) {
    this.socket.write(encodeMessage(opcode, bufferDatafer));
  }
}

module.exports = MyWebsocket;

索引:

const MyWebSocket = require('./ws');
const ws = new MyWebSocket({ port: 8080 });

ws.on('data', (data) => {
  console.log('receive data:' + data);
  setInterval(() => {
    ws.send(data + ' ' + Date.now());
  }, 2000)
});

ws.on('close', (code, reason) => {
  console.log('close:', code, reason);
});

html:

<!DOCTYPE HTML>
<html>
<body>
    <script>
        const ws = new WebSocket("ws://localhost:8080");

        ws.onopen = function () {
            ws.send("发送数据");
            setTimeout(() => {
                ws.send("发送数据2");
            }, 3000)
        };

        ws.onmessage = function (evt) {
            console.log(evt)
        };

        ws.onclose = function () {
        };
    </script>
</body>

</html>

要約する

WebSocket を使用して、インスタント メッセージング、ゲーム、その他のシナリオなどの高いリアルタイム要件を実装します。

websocket は http とは関係ありませんが、http から websocket への切り替えプロセスが必要です。

この切り替えプロセスでは、アップグレードのヘッダーに加えて、sec-websocket-key も必要であり、サーバーはこのキーに基づいて結果を計算し、sec-websocket-accept を通じて返します。応答は 101 スイッチング プロトコル ステータス コードです。

この計算プロセスは比較的固定されており、キー + sha1 と Base64 で暗号化された固定文字列の結果です。

このメカニズムは、任意に 101 ステータス コードを返すのではなく、相手が WebSocket サーバーである必要があることを保証するために追加されています。

次にWebSocketプロトコルですが、これはバイナリプロトコルであり、フォーマットに従ってWebSocketフレームの解析と生成が完了しました。

これは完全な WebSocket プロトコルの実装です。

WebSocket サービスを手作業で作成しましたが、WebSocket についての理解が深まったと思いますか?

おすすめ

転載: blog.csdn.net/lambert00001/article/details/131878501