2022年10月14日

WebSocket

仕様RFC 6455で記述されているWebSocketプロトコルは、永続的な接続を介してブラウザとサーバー間でデータ交換を行う方法を提供します。データは「パケット」として双方向に渡すことができ、接続を切断したり、追加のHTTPリクエストを必要とすることなく行えます。

WebSocketは、オンラインゲーム、リアルタイム取引システムなど、継続的なデータ交換を必要とするサービスに特に適しています。

簡単な例

WebSocket接続を開くには、URLに特別なプロトコルwsを使用してnew WebSocketを作成する必要があります。

let socket = new WebSocket("ws://javascriptinfo.dokyumento.jp");

暗号化されたwss://プロトコルもあります。これは、WebSocketのHTTPSのようなものです。

常にwss://を優先してください。

wss://プロトコルは暗号化されているだけでなく、より信頼性も高いです。

これは、ws://データは暗号化されておらず、中間者に対して可視化されるためです。古いプロキシサーバーはWebSocketについて認識しておらず、「奇妙な」ヘッダーを見て接続を中止することがあります。

一方、wss://はTLS上のWebSocket(HTTPSがTLS上のHTTPであるのと同様)であり、トランスポートセキュリティレイヤーは送信者側でデータを暗号化し、受信者側で復号化します。そのため、データパケットはプロキシを介して暗号化されて渡されます。プロキシは内部の内容を見ることができず、通過させます。

ソケットが作成されたら、そのイベントをリッスンする必要があります。合計で4つのイベントがあります。

  • open – 接続が確立されました。
  • message – データを受信しました。
  • error – WebSocketエラーが発生しました。
  • close – 接続が閉じられました。

…何かを送信したい場合は、socket.send(data)を使用します。

例を以下に示します。

let socket = new WebSocket("wss://javascriptinfo.dokyumento.jp/article/websocket/demo/hello");

socket.onopen = function(e) {
  alert("[open] Connection established");
  alert("Sending to server");
  socket.send("My name is John");
};

socket.onmessage = function(event) {
  alert(`[message] Data received from server: ${event.data}`);
};

socket.onclose = function(event) {
  if (event.wasClean) {
    alert(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
  } else {
    // e.g. server process killed or network down
    // event.code is usually 1006 in this case
    alert('[close] Connection died');
  }
};

socket.onerror = function(error) {
  alert(`[error]`);
};

デモのために、上記の例を実行するためのNode.jsで記述された小さなサーバーserver.jsがあります。「Hello from server, John」で応答し、5秒間待ってから接続を閉じます。

そのため、openmessagecloseイベントが表示されます。

実際にはこれで、すでにWebSocketで通信できます。非常に簡単ですね?

それでは、より詳細に見ていきましょう。

WebSocketを開く

new WebSocket(url)が作成されると、すぐに接続を開始します。

接続中は、ブラウザ(ヘッダーを使用して)サーバーに「WebSocketをサポートしていますか?」と問い合わせます。サーバーが「はい」と応答すると、会話はまったくHTTPではないWebSocketプロトコルで続行されます。

new WebSocket("wss://javascriptinfo.dokyumento.jp/chat")によって行われたリクエストのブラウザヘッダーの例を次に示します。

GET /chat
Host: javascript.info
Origin: https://javascriptinfo.dokyumento.jp
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q==
Sec-WebSocket-Version: 13
  • Origin – クライアントページのオリジン(例:https://javascriptinfo.dokyumento.jp)。WebSocketオブジェクトは本質的にクロスオリジンです。特別なヘッダーやその他の制限はありません。古いサーバーはとにかくWebSocketを処理できないため、互換性の問題は発生しません。しかし、Originヘッダーは、サーバーがこのウェブサイトとWebSocketで通信するかどうかを決定できるようにするため重要です。
  • Connection: Upgrade – クライアントがプロトコルを変更したいことを示します。
  • Upgrade: websocket – 要求されたプロトコルは「websocket」です。
  • Sec-WebSocket-Key – サーバーがWebSocketプロトコルをサポートしていることを確認するために使用される、ブラウザによって生成されたランダムなキーです。プロキシが後続の通信をキャッシュするのを防ぐためにランダムです。
  • Sec-WebSocket-Version – WebSocketプロトコルバージョン。13が現在のバージョンです。
WebSocketハンドシェイクはエミュレートできません

JavaScriptはこれらのヘッダーを設定できないため、XMLHttpRequestまたはfetchを使用してこの種のHTTPリクエストを行うことはできません。

サーバーがWebSocketへの切り替えに同意した場合、コード101の応答を送信する必要があります。

101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g=

ここで、Sec-WebSocket-Acceptは、特別なアルゴリズムを使用して再コード化されたSec-WebSocket-Keyです。これを見ると、ブラウザはサーバーが実際にWebSocketプロトコルをサポートしていることを理解します。

その後、データはWebSocketプロトコルを使用して転送されます。その構造(「フレーム」)はすぐにわかります。そしてそれはまったくHTTPではありません。

拡張機能とサブプロトコル

拡張機能とサブプロトコルを記述する追加ヘッダーSec-WebSocket-ExtensionsおよびSec-WebSocket-Protocolがある場合があります。

例えば

  • Sec-WebSocket-Extensions: deflate-frameは、ブラウザがデータ圧縮をサポートしていることを意味します。拡張機能は、データ転送に関連するものであり、WebSocketプロトコルを拡張する機能です。ヘッダーSec-WebSocket-Extensionsは、サポートされているすべての拡張機能のリストとともに、ブラウザによって自動的に送信されます。

  • Sec-WebSocket-Protocol: soap, wampは、任意のデータだけでなく、SOAPまたはWAMP(「WebSocket Application Messaging Protocol」)プロトコルでデータを転送したいことを意味します。WebSocketサブプロトコルは、IANAカタログに登録されています。そのため、このヘッダーは使用するデータ形式を記述しています。

    このオプションのヘッダーは、new WebSocketの2番目のパラメーターを使用して設定されます。これはサブプロトコルの配列です(例:SOAPまたはWAMPを使用する場合)。

    let socket = new WebSocket("wss://javascriptinfo.dokyumento.jp/chat", ["soap", "wamp"]);

サーバーは、使用するのに同意したプロトコルと拡張機能のリストで応答する必要があります。

たとえば、リクエスト

GET /chat
Host: javascript.info
Upgrade: websocket
Connection: Upgrade
Origin: https://javascriptinfo.dokyumento.jp
Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q==
Sec-WebSocket-Version: 13
Sec-WebSocket-Extensions: deflate-frame
Sec-WebSocket-Protocol: soap, wamp

レスポンス

101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g=
Sec-WebSocket-Extensions: deflate-frame
Sec-WebSocket-Protocol: soap

ここで、サーバーは「deflate-frame」拡張機能と、要求されたサブプロトコルのSOAPのみをサポートしていることを応答します。

データ転送

WebSocket通信は「フレーム」つまりデータフラグメントで構成され、どちらの側からも送信でき、いくつかの種類があります。

  • 「テキストフレーム」– 相互に送信するテキストデータが含まれています。
  • 「バイナリデータフレーム」– 相互に送信するバイナリデータが含まれています。
  • 「ping/pongフレーム」は接続を確認するために使用され、サーバーから送信され、ブラウザはこれらに自動的に応答します。
  • 「接続クローズフレーム」とその他のいくつかのサービスフレームもあります。

ブラウザでは、テキストフレームまたはバイナリフレームのみを直接操作します。

WebSocketの.send()メソッドは、テキストデータまたはバイナリデータのいずれかを送信できます。

socket.send(body)の呼び出しでは、BlobArrayBufferなどを含む文字列またはバイナリ形式でbodyを許可します。設定は必要ありません。どのような形式でも送信できます。

データを受信すると、テキストは常に文字列として表示されます。バイナリデータの場合、BlobArrayBufferの形式から選択できます。

これはsocket.binaryTypeプロパティで設定され、デフォルトでは"blob"であるため、バイナリデータはBlobオブジェクトとして表示されます。

Blobは高レベルのバイナリオブジェクトであり、<a><img>などのタグと直接統合されているため、適切なデフォルトです。ただし、個々のデータバイトにアクセスするには、バイナリ処理のために"arraybuffer"に変更できます。

socket.binaryType = "arraybuffer";
socket.onmessage = (event) => {
  // event.data is either a string (if text) or arraybuffer (if binary)
};

レート制限

アプリが大量のデータを送信していると想像してください。しかし、ユーザーのネットワーク接続が遅く、モバイルインターネットを使用していて、都市の外にいるかもしれません。

socket.send(data)を何度も呼び出すことができます。しかし、データはメモリにバッファリング(保存)され、ネットワーク速度が許す限り速く送信されます。

socket.bufferedAmountプロパティは、現在ネットワーク経由で送信されるのを待っているバッファリングされたバイト数を保存します。

これを調べて、ソケットが実際に送信可能かどうかを確認できます。

// every 100ms examine the socket and send more data
// only if all the existing data was sent out
setInterval(() => {
  if (socket.bufferedAmount == 0) {
    socket.send(moreData());
  }
}, 100);

接続クローズ

通常、一方の当事者が接続を閉じたい場合(ブラウザとサーバーの両方に同等の権利があります)、数値コードとテキストの理由を含む「接続クローズフレーム」を送信します。

そのためのメソッドは次のとおりです。

socket.close([code], [reason]);
  • codeは特別なWebSocketクローズコード(オプション)です。
  • reasonは、クローズの理由を説明する文字列です(オプション)。

その後、closeイベントハンドラー内のもう一方の当事者は、コードと理由を取得します(例:)。

// closing party:
socket.close(1000, "Work complete");

// the other party
socket.onclose = event => {
  // event.code === 1000
  // event.reason === "Work complete"
  // event.wasClean === true (clean close)
};

最も一般的なコード値

  • 1000 – デフォルトの通常のクローズ(codeが指定されていない場合に使用)。
  • 1006 – このようなコードを手動で設定する方法がなく、接続が失われたことを示します(クローズフレームはありません)。

他にも次のようなコードがあります。

  • 1001 – 当事者が離れています(例:サーバーがシャットダウンしている、またはブラウザがページを離れている)。
  • 1009 – メッセージが大きすぎて処理できません。
  • 1011 – サーバーで予期しないエラーが発生しました。
  • …など。

完全なリストはRFC6455、§7.4.1にあります。

WebSocketコードはHTTPコードに似ていますが、異なります。特に、1000未満のコードは予約されており、このようなコードを設定しようとするとエラーが発生します。

// in case connection is broken
socket.onclose = event => {
  // event.code === 1006
  // event.reason === ""
  // event.wasClean === false (no closing frame)
};

接続状態

接続状態を取得するために、値を持つsocket.readyStateプロパティもあります。

  • 0 – “接続中”: 接続はまだ確立されていません。
  • 1 – “オープン”: 通信中
  • 2 – “クローズ中”: 接続を閉じている最中です。
  • 3 – “クローズ”: 接続が閉じられました。

チャットの例

ブラウザのWebSocket APIとNode.jsのWebSocketモジュールhttps://github.com/websockets/wsを使ったチャットの例を確認しましょう。クライアントサイドに焦点を当てますが、サーバーサイドも簡単です。

HTML: メッセージを送信するための<form>と、受信メッセージを表示するための<div>が必要です。

<!-- message form -->
<form name="publish">
  <input type="text" name="message">
  <input type="submit" value="Send">
</form>

<!-- div with messages -->
<div id="messages"></div>

JavaScriptでは、次の3つの処理を行います。

  1. 接続を開く。
  2. フォーム送信時に – メッセージに対してsocket.send(message)を実行する。
  3. 受信メッセージ時に – それをdiv#messagesに追加する。

コードはこちらです。

let socket = new WebSocket("wss://javascriptinfo.dokyumento.jp/article/websocket/chat/ws");

// send message from the form
document.forms.publish.onsubmit = function() {
  let outgoingMessage = this.message.value;

  socket.send(outgoingMessage);
  return false;
};

// message received - show the message in div#messages
socket.onmessage = function(event) {
  let message = event.data;

  let messageElem = document.createElement('div');
  messageElem.textContent = message;
  document.getElementById('messages').prepend(messageElem);
}

サーバーサイドのコードは、今回の範囲を超えています。ここではNode.jsを使用しますが、必須ではありません。他のプラットフォームでもWebSocketを使用する手段があります。

サーバーサイドのアルゴリズムは以下のようになります。

  1. clients = new Set() – ソケットの集合を作成する。
  2. 受け入れた各websocketを集合clients.add(socket)に追加し、メッセージを受信するためのmessageイベントリスナーを設定する。
  3. メッセージを受信したら: クライアントを反復処理し、全員に送信する。
  4. 接続が閉じられたら: clients.delete(socket)を実行する。
const ws = new require('ws');
const wss = new ws.Server({noServer: true});

const clients = new Set();

http.createServer((req, res) => {
  // here we only handle websocket connections
  // in real project we'd have some other code here to handle non-websocket requests
  wss.handleUpgrade(req, req.socket, Buffer.alloc(0), onSocketConnect);
});

function onSocketConnect(ws) {
  clients.add(ws);

  ws.on('message', function(message) {
    message = message.slice(0, 50); // max message length will be 50

    for(let client of clients) {
      client.send(message);
    }
  });

  ws.on('close', function() {
    clients.delete(ws);
  });
}

動作例はこちらです。

iframe内の右上ボタンからダウンロードして、ローカルで実行することもできます。実行する前に、Node.jsをインストールし、npm install wsを実行することを忘れないでください。

概要

WebSocketは、ブラウザとサーバー間の永続的な接続を確立するための現代的な方法です。

  • WebSocketは、クロスオリジン制限がありません。
  • ブラウザで広くサポートされています。
  • 文字列とバイナリデータの送受信が可能です。

APIはシンプルです。

メソッド

  • socket.send(data),
  • socket.close([code], [reason]).

イベント

  • open,
  • message,
  • error,
  • close.

WebSocket自体は、再接続、認証、その他の多くの高レベルなメカニズムを含んでいません。そのため、それらのためのクライアント/サーバーライブラリが存在し、これらの機能を手動で実装することも可能です。

既存のプロジェクトにWebSocketを統合するために、メインのHTTPサーバーと並列でWebSocketサーバーを実行し、単一のデータベースを共有することがあります。WebSocketへのリクエストはwss://ws.site.com(WebSocketサーバーにつながるサブドメイン)を使用し、https://site.comはメインのHTTPサーバーにつながります。

もちろん、他の統合方法も可能です。

チュートリアルマップ

コメント

コメントする前にこれを読んでください…
  • 改善点があれば、コメントする代わりにGitHub issueまたはプルリクエストを送信してください。
  • 記事の内容が理解できない場合は、詳しく説明してください。
  • 数行のコードを挿入するには<code>タグを使用し、複数行の場合は<pre>タグで囲み、10行を超える場合は、サンドボックス(plnkrjsbincodepen…)を使用してください。