Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Rubyでゼロから学ぶ! シンプルなWebSocketサーバーの自作ガイド

近年、WebSocketはますます注目を集めています。「これこそ未来の技術だ」「Rails 5のActionCableのおかげで、かつてないほど簡単に使える」——そんな話を耳にすることも多いでしょう。しかし、そもそもWebSocketとは何なのでしょうか? そして、どのような仕組みで動いているのでしょうか?

この記事では、こうした疑問に答えるため、Rubyを使ってゼロからシンプルなWebSocketサーバーを構築していきます。最終的には、ブラウザとサーバーの間で双方向通信を実現できるようになります。

本記事のコードはあくまで学習用のものです。実際の本番アプリケーションにWebSocketを実装したい場合は、高品質なwebsocket-ruby gemの利用をおすすめします。WebSocketの公式仕様(RFC 6455)も併せて参照すると理解が深まります。

そもそもWebSocketとは何か

WebSocketは、通常のHTTP接続に内在するいくつかの問題を解決するために生まれました。通常のHTTP接続でWebページをリクエストすると、サーバーはコンテンツを送信した後に接続を切断します。別のページが必要になれば、再度新しい接続を確立しなければなりません。多くの場合これは問題ありませんが、以下のようなユースケースには不向きです。

  • チャットなどのアプリケーションでは、新着メッセージが届いた瞬間にフロントエンドを更新する必要があります。通常のHTTPリクエストしか使えない場合、新しいコンテンツがないかサーバーへポーリング(定期的な問い合わせ)を続けなければなりません。
  • フロントエンドが大量の小さなリクエストをサーバーに送る必要がある場合、リクエストごとに接続を確立するオーバーヘッドがパフォーマンス上のボトルネックになります(HTTP/2ではこの問題は軽減されています)。

WebSocketを使えば、サーバーへの接続を一度確立したら、その接続を開いたまま維持し、双方向の通信に使い続けることができます。

クライアント側の実装

WebSocketは通常、ブラウザとWebサーバー間の通信に使われます。ブラウザ側はJavaScriptで実装します。以下の例は、ローカルサーバーへのWebSocket接続を開き、メッセージを送信するごくシンプルなJavaScriptです。

<!doctype html>
<html lang="en">
<head>
  <title>Websocket Client</title>
</head>
<body>
  <script>
    var exampleSocket = new WebSocket("ws://localhost:2345");
    exampleSocket.onopen = function (event) {
      exampleSocket.send("Can you hear me?");
    };
    exampleSocket.onmessage = function (event) {
      console.log(event.data);
    }
  </script>
</body>
</html>

静的ファイルサーバーを起動してこのファイルをブラウザで開くと、エラーが表示されます。当然ですね。まだサーバーが存在しないのですから。それでは、サーバーを作っていきましょう。

Rubyでゼロから学ぶ! シンプルなWebSocketサーバーの自作ガイド

サーバーの土台を作る

WebSocketは、その始まりとしては普通のHTTPリクエストです。少し変わったライフサイクルを持っています。

  1. ブラウザが、「WebSocketにしてください」と伝える特別なヘッダー付きの通常のHTTPリクエストを送信します。
  2. サーバーは特定の形式のHTTPレスポンスを返しますが、接続を閉じません。
  3. その後、ブラウザとサーバーは開かれたままの接続上で、特殊なWebSocketプロトコルを使ってデータのフレームをやり取りします。

というわけで、最初のステップはWebサーバーを構築することです。以下のコードでは、可能な限りシンプルなWebサーバーを作成しています。実際に何かを配信するわけではなく、リクエストを待ち受けて、その内容をSTDERRに出力するだけです。

require 'socket'

server = TCPServer.new('localhost', 2345)

loop do

  # 接続を待ち受ける
  socket = server.accept
  STDERR.puts "Incoming Request"

  # HTTPリクエストを読み込む。\r\nのみの行が来たら終了とみなす
  http_request = ""
  while (line = socket.gets) && (line != "\r\n")
    http_request += line
  end
  STDERR.puts http_request
  socket.close
end

このサーバーを起動して、先ほどのWebSocketテストページを再読み込みすると、次のような出力が得られます。

$ ruby server1.rb
Incoming Request
GET / HTTP/1.1
Host: localhost:2345
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: cG8zEwcrcLnEftn2qohdKQ==

お気づきでしょうか。このHTTPリクエストには、WebSocketに関連するヘッダーが多数含まれています。実はこれこそが、WebSocketハンドシェイクの第一歩なのです。

ハンドシェイクの仕組み

すべてのWebSocket通信はハンドシェイクから始まります。これは、クライアントとサーバーの双方が「これからWebSocket通信を行うこと」を認識し、プロトコルバージョンについて合意するための手順です。流れは以下の通りです。

クライアントが送るHTTPリクエスト

GET / HTTP/1.1
Host: localhost:2345
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: E4i4gDQc1XTIQcQxvf+ODA==
Sec-WebSocket-Version: 13

このリクエストで最も重要なのはSec-WebSocket-Keyヘッダーです。クライアントは、XSS攻撃やキャッシュプロキシへの対策として、サーバーがこの値を加工して返すことを期待しています。

サーバーからのレスポンス

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: d9WHst60HtB4IvjOVevrexl0oLA=

サーバーレスポンスは、Sec-WebSocket-Acceptヘッダー以外は定型文です。このヘッダーは次のように生成されます。

# クライアントから提供された値にマジック文字列を連結し、
# SHA1ハッシュを生成してbase64エンコードする。
Digest::SHA1.base64digest([sec_websocket_accept, "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"].join)

見間違いではありませんよ。ここには本当に「マジック定数」が登場します。

ハンドシェイクの実装

それでは、ハンドシェイクを完了させるようサーバーを更新しましょう。まず、リクエストヘッダーからセキュリティトークンを取り出します。

# ヘッダーからセキュリティキーを取得する。
# 存在しない場合は接続を閉じる。
if matches = http_request.match(/^Sec-WebSocket-Key: (\S+)/)
  websocket_key = matches[1]
  STDERR.puts "Websocket handshake detected with key: #{ websocket_key }"
else
  STDERR.puts "Aborting non-websocket connection"
  socket.close
  next
end

次に、このセキュリティキーを使って有効なレスポンスを生成します。

response_key = Digest::SHA1.base64digest([websocket_key, "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"].join)
STDERR.puts "Responding to handshake with key: #{ response_key }"

socket.write <<-eos
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: #{ response_key }

eos

STDERR.puts "Handshake completed."

WebSocketテストページを再読み込みすると、接続エラーが消えていることがわかります。接続が確立されたのです!

Rubyでゼロから学ぶ! シンプルなWebSocketサーバーの自作ガイド

サーバー側の出力を見ると、セキュリティキーとレスポンス用キーが確認できます。

$ ruby server2.rb
Incoming Request
Websocket handshake detected with key: Fh06+WnoTQQiVnX5saeYMg==
Responding to handshake with key: nJg1c2upAHixOmXz7kV2bJ2g/YQ=
Handshake completed.

WebSocketフレームプロトコル

WebSocket接続が確立されると、以降はHTTPは使われなくなります。代わりに、データはWebSocketプロトコル経由でやり取りされます。

フレーム(Frame)は、WebSocketプロトコルにおける基本単位です。

WebSocketプロトコルはフレームベースですが、これはどういう意味なのでしょうか?

ブラウザにWebSocket経由でデータ送信を指示するときも、サーバーがレスポンスを返すときも、データは一連のチャンク(塊)に分割され、それぞれのチャンクがメタデータで包まれて「フレーム」になります。

フレームの構造は下図の通りです。上部の数字はビット位置を表し、拡張ペイロード長のように常には存在しないフィールドもあります。

0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len |    Extended payload length    |
|I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
|N|V|V|V|       |S|             |   (if payload len==126/127)   |
| |1|2|3|       |K|             |                               |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
|     Extended payload length continued, if payload len == 127  |
+ - - - - - - - - - - - - - - - +-------------------------------+
|                               |Masking-key, if MASK set to 1  |
+-------------------------------+-------------------------------+
| Masking-key (continued)       |          Payload Data         |
+-------------------------------- - - - - - - - - - - - - - - - +
:                     Payload Data continued ...                :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
|                     Payload Data continued ...                |
+---------------------------------------------------------------+

まず目を引くのは、これがバイナリプロトコルだということです。ビット操作が必要になりますが、心配いりません。それほど難しくはありません。図の上部の数字はビット数を示しており、一部のフィールドは常に存在するとは限りません。たとえば拡張ペイロード長フィールドは、ペイロードが127バイト未満の場合には現れません。

データを受信する

ハンドシェイクが完了したので、バイナリフレームの解析を始められます。話を簡単にするため、まずは受信フレームを1バイトずつ見ていきましょう。その後、すべてを組み合わせて実際の動作を確認します。

第1バイト: FINとOpcode

先ほどの表からわかるように、最初のバイト(先頭8ビット)には複数の情報が含まれています。

  • FIN: 1ビット — falseの場合、メッセージは複数のフレームに分割されています。
  • opcode: 4ビット — ペイロードがテキストかバイナリか、あるいは接続維持のための「ping」かを示します。
  • RSV: 3ビット — 現行のWebSocket仕様では未使用です。

最初のバイトはIO#getbyteメソッドで取得し、データの抽出にはシンプルなビットマスクを使用します。ビット演算子に馴染みがない方は、関連記事「Bitwise hacks in Ruby」などを参考にしてください。

first_byte = socket.getbyte
fin = first_byte & 0b10000000
opcode = first_byte & 0b00001111

# 本サーバーはシングルフレームのテキストメッセージのみサポートする。
# それ以外が送られてきた場合は例外を発生させる。
raise "We don't support continuations" unless fin
raise "We only support opcode 1" unless opcode == 1

第2バイト: MASKとペイロード長

フレームの第2バイトには、ペイロードに関する追加情報が含まれます。

  • MASK: 1ビット — ペイロードがマスクされているかどうかを示す真偽フラグです。trueの場合、使用前にペイロードを「アンマスク」する必要があります。クライアントから送られるフレームでは、仕様により必ずtrueになります。
  • payload length: 7ビット — ペイロードが126バイト未満なら、長さがここに格納されます。この値が126以上の場合、続く追加バイトで長さが表されます。

第2バイトの処理は次のようになります。

second_byte = socket.getbyte
is_masked = second_byte & 0b10000000
payload_size = second_byte & 0b01111111

raise "All frames sent to a server should be masked according to the websocket spec" unless is_masked
raise "We only support payloads < 126 bytes in length" unless payload_size < 126

STDERR.puts "Payload size: #{ payload_size } bytes"

第3〜7バイト: マスキングキー

受信フレームのペイロードはすべてマスクされていると想定します。内容をアンマスクするには、マスキングキーとのXOR演算が必要です。

このマスキングキーは、続く4バイトで構成されています。特別な処理は不要で、単純に配列として読み込むだけでOKです。

mask = 4.times.map { socket.getbyte }
STDERR.puts "Got mask: #{ mask.inspect }"

4バイトを配列に読み込むより良い方法をご存知でしたら教えてください。times.mapは少々奇妙ですが、私が思いつく中で最も簡潔な方法でした。(Twitter: @StarrHorne)

第8バイト以降: ペイロード

さて、メタデータの処理は完了です。ここからが実際のペイロードの取得です。

data = payload_size.times.map { socket.getbyte }
STDERR.puts "Got masked data: #{ data.inspect }"

このペイロードはマスクされているため、そのまま出力すると意味不明な文字列に見えます。アンマスクするには、各バイトをマスクの対応するバイトとXOR演算します。マスクは4バイトしかないので、ペイロードの長さに合わせて繰り返し適用します。

unmasked_data = data.each_with_index.map { |byte, i| byte ^ mask[i % 4] }
STDERR.puts "Unmasked the data: #{ unmasked_data.inspect }"

これでバイト配列が得られました。次に、これをUnicode文字列に変換します。WebSocket上のすべてのテキストはUnicodeです。

STDERR.puts "Converted to a string: #{ unmasked_data.pack('C*').force_encoding('utf-8').inspect }"

全体を組み合わせる

ここまでのコードをすべて組み合わせると、次のようなスクリプトになります。

require 'socket' # TCPServer / TCPSocket クラスを提供
require 'digest/sha1'

server = TCPServer.new('localhost', 2345)

loop do

  # 接続を待ち受ける
  socket = server.accept
  STDERR.puts "Incoming Request"

  # HTTPリクエストを読み込む。\r\nのみの行が来たら終了とみなす
  http_request = ""
  while (line = socket.gets) && (line != "\r\n")
    http_request += line
  end

  # ヘッダーからセキュリティキーを取得。存在しなければ接続を閉じる
  if matches = http_request.match(/^Sec-WebSocket-Key: (\S+)/)
    websocket_key = matches[1]
    STDERR.puts "Websocket handshake detected with key: #{ websocket_key }"
  else
    STDERR.puts "Aborting non-websocket connection"
    socket.close
    next
  end

  response_key = Digest::SHA1.base64digest([websocket_key, "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"].join)
  STDERR.puts "Responding to handshake with key: #{ response_key }"

  socket.write <<-eos
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: #{ response_key }

  eos

  STDERR.puts "Handshake completed. Starting to parse the websocket frame."

  first_byte = socket.getbyte
  fin = first_byte & 0b10000000
  opcode = first_byte & 0b00001111

  raise "We don't support continuations" unless fin
  raise "We only support opcode 1" unless opcode == 1

  second_byte = socket.getbyte
  is_masked = second_byte & 0b10000000
  payload_size = second_byte & 0b01111111

  raise "All incoming frames should be masked according to the websocket spec" unless is_masked
  raise "We only support payloads < 126 bytes in length" unless payload_size < 126

  STDERR.puts "Payload size: #{ payload_size } bytes"

  mask = 4.times.map { socket.getbyte }
  STDERR.puts "Got mask: #{ mask.inspect }"

  data = payload_size.times.map { socket.getbyte }
  STDERR.puts "Got masked data: #{ data.inspect }"

  unmasked_data = data.each_with_index.map { |byte, i| byte ^ mask[i % 4] }
  STDERR.puts "Unmasked the data: #{ unmasked_data.inspect }"

  STDERR.puts "Converted to a string: #{ unmasked_data.pack('C*').force_encoding('utf-8').inspect }"

  socket.close
end

WebSocketテストページを再読み込みしてサーバーにリクエストを送ると、次のような出力が得られます。

$ ruby websocket_server.rb
Incoming Request
Websocket handshake detected with key: E4i4gDQc1XTIQcQxvf+ODA==
Responding to handshake with key: d9WHst60HtB4IvjOVevrexl0oLA=
Handshake completed. Starting to parse the websocket frame.
Payload size: 16 bytes
Got mask: [80, 191, 161, 254]
Got masked data: [19, 222, 207, 222, 41, 208, 212, 222, 56, 218, 192, 140, 112, 210, 196, 193]
Unmasked the data: [67, 97, 110, 32, 121, 111, 117, 32, 104, 101, 97, 114, 32, 109, 101, 63]
Converted to a string: "Can you hear me?"

クライアントへデータを返信する

これで、クライアントからおもちゃのWebSocketサーバーへテストメッセージを送信できました。今度は、サーバーからクライアントへメッセージを返してみましょう。

こちらはやや簡単です。マスク処理を一切扱う必要がないからです。サーバーからクライアントへ送られるフレームは、常にマスクされません。

受信時に1バイトずつ読み取ったのと同じ要領で、送信時にも1バイトずつフレームを構築していきます。

第1バイト: FINとOpcode

今回のペイロードは1つのフレームに収まり、種類はテキストです。つまりFINは1、opcodeも1になります。以前と同じビット形式でこれらを組み合わせると、次の数値が得られます。

output = [0b10000001]

第2バイト: MASKEDとペイロード長

このフレームはサーバーからクライアントへ向かうものなので、MASKEDはゼロです。つまり無視して問題ありません。ペイロード長は単純に文字列の長さです。

output = [0b10000001, response.size]

第3バイト以降: ペイロード

ペイロードはマスクされず、ただの文字列です。

response = "Loud and clear!"
STDERR.puts "Sending response: #{ response.inspect }"

output = [0b10000001, response.size, response]

送信!

これで、送信したいデータを含む配列ができました。あとは、これをネットワーク上に送れるバイト列に変換する必要があります。そこで活躍するのが、非常に万能なArray#packメソッドです。

socket.write output.pack("CCA#{ response.size }")

"CCA#{ response.size }"という奇妙な文字列は、「配列には8ビット符号なし整数が2つ続き、その後に指定サイズの文字列が含まれる」ことをArray#packに伝えています。

Chromeのネットワークインスペクターを開くと、メッセージがしっかり届いているのが確認できます。

Rubyでゼロから学ぶ! シンプルなWebSocketサーバーの自作ガイド

さらなる発展課題

以上で完成です! この記事を通じて、WebSocketについて何か新しい発見があったなら幸いです。今回のサーバーにはまだ足りない機能がたくさんあります。練習の続きとして、以下の実装に挑戦してみてはいかがでしょうか。

  • 複数フレームにまたがるペイロードのサポート
  • バイナリペイロードのサポート
  • Ping/Pong(接続維持)のサポート
  • 長大なペイロードのサポート
  • クロージングハンドシェイク(接続終了処理)
  1. 最初のWebスクレイパーの構築、パート3

    Webスクレイパーの構築に関するこのシリーズへようこそ。このチュートリアルでは、自分のポッドキャストサイトからデータをスクレイピングする例を紹介します。データを抽出する方法、ヘルパーメソッドとユーティリティメソッドがどのように仕事を遂行するか、すべてのパズルのピースがどのように組み合わされるかについて詳しく説明します。 トピック ポッドキャストをスクレイピングする こじ開ける スクレーパー ヘルパーメソッド 投稿を書く ポッドキャストのスクレイピング これまでに学んだことを実践してみましょう。さまざまな理由で、ポッドキャストの再設計を行いました。画面は長い間延期されていました

  2. Fallout 76で「サーバーから切断される」エラーを解決する6つの対処法

    Fallout 76は、Bethesda Studiosが2018年にリリースした人気のマルチプレイRPGアクションゲームです。Windows PC、Xbox One、PlayStation 4向けに提供されており、Falloutシリーズが好きな方ならきっと楽しめる作品でしょう。しかし、多くのプレイヤーから「PCでゲームを起動しようとした際に、Fallout 76がサーバーから切断される」というエラーが報告されています。Bethesda Studiosによると、この問題はサーバーへの過負荷、つまり多数のプレイヤーが同時にアクセスしたことが原因であるとされています。ただし、同じエラーに悩まされて