🖥️

RubyでシンプルなWebSocketサーバーをゼロからつくってみた

2024/02/10に公開

WebSocketについて

WebSocketはHTTP接続が持ついくつかの問題を解決するために発明されたプロトコルです。
例えば通常のHTTPでは、ページをリクエストするたびに接続が閉じてしまいます。
これではチャットなどリアルタイム更新が必要なアプリでは非効率ですね。
また、HTTPリクエストの継続的なポーリングや小さなリクエストの多用による接続のオーバーヘッドも問題となります。
WebSocketでは、サーバーとの間に一度開設した接続を維持し、双方向通信を実現します。

それではそんな素敵なWebSocket通信ができるサーバーを実装していきましょう。
実装したコードはこちら

クライアント側の実装

index.html
<!doctype html>
<html lang="en">
<head>
  <title>Websocket Client</title>
  <meta charset="utf-8">
</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>

今回クライアントのページを表示するためのサーバーはwebrickを使用することにします。

webrick.rb
require 'webrick'
srv = WEBrick::HTTPServer.new({ :DocumentRoot => './',
                                :BindAddress => '127.0.0.1',
                                :Port => 4000})
srv.mount('/', WEBrick::HTTPServlet::FileHandler, 'index.html')
trap("INT"){ srv.shutdown }
srv.start

サーバー側の実装

server1.ruby
require 'socket'

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

loop do
  # 接続を待機
  socket = server.accept
  STDERR.puts "リクエストが来ました!"

  # HTTPリクエストを読み込む。\r\nの行によって終了を検知する
  http_request = ""
  while (line = socket.gets) && (line != "\r\n")
    http_request += line
  end
  STDERR.puts http_request
  socket.close
end

ここまで実装してサーバーを起動し、ブラウザでlocalhost:4000にアクセスすると以下のようなログが得られます。

$ ruby server1.rb
リクエストが来ました!
GET / HTTP/1.1
Host: localhost:2345
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: JMr/bZ++RdqeKBat9tueXA==

ここでヘッダーの中にWebSocket関連のものがあることが確認できますね。
Sec-WebSocket-VersionSec-WebSocket-Keyが含まれています。


一方でブラウザのコンソールにはエラーが表示されています。なぜでしょう?
それはまだ現時点でハンドシェイクの実装が不完全であるからです。

WebSocket opening ハンドシェイク

すべてのWebSocketリクエストは最初にHTTP通信を使用してハンドシェイクを行います。
HTTP/1.1の場合ではハンドシェイクは以下の手順で行われます。(HTTP/2ではまた異なります)

クライアントからのリクエスト

GET / HTTP/1.1
Host: localhost:2345
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: JMr/bZ++RdqeKBat9tueXA==

先程のログの内容です。
ここで重要なのはSec-WebSocket-Keyヘッダーです。
こちらはランダムに選ばれた16バイトの文字列で、クライアントはXSS攻撃などを防ぐためにこの値を使用します。

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

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: baJ+oBd+wagKP+vsqPaCpD+Rdv4=

Sec-WebSocket-Acceptヘッダーがありますね。
これはクライアントから受け取ったSec-WebSocket-Keyをある定数(salt)と共にSHA1ハッシュを計算し、それをBase64エンコードしたものです。
具体的には以下のような処理をします。

Digest::SHA1.base64digest([sec_websocket_accept, "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"].join)

そう。ある定数とは258EAFA5-E914-47DA-95CA-C5AB0DC85B11です。

258EAFA5-E914-47DA-95CA-C5AB0DC85B11のIDとは?

WebSocketsプロトコルにおいて、クライアントからの初期接続要求(Opening Handshake)に使用される固定のGUID(Globally Unique Identifier)です。
この文字列は、クライアントがサーバーに接続要求を送る際、Sec-WebSocket-Keyヘッダーによって送信された値と組み合わせて使用されます。

これまで実装したサーバーではハンドシェイクが実装されていませんね。
よってこれから実装していきます。

サーバー側の実装2(ハンドシェイク)

server2.rb
require 'socket'
require 'digest/sha1'

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

loop do
  # 接続を待機
  socket = server.accept
  STDERR.puts "リクエストが来ました!"

  # HTTPリクエストを読み込む。\r\nの行によって終了を検知する
  http_request = ""
  while (line = socket.gets) && (line != "\r\n")
    http_request += line
  end

  STDERR.puts http_request

  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 }"

  handshake_response = [
    "HTTP/1.1 101 Switching Protocols",
    "Upgrade: websocket",
    "Connection: Upgrade",
    "Sec-WebSocket-Accept: #{response_key}",
    "\r\n"
  ].join("\r\n")

  socket.write(handshake_response)

  STDERR.puts "Handshake completed."

  socket.close
end

ここまで実装したら再度ブラウザを更新してみます。
するとコンソールのエラーが消えて接続が確立されるはずです。

サーバーのログには以下のように表示されます。

$ ruby server2.rb
リクエストが来ました!
Websocket handshake detected with key: JMr/bZ++RdqeKBat9tueXA==
Responding to handshake with key: baJ+oBd+wagKP+vsqPaCpD+Rdv4=
Handshake completed.

WebSocketフレームプロトコル

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

以下はWebSocketプロトコルのフレーム構造です。
拡張ペイロードなど常に存在するとは限らないものも含まれます。

0                   1                   2                   3
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 ...                |
+---------------------------------------------------------------+

ここまででハンドシェイクの確立まで実装したので、これから上記のバイナリフレームの解析を実装します。

バイナリフレームの解析実装(クライアントからサーバーへの通信)

まず最初の8ビットを見てみましょう

1バイト目:FINとopecode

上の表から、最初の1バイト(8ビット)に下記のデータが含まれていることがわかります。

  • FIN
    • 1ビット長
    • これがfalseの場合は、メッセージが複数のフレームに分割されていることを意味します。
  • RSV
    • 3ビット長
    • これらは現在のWebSocket仕様では使われていません。
  • opecode
    • 4ビット長
    • payloadがテキストであるか、バイナリか、または接続を維持するためのpingであるかを示します。
      • テキストの場合は1となります。(今回はテキストとして実装します)

1バイト目を取得するためにRubyのIO#getbyteメソッドを使用します。

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

# このサーバーは、単一フレームのテキストメッセージにのみ対応しています。
# クライアントがそれ以外のものを送信しようとした場合は例外を発生させます。
raise "このサーバーは複数フレームには非対応です" unless fin
raise "このサーバーではopcode 1(テキスト)のみに対応してます" unless opcode == 1

2バイト目:MASKとPayload len

フレームの2バイト目にはペイロードに関する詳細情報が含まれています。

  • MASK
    • 1ビット長
    • ペイロードがマスクされているかどうかを示す1ビットのbool値フラグです。
    • trueの場合はペイロードの「マスク解除」が必要になります。
    • クライアントから受信するフレームは仕様上、常にtrueとなります。
  • Payload len
    • 7ビット長
    • ペイロードが126バイト未満である場合、長さはここに格納されます。
    • この値が126バイト以上65535バイト(2^16-1バイト)以下の場合、Payload lenフィールドは126に設定され、直後に2バイトの拡張ペイロードフィールドが続きます。この拡張ペイロードフィールドがペイロードの長さを表します。
    • ペイロードの長さが65536バイト(2^16バイト)を超える場合、Payload lenフィールドは127に設定され、直後に8バイトの拡張ペイロードフィールドが続きます。この拡張ペイロードフィールドがペイロードの実際の長さを表します。

今回実装するサーバーはペイロードの長さが126バイト未満のみに対応します。
2バイト目の実装は以下のようになります。

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

raise "サーバーに送信されるすべてのフレームは、WebSocketの仕様に従ってマスクされる必要があります" unless is_masked
raise "ペイロードの長さは126バイト未満のみサポートします" unless payload_size < 126

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

3~7バイト目:masking key

MASKがtrueの場合、すべての受信フレームのペイロードがマスクされていることを意味します。
受け取ったコンテンツのマスクを解除するためにmasking keyに対してXOR演算を実行しなければなりません。

このmasking keyは次の4バイトを構成します。この実装はバイトを配列に読み取るだけです。

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

8バイト目以降:Payload

これでメタデータの作成は完了です。これで実際のペイロードを取得することができます。

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 }"

ここまでのサーバーの実装

require 'socket'
require 'digest/sha1'

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

loop do
  # 接続を待機
  socket = server.accept
  STDERR.puts "リクエストが来ました!"

  # 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 }"

  handshake_response = [
    "HTTP/1.1 101 Switching Protocols",
    "Upgrade: websocket",
    "Connection: Upgrade",
    "Sec-WebSocket-Accept: #{response_key}",
    "\r\n"
  ].join("\r\n")

  socket.write(handshake_response)

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

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

  raise "このサーバーは複数フレームには非対応です" unless fin
  raise "このサーバーではopcode 1(テキスト)のみに対応してます" unless opcode == 1

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

  raise "サーバーに送信されるすべてのフレームは、WebSocketの仕様に従ってマスクされる必要があります" unless is_masked
  raise "ペイロードの長さは126バイト未満のみサポートします" 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

ここまで実装して、ブラウザをリロードすると次のような出力が得られます。

$ ruby server3.rb
リクエストが来ました!
Websocket handshake detected with key: b0/OU3QOsU8n+mfztQRtYw==
Responding to handshake with key: 9CIywhUyv2uxwCuyL9azCZyinLs=
Handshake completed. Starting to parse the websocket frame.
Payload size: 16 bytes
Got mask: [130, 202, 212, 204]
Got masked data: [193, 171, 186, 236, 251, 165, 161, 236, 234, 175, 181, 190, 162, 167, 177, 243]
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?"

クライアントからのメッセージがサーバーに送信されてますね!

バイナリフレームの解析実装(サーバーからクライアントへの通信)

サーバーにデータを送り返す

それでは最後にクライアントにデータを送り返してみます。

サーバーからクライアントに送信されるフレームはマスキング処理が必要ないので少し簡単です。
フレームを1バイトずつ読み取ったのと同様にして、1バイトずつ構築していきます。

1バイト目:FINとopecode

ペイロードは1つのフレームに収まり、テキストであるものとします。
この場合はFINが1であり、opcodeも1です。
よって次の数値が得られます。

output = [0b10000001]

2バイト目:MASKとPayload len

このフレームはサーバーからクライアントに送信されるため、MASKの値は0になります。
ペイロードの長さは文字列の長さとなります。

output = [0b10000001, response.size]

3バイト目以降:Payload

ペイロードはマスクされていないため、単なる文字列となります。

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

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

この時点で送信したいデータの配列の準備ができました。
これをrubyのArray#packを使用してネットワーク経由で送信可能なバイト文字列への変換します。

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

最終的なコード

上記の実装を反映させた最終形のコードがこちらです。

require 'socket'
require 'digest/sha1'

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

loop do
  # 接続を待機
  socket = server.accept
  STDERR.puts "リクエストが来ました!"

  # 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 }"

  handshake_response = [
    "HTTP/1.1 101 Switching Protocols",
    "Upgrade: websocket",
    "Connection: Upgrade",
    "Sec-WebSocket-Accept: #{response_key}",
    "\r\n"
  ].join("\r\n")

  socket.write(handshake_response)

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

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

  raise "このサーバーは複数フレームには非対応です" unless fin
  raise "このサーバーではopcode 1(テキスト)のみに対応してます" unless opcode == 1

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

  raise "サーバーに送信されるすべてのフレームは、WebSocketの仕様に従ってマスクされる必要があります" unless is_masked
  raise "ペイロードの長さは126バイト未満のみサポートします" 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 }"

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

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

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

  socket.close
end

ブラウザをリロードすると、サーバーからメッセージが届いていることが確認できます!

サーバーのログ

$ ruby server4.rb
リクエストが来ました!
Websocket handshake detected with key: crpp5YHgADXFvGYfRALK+A==
Responding to handshake with key: UZHkHSE63lg9st4ig7QB85xbqbk=
Handshake completed. Starting to parse the websocket frame.
Payload size: 16 bytes
Got mask: [121, 147, 15, 204]
Got masked data: [58, 242, 97, 236, 0, 252, 122, 236, 17, 246, 110, 190, 89, 254, 106, 243]
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?"
Sending response: "Loud and clear!"

Discussion