WebSocketライブラリを解析してみた
概要・学んだこと
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は以下のように通信します。このライブラリでも同様のステップを踏んでおり、この手順通りに通信を行います。
- HTTPリクエストを送信してサーバーと接続する
- HTTPレスポンスを受信し、Socketを保持する
- 確立された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
ライブラリに依存しています。ライブラリ内でそれぞれnet
とtls
を使用しているため、こちらにも依存する形になります。
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を保持しており、うまく処理されるようになっているらしいです。イベント駆動を実現する手軽な方法であり、ネットワーク・ファイル処理でしばしばみかける気がしています。
関連項目
注意
この情報は2023年4月時点のものになります。古い情報になるかもしれないため注意してください。また、情報が不正確な場合もありますので、参考程度にご利用ください。
今回の記事用にソースコードは大幅に改変してあります。この記事の引用は避け、一時情報にあたるようにお願いいたします。
Discussion