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

RubyでシンプルなWebSocketサーバーをゼロから構築する

最近、WebSocketはますます報道されています。彼らは「未来」だと聞いています。 Rails 5のActionCableのおかげで、これまで以上に使いやすくなっていると聞いています。しかし、WebSocketとは正確には何でしょうか。それらはどのように機能しますか?

この投稿では、Rubyで単純なWebSocketサーバーを最初から構築することで、これらの質問に答えます。完了すると、ブラウザとサーバー間の双方向通信が実現します。

この投稿のコードは、学習演習として意図されています。実際の本番アプリにWebSocketを実装する場合は、優れたWebSocket-rubygemを確認してください。 WebSocketの仕様もご覧ください。

つまり、WebSocketについて聞いたことがない

Webソケットは、通常のHTTP接続に固有のいくつかの問題を解決するために発明されました。通常のHTTP接続を使用してWebページを要求すると、サーバーはコンテンツを送信してから接続を閉じます。別のページをリクエストする場合は、別の接続を確立する必要があります。これは通常は正常に機能しますが、一部のユースケースでは最善のアプローチではありません:

  • チャットなどの一部のアプリケーションでは、新しいメッセージが届いたらすぐにフロントエンドを更新する必要があります。通常のHTTPリクエストしかない場合は、サーバーを継続的にポーリングして、存在するかどうかを確認する必要があります。新しいコンテンツ。
  • フロントエンドアプリケーションがサーバーに対して大量の小さなリクエストを行う必要がある場合、リクエストごとに新しい接続を作成するオーバーヘッドがパフォーマンスの問題になる可能性があります。これはHTTP2ではそれほど問題ではありません。

Webソケットを使用すると、サーバーに1つの接続を確立し、サーバーを開いたままにして双方向通信に使用します。

クライアント側

Webソケットは通常、ブラウザとWebサーバー間の通信に使用されます。ブラウザ側はJavaScriptで実装されています。以下の例では、ローカルサーバーへのWebソケットを開いて、そこにメッセージを送信するための非常に単純な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>

小さな静的サーバーを起動し、このファイルをWebブラウザーで開くと、エラーが発生します。サーバーがまだないので、それは理にかなっています。まだ作成する必要があります。 :-)

RubyでシンプルなWebSocketサーバーをゼロから構築する

サーバーの開始

Webソケットは、通常のHTTPリクエストとして機能し始めます。彼らには奇妙なライフサイクルがあります:

  1. ブラウザは通常のHTTPリクエストを送信し、「ウェブソケットを作成してください」という特別なヘッダーがいくつかあります。
  2. サーバーは特定のHTTP応答で応答しますが、接続を閉じません。
  3. ブラウザとサーバーは、特別なWebSocketプロトコルを使用して、開いている接続を介してデータのフレームを交換します。

したがって、私たちの最初のステップは、Webサーバーを構築することです。以下のコードでは、可能な限り単純なWebサーバーを作成しています。実際には何も提供しません。リクエストを待ってからSTDERRに出力するだけです。

require 'socket'

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

loop do

  # Wait for a connection
  socket = server.accept
  STDERR.puts "Incoming Request"

  # Read the HTTP request. We know it's finished when we see a line with nothing but \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リクエストには、Webソケットに関連する一連のヘッダーが含まれています。これは実際にはWebSocketハンドシェイクの最初のステップです

ハンドシェイク

すべてのWebソケット要求は、ハンドシェイクから始まります。これは、クライアントとサーバーの両方がWebソケットが発生しようとしていることを理解し、両方がプロトコルバージョンに同意していることを確認するためです。これは次のように機能します:

クライアントはこのような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を除いて、サーバーの応答は定型文です。 ヘッダ。このヘッダーは次のように生成されます:

# Take the value provided by the client, append a magic
# string to it. Generate the SHA1 hash, then base64 encode it.
Digest::SHA1.base64digest([sec_websocket_accept, "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"].join)

あなたの目はあなたに嘘をついていません。関係する魔法の定数があります。

ハンドシェイクの実装

サーバーを更新してハンドシェイクを完了しましょう。まず、リクエストヘッダーからセキュリティトークンを引き出します:

# Grab the security key from the headers.
# If one isn't present, close the connection.
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プロトコルを介して交換されます。

フレームはWebSocketプロトコルの基本単位です。

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

Webブラウザに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とオペコード

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

  • FIN:1ビット これがfalseの場合、メッセージは複数のフレームに分割されます
  • オペコード:4ビット ペイロードがテキストであるかバイナリであるか、またはこれが接続を維持するための単なる「ping」であるかどうかを通知します。
  • RSV:3ビット これらは現在のWebSocket仕様では使用されていません。

最初のバイトを取得するには、IO#getbyteを使用します 方法。そして、データを抽出するために、いくつかの単純なビットマスキングを使用します。ビット単位の演算子に慣れていない場合は、他の記事「Rubyでのビット単位のハック」を確認してください

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

# Our server will only support single-frame, text messages.
# Raise an exception if the client tries to send anything else.
raise "We don't support continuations" unless fin
raise "We only support opcode 1" unless opcode == 1

バイト2:マスクとペイロードの長さ

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

  • マスク:1ビット ペイロードがマスクされているかどうかを示すブールフラグ。 trueの場合、ペイロードは使用前に「マスク解除」する必要があります。これは、クライアントから着信するフレームには常に当てはまります。仕様にはそう書かれています。
  • ペイロードの長さ: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バイトを構成します。処理する必要はまったくありません。バイトを配列に読み込むだけです。

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' # Provides TCPServer and TCPSocket classes
require 'digest/sha1'

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

loop do

  # Wait for a connection
  socket = server.accept
  STDERR.puts "Incoming Request"

  # Read the HTTP request. We know it's finished when we see a line with nothing but \r\n
  http_request = ""
  while (line = socket.gets) && (line != "\r\n")
    http_request += line
  end

  # Grab the security key from the headers. If one isn't present, close the connection.
  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テスターのWebページを更新してサーバーにリクエストを送信すると、次のような出力が表示されます。

$ 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とオペコード

ペイロードは1つのフレームに収まり、テキストになります。つまり、FINは1に等しく、オペコードも1に等しくなります。以前に使用したのと同じビット形式を使用してそれらを組み合わせると、数値が得られます:

output = [0b10000001]

バイト2:マスクされたペイロードの長さ

このフレームはサーバーからクライアントに送信されるため、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 }" Array#packに通知します 配列に2つの8ビットunsignedintが含まれ、その後に指定されたサイズの文字列が続くこと。

ネットワークインスペクターをChromeで開くと、メッセージがはっきりと聞こえてきたことがわかります。

RubyでシンプルなWebSocketサーバーをゼロから構築する

追加のクレジット

それでおしまい! WebSocketについて何かを学んだことを願っています。サーバーに欠けているものはたくさんあります。運動を続けたい場合は、それらを調べてみてください:

  • マルチフレームペイロードのサポート
  • バイナリペイロードのサポート
  • ピン/ポンのサポート
  • 長いペイロードのサポート
  • 握手を閉じる

  1. 最初のWebスクレイパーの構築、パート3

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

  2. サーバーから切断されたFallout 76を修正

    Fallout 76 は、Bethesda Studios が 2018 年にリリースした人気のマルチプレイヤー ロールプレイング アクション ゲームです。このゲームは、Windows PC、Xbox One、および Play Station 4 で利用できます。Fallout シリーズのゲームが好きな場合は、楽しくプレイできます。ただし、多くのプレイヤーが、自分のコンピューターでゲームを起動しようとしたときに、Fallout 76 がサーバーから切断されたというエラーが発生したと報告しています。 Bethesda Studios は、サーバーの過負荷が原因で問題が発生したと主張しました。多数