🔬

WebSocketライブラリを解析してみた

2023/04/16に公開

概要・学んだこと

NodeJSの代表的なWebSocket通信ライブラリ「websockets/ws」を解析した。
WebSocket通信はHTTP通信で101ステータスを受け取ったのち、そのHTTP通信で使用したTCPSocketを利用して通信を行う。
EventEmitterが各所に使用されており、これを用いたイベント駆動で処理がされている。

経緯

業務でWebSocketを使用した際に、WebSocketの詳細な仕様がわからずデバッグに時間をかけてしまったことがありました。その時の反省として、WebSocketの仕様についてライブラリレベルで調べていったものになります。

wsライブラリの使い方

使用例:

import WebSocket from 'ws';

const ws = new WebSocket('ws://www.host.com/path');

ws.on('error', console.error);

ws.on('open', function open() {
  ws.send('something');
});

ws.on('message', function message(data) {
  console.log('received: %s', data);
});

通信確立

const ws = new WebSocket('ws://www.host.com/path');

WebSocket通信に関するオブジェクトを作成します。
引数としてwsあるいはwssのschemeを利用したURLを渡し、そのURL先に対して通信を試みます。

イベント発生時のリスナ登録

ws.on('message', function message(data) {

「ws.on()」を使用してイベント発生時のリスナ関数を登録します。
「ws.on()」の第一引数はリスナ関数が発火する「イベント」を指定しています。
errorはエラー発生時に、openを指定すればは通信開始時に、messageを指定すればメッセージ受信時にそれぞれのリスナ関数が発火します。

メッセージ送信

ws.send('something');

「ws.send()」を使用することでWebSocketの接続先にデータを送信します。

WebSocketの概要

WebSocketは以下のように通信します。このライブラリでも同様のステップを踏んでおり、この手順通りに通信を行います。

  1. HTTPリクエストを送信してサーバーと接続する
  2. HTTPレスポンスを受信し、Socketを保持する
  3. 確立されたSocketを介して通信を行う

1. HTTPリクエストを送信してサーバーと接続する

// ライブラリのインポート
const EventEmitter = require('events');
const https = require('https');
const http = require('http');

// オブジェクトを作成
class WebSocket extends EventEmitter {
  constructor(address, protocols, options) {
    super();
    initAsClient(this, address, protocols, options);
  }
  ...
}

function initAsClient(websocket, address, protocols, options) {
  // URLをパースする
  let parsedUrl
  parsedUrl = new URL(address); // addressはコンストラクタの引数
  const isSecure = parsedUrl.protocol === 'wss:';
  const defaultPort = isSecure ? 443 : 80;
  const request = isSecure ? https.request : http.request;

  // HTTPリクエストを送信する
  opts.port = parsedUrl.port || defaultPort;
  opts.host = parsedUrl.hostname.startsWith('[')
    ? parsedUrl.hostname.slice(1, -1)
    : parsedUrl.hostname;
  opts.headers = {
    ...opts.headers,
    'Sec-WebSocket-Version': opts.protocolVersion,
    'Sec-WebSocket-Key': key,
    Connection: 'Upgrade',
    Upgrade: 'websocket'
  };

  let req
  req = request(opts);
...

(簡略化のため一部改変、コメントを追加)

ライブラリのインポート

const EventEmitter = require('events');
const https = require('https');
const http = require('http');

このライブラリはhttpあるいはhttpsライブラリに依存しています。ライブラリ内でそれぞれnettlsを使用しているため、こちらにも依存する形になります。
EventEmitterは「on」等を利用してリスナを登録する仕組みで、WebSocketはこれを継承して使用しています。(詳細は補足参照)

URLのパース

let parsedUrl
parsedUrl = new URL(address); // addressはコンストラクタの引数
const isSecure = parsedUrl.protocol === 'wss:';
const defaultPort = isSecure ? 443 : 80;
const request = isSecure ? https.request : http.request;

引数として渡されたURLをパースします。このときにschemeがwsから始まる場合はhttpを、wssから始まる場合はhttpsをプロトコルとして選択するようになっています。

HTTPリクエストの送信

req = request(opts);

HTTPリクエストを送信します。headerに接続のための情報を載せ、サーバーにリクエストを送信します。

2. HTTPレスポンスを受信し、Socketを保持する

...
  // HTTPレスポンス受信時のリスナ登録
  req.on('upgrade', (res, socket, head) => {
    // websocketはWebSocketオブジェクトのこと。setSocketメソッドを呼び出している
    websocket.setSocket(socket, head, {
      generateMask: opts.generateMask,
      maxPayload: opts.maxPayload,
      skipUTF8Validation: opts.skipUTF8Validation
    });
  });
}

...
// このメソッドはWebSocketオブジェクトのもの。本記事のために場所を入れ替えている
// SocketのWebSocketオブジェクトへのセットとイベントの登録と発火
setSocket(socket, head, options) {
  /**
   * Receiverの実装については省略する。
   * ReceiverはEventEmitterを継承している。
   * (正確にはEventEmitter -> Stream -> Writeableを継承している)
   */
  const receiver = new Receiver({
    binaryType: this.binaryType,
    extensions: this._extensions,
    isServer: this._isServer,
    maxPayload: options.maxPayload,
    skipUTF8Validation: options.skipUTF8Validation
  });
  
  /**
   * Senderの実装については省略する。
   */
  this._sender = new Sender(socket, this._extensions, options.generateMask);
  this._receiver = receiver;
  this._socket = socket;
  
  receiver[kWebSocket] = this;
  socket[kWebSocket] = this;
  
  receiver.on('error', receiverOnError);
  receiver.on('message', receiverOnMessage);
  receiver.on('ping', receiverOnPing);
  receiver.on('pong', receiverOnPong);
  
  socket.on('close', socketOnClose);
  socket.on('data', socketOnData);
  socket.on('end', socketOnEnd);
  socket.on('error', socketOnError);
  
  this._readyState = WebSocket.OPEN;
  this.emit('open');
}

HTTPレスポンス受信時のリスナ登録

req.on('upgrade', (res, socket, head) => {
  // websocketはWebSocketオブジェクトのこと。setSocketメソッドを呼び出している
  websocket.setSocket(socket, head, {
    generateMask: opts.generateMask,
    maxPayload: opts.maxPayload
    skipUTF8Validation: opts.skipUTF8Validation
  });
});

HTTPリクエストのレスポンスが101 upgradeだったときに、その通信に使用していたTCPソケットをそのまま使用します。upgradeイベントが発生したときにTCPSocketをWebSocketオブジェクトにセットする処理を呼び出します。

SocketのWebSocketオブジェクトへのセットとイベントの登録と発火

setSocket(socket, head, options) {
  this._sender = new Sender(socket, this._extensions, options.generateMask);
  this._receiver = receiver;
  this._socket = socket;
  ...
  receiver[kWebSocket] = this;
  socket[kWebSocket] = this;
  ...
  receiver.on('message', receiverOnMessage);
}

ReceiverはEventEmitterを継承していて、「on」や「emit」が可能になっています。これを利用して、イベントとリスナ関数を登録します。
socketもEventEmitterで、このsocketでなんらかのイベントが発生した際にWebSocketのイベントも発火するように設定しています。
Sender, Receiverではそれぞれ受信した・送信するデータをWebSocketの形式に合うように整形する処理が書かれています。今回はここに関する説明は省略します。

3. 確立されたSocketを介して通信を行う

// このメソッドはWebSocketオブジェクトのもの。本記事のために場所を入れ替えている
// データの送信
send(data, options, cb) {
  /**
   * Senderのsendメソッドを呼び出す。
   * Sender内ではDataをFlameにしてsocketを介して送信している。
   * この実装内容については省略する
   */
  this._sender.send(data || EMPTY_BUFFER, opts, cb);
}

// イベント受信時に発火する関数
function receiverOnMessage(data, isBinary) {
  /**
   * WebSocketオブジェクトの「message」イベントを発火させる
   */
  // this[kWebSocket]はWebSocketオブジェクト
  this[kWebSocket].emit('message', data, isBinary);
}

データの送信

this._sender.send(data || EMPTY_BUFFER, opts, cb);

Senderオブジェクトのsendメソッドを発火します。Senderにはsocketが登録されているため、そのsocketを介してデータが送信されます。

イベント受信時に発火する関数

// this[kWebSocket]はWebSocketオブジェクトのこと
this[kWebSocket].emit('message', data, isBinary);

Receiverがデータを受信した際に発火する関数です。「this[kWebSocket]」はWebSocketオブジェクトで、emitメソッドを使用してmessageイベントを発火させます。WebSocketオブジェクトはEventEmitterを継承しているため、messageイベントを「on」していたリスナ関数が実行されます。

感想

「HTTPリクエストで使用したsocketの使い回し」はHTTP1.1以降のみできるらしく、WebSocketがHTTP1.1以降のみで使用可能なのはこの制限が存在するためだそうです。
こういうことを実感できるのも基幹のライブラリだからで、やはり基幹のライブラリを見るのは楽しいなー、と思います!http, https, net, tls, eventsなどのnodeの根幹ライブラリがたくさん使用されていてワクワクしました。
次は何を解析しようか悩みますねー、

補足

EventEmitterについて

const EventEmitter = require('events');

// EventEmitterをインスタンス化
const myEmitter = new EventEmitter();

// イベントリスナーを登録
myEmitter.on('hoge', () => {
  console.log('イベントが発生しました!');
});

// イベントを発生させる
myEmitter.emit('hoge');

EventEmitterはイベント駆動を実現するための仕組みです。
「on」等を利用して特定のイベントに対してリスナを登録し、「emit」でそのイベントを発火させることができます。上の例では、onでhogeイベントに対してリスナを登録し、emitでhogeイベントを発火させています。
EventEmitterは内部にQueueを保持しており、うまく処理されるようになっているらしいです。イベント駆動を実現する手軽な方法であり、ネットワーク・ファイル処理でしばしばみかける気がしています。

関連項目

ライブラリのURL

注意

この情報は2023年4月時点のものになります。古い情報になるかもしれないため注意してください。また、情報が不正確な場合もありますので、参考程度にご利用ください。
今回の記事用にソースコードは大幅に改変してあります。この記事の引用は避け、一時情報にあたるようにお願いいたします。

Discussion