Ruby で Heroku の WebSocket を使用する
最終更新日 2023年09月29日(金)
Table of Contents
このクイックスタートでは、Heroku にデプロイされた WebSocket を使用する Sinatra アプリケーションについて説明します。
デモアプリケーションのサンプルコードは GitHub で入手できます。編集や機能強化を歓迎します。単にリポジトリをフォークし、変更を加えて、プルリクエストを送信してください。
前提条件
- Heroku ユーザーアカウント。無料ですぐにサインアップできます。
- Ruby 2.1.2 以降および Heroku CLI (基本的な Ruby スターターガイド)で説明しているとおり)
- Rack ミドルウェアに関する実践的な知識
WebSocket アプリの作成
このサンプルアプリケーションは、Ruby で WebSocket を使用する単純な例を提供します。サンプルを複製し、読みながらコードを追っていくことができます。
オプション 1。サンプルアプリを複製する
$ git clone git@github.com:heroku-examples/ruby-websockets-chat-demo.git
Cloning into 'ruby-websockets-chat-demo'...
remote: Counting objects: 31, done.
remote: Compressing objects: 100% (24/24), done.
remote: Total 31 (delta 0), reused 31 (delta 0)
Receiving objects: 100% (31/31), 38.33 KiB | 0 bytes/s, done.
オプション 2。新しいアプリを作成する
$ mkdir ruby-websockets-chat-demo
$ cd ruby-websockets-chat-demo
機能
このデモアプリは、バックエンドへの WebSocket を開く単純なチャットアプリケーションです。ブラウザからチャットメッセージが送信されると常に、そのメッセージはサーバーに送信された後、接続している各クライアントにブロードキャストされ、ページに表示されます。
この実装にとって重要なものがいくつかあります。ここでは、標準の WebSocket API を提供する Faye の WebSocket 実装を使用します。これにより、WebSocket に応答する Rack ミドルウェアをビルドできるようになります。
ブラウザ上の JavaScript は、サーバーへの WebSocket 接続を開き、サーバーから受信された WebSocket メッセージに応答してチャットメッセージを表示します。では、バックエンドとフロントエンドの両方をさらに詳細に見ていきましょう。
バックエンド
サンプルアプリでは、ChatBackend
という名前の WebSocket ロジックをカプセル化する Rack ミドルウェアを作成します。組織の目的のために、すべての Rack ミドルウェアを middlewares
ディレクトリに配置します。
ChatBackend
ミドルウェアについて見ていきましょう。Web アプリに接続しているすべてのクライアントを追跡する必要があるため、基本的なボイラープレートコードを使用して @clients
配列を設定しましょう。
# middlewares/chat_backend.rb
require 'faye/websocket'
module ChatDemo
class ChatBackend
KEEPALIVE_TIME = 15 # in seconds
def initialize(app)
@app = app
@clients = []
end
def call(env)
end
end
end
call
メソッド内の Faye::Websocket
では、env
を検査することで、受信リクエストが WebSocket リクエストであるかどうかを検出できます。そうであれば、WebSocket ロジックを実行しましょう。そうでない場合は、スタックの残りの部分を実行する必要があります (この場合、これは Sinatra アプリ)。
# middlewares/chat_backend.rb
def call(env)
if Faye::WebSocket.websocket?(env)
# WebSockets logic goes here
# Return async Rack response
ws.rack_response
else
@app.call(env)
end
end
ここで、WebSocket コードを追加しましょう。まず、env
に基づいて新しい WebSocket オブジェクトを作成する必要があります。これは Faye::WebSocket
イニシャライザを使用して行うことができます。オプションハッシュでは、X 秒ごとにアクティブな各接続に ping を送信する ping
オプションを指定できます。この例では、以前に 15 秒に設定した KEEPALIVE_TIME
定数を使用します。これを行うのは、接続がアイドル状態になってから 55 秒が経過すると Heroku によって接続が終了されるためです。
ミドルウェアの call
メソッドで ping
オプションを使用して WebSocket を初期化します。
# middlewares/chat_backend.rb#call
ws = Faye::WebSocket.new(env, nil, {ping: KEEPALIVE_TIME })
このアプリにとって重要な 3 つの WebSocket イベントは open
、message
、close
です。それぞれのイベントで、WebSocket のライフサイクルを確認するために、デモの目的で STDOUT
にメッセージを出力します。
open
open
は、サーバーへの新しい接続が発生すると呼び出されます。open
に関しては、クライアントが接続したことを、以前に設定した @clients
配列に格納するだけです。
# middlewares/chat_backend.rb#call
ws.on :open do |event|
p [:open, ws.object_id]
@clients << ws
end
message
message
は、サーバーで WebSocket メッセージを受信すると呼び出されます。message
に関しては、接続された各クライアントにメッセージをブロードキャストする必要があります。渡される event
オブジェクトには data
属性があり、これがメッセージです。
# middlewares/chat_backend.rb#call
ws.on :message do |event|
p [:message, event.data]
@clients.each {|client| client.send(event.data) }
end
close
close
は、クライアントが接続を閉じると呼び出されます。close
に関しては、クライアントのストアからクライアントを削除することによってクリーンアップする必要があるだけです。
# middlewarces/chat_backend.rb#call
ws.on :close do |event|
p [:close, ws.object_id, event.code, event.reason]
@clients.delete(ws)
ws = nil
end
フロントエンド
この記事の 2 番目の部分は、サーバーとの WebSocket 接続を実際に開くようにクライアント側を設定することです。まず、使用するページをレンダリングするための Sinatra アプリのセットアップを完了する必要があります。
ビュー
Sinatra アプリでは、インデックスビューをレンダリングする必要があるだけです。これには、組み込みの ERB
を使用します。
# app.rb
require 'sinatra/base'
module ChatDemo
class App < Sinatra::Base
get "/" do
erb :"index.html"
end
end
end
Sinatra では、そのビューは views
ディレクトリに格納されます。基本的なチャットフォームを備えた index.html.erb
を作成します。
メッセージの受信
ここで、WebSocket に戻ります。メインページによってロードされ、バックエンドへの WebSocket 接続を確立する public/assets/js/application.js
を記述しましょう。
var scheme = "ws://";
var uri = scheme + window.document.location.host + "/";
var ws = new WebSocket(uri);
WebSocket を開くと、ブラウザはメッセージを受信します。これらのメッセージは、handle
(ユーザーのハンドル) と text
(ユーザーのメッセージ) の 2 つのキーを含む JSON 応答として構造化されます。メッセージが受信されたら、そのメッセージを新しいエントリとしてページに挿入する必要があります。
// public/assets/js/application.js
ws.onmessage = function(message) {
var data = JSON.parse(message.data);
$("#chat-text").append("<div class='panel panel-default'><div class='panel-heading'>" + data.handle + "</div><div class='panel-body'>" + data.text + "</div></div>");
$("#chat-text").stop().animate({
scrollTop: $('#chat-text')[0].scrollHeight
}, 800);
};
メッセージを送信する
これでページにサーバーからのメッセージを受信できるようになったので、次はメッセージを実際に送信する方法が必要です。フォームからこの値を取得し、JSON メッセージとして WebSocket 経由でサーバーに送信するように、フォーム送信ボタンを上書きします。
// public/assets/js/application.js
$("#input-form").on("submit", function(event) {
event.preventDefault();
var handle = $("#input-handle")[0].value;
var text = $("#input-text")[0].value;
ws.send(JSON.stringify({ handle: handle, text: text }));
$("#input-text")[0].value = "";
});
これで、最終的な application.js
ファイルにより、完全な送受信機能が定義されます。
ローカル実行
アプリケーションを接続するために、Rack::Builder
をセットアップする config.ru
を使用します。これは、アプリケーションをロードする方法を Rack およびサーバーに指示します。セットアップするのは WebSocket ミドルウェアなので、リクエストはまずこれを通過します。
# config.ru
require './app'
require './middlewares/chat_backend'
use ChatDemo::ChatBackend
run ChatDemo::App
依存関係をダウンロードおよび取得する必要があるため、Gemfile
を設定する必要があります。
# Gemfile
source "https://rubygems.org"
ruby "2.0.0"
gem "faye-websocket"
gem "sinatra"
gem "puma"
そして、すべての依存関係をインストールします。
$ bundle install
ここで Procfile を設定するので、Web サービスを実行する方法を文書化しました。faye-websocket
でサポートされている Web サーバーの 1 つである puma Web サーバーを使用します。
web: bundle exec puma -p $PORT
アプリをローカルで実行します。
$ heroku local --port 5001
Puma starting in single mode...
* Version 2.6.0, codename: Pantsuit Party
* Min threads: 0, max threads: 16
* Environment: development
* Listening on tcp://0.0.0.0:5001
Use Ctrl-C to stop
http://localhost:5001 でアプリを開き、チャットメッセージを入力します。サーバー出力は次のようになります。
127.0.0.1 - - [03/Oct/2013 17:16:25] "GET / HTTP/1.1" 200 1430 0.0115
127.0.0.1 - - [03/Oct/2013 17:16:25] "GET /assets/css/application.css HTTP/1.1" 304 - 0.0164
127.0.0.1 - - [03/Oct/2013 17:16:25] "GET /assets/css/bootstrap.min.css HTTP/1.1" 304 - 0.0046
127.0.0.1 - - [03/Oct/2013 17:16:25] "GET /assets/js/jquery-2.0.3.min.js HTTP/1.1" 304 - 0.0007
127.0.0.1 - - [03/Oct/2013 17:16:25] "GET /assets/js/application.js HTTP/1.1" 200 716 0.0063
127.0.0.1 - - [03/Oct/2013 17:16:25] "GET / HTTP/1.1" HIJACKED -1 0.0059
[:open, 7612540]
127.0.0.1 - - [03/Oct/2013 17:16:25] "GET /favicon.ico HTTP/1.1" 404 490 0.0012
[:message, "{\"handle\":\"hone\",\"text\":\"test\"}"]
デプロイ
アプリをローカルで実行した後は、Heroku にデプロイします。まだ実行していない場合は、そのアプリケーションを Git リポジトリに配置します。
$ git init
$ git add .
$ git commit -m "Ready to deploy"
Heroku にデプロイするアプリを作成します。
$ heroku create
Creating limitless-ocean-5045... done, stack is heroku-18
http://limitless-ocean-5045.herokuapp.com/ | git@heroku.com:limitless-ocean-5045.git
Git remote heroku added
git push
を使用してアプリを Heroku にデプロイします。
$ git push heroku master
Counting objects: 9, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (5/5), done.
Writing objects: 100% (5/5), 557 bytes, done.
Total 5 (delta 3), reused 0 (delta 0)
-----> Ruby/Rack app detected
-----> Using Ruby version: ruby-2.1.2
-----> Installing dependencies using Bundler version 1.6.3
Running: bundle install --without development:test --path vendor/bundle --binstubs vendor/bundle/bin --deployment
Installing eventmachine (1.0.3)
Installing websocket-driver (0.3.0)
Installing faye-websocket (0.7.0)
Installing rack (1.5.2)
Installing puma (2.6.0)
Installing rack-protection (1.5.0)
Installing redis (3.0.4)
Installing tilt (1.4.1)
Installing sinatra (1.4.3)
Installing bundler (1.3.2)
Your bundle is complete! It was installed into ./vendor/bundle
Cleaning up the bundler cache.
-----> Discovering process types
Procfile declares types -> web
Default types for Ruby/Rack -> console, rake
-----> Compiled slug size: 26.8MB
-----> Launching... done, v3
http://limitless-ocean-5045.herokuapp.com deployed to Heroku
以上で、Web アプリが Heroku で稼働するようになりました。heroku open
を使用してブラウザでアプリを開きます。
スケーリング
これでアプリをデプロイし、基本中の基本を理解できたので、アプリをもう少し堅牢になるように拡張したいと思います。これには、公開する前にやっておきたいことのすべては含まれていませんが、正しい判断を行うという方向性は間違っていません。
現時点では、アプリを複数の dyno にスケーリングしても、全員にすべてのメッセージが届くわけではありません。現在のアプリのステートレスな性質のため、1 番目の dyno に接続されたクライアントには、2 番目の dyno に接続されたクライアントから送信されたメッセージは届きません。チャットの例では、メッセージの状態を Redis などのグローバルストレージシステムに格納することによってこれを解決できます。これにより、Redis に接続されたすべての dyno にメッセージを送信できるようになります。WebSocket アプリケーションアーキテクチャに関するセクションで詳しく説明しています。
Redis アドオンを追加します。
$ heroku addons:create rediscloud:20
Adding rediscloud:20 on limitless-ocean-5045... v4 (free)
ローカルで Redis がセットアップされていることを確認します。redis
gem を使用して、Ruby アプリから Redis とインターフェース接続します。前述したように、Gemfile
を編集してアプリの依存関係を設定する必要があります。次の行を Gemfile
の末尾に追加します。
gem 'redis'
依存関係をインストールします。
$ bundle install
Redis の pubsub システムを使用するように各 dyno を設定する必要があります。すべての dyno が同じチャネル chat-demo
にサブスクライブしてメッセージを待機します。各サーバーでメッセージを取得したら、接続されたクライアントにそのメッセージを発行できます。
require 'redis'
module ChatDemo
class ChatBackend
...
CHANNEL = "chat-demo"
def initialize(app)
...
uri = URI.parse(ENV["REDISCLOUD_URL"])
@redis = Redis.new(host: uri.host, port: uri.port, password: uri.password)
Thread.new do
redis_sub = Redis.new(host: uri.host, port: uri.port, password: uri.password)
redis_sub.subscribe(CHANNEL) do |on|
on.message do |channel, msg|
@clients.each {|ws| ws.send(msg) }
end
end
end
end
...
end
サブスクライブはブロック関数であり、実行フローを停止してメッセージを待機するため、別のスレッドでサブスクライブを実行する必要があります。さらに、接続に対してサブスクライブコマンドを実行すると、その接続ではサブスクライブ解除またはメッセージの受信しかできなくなるため、2 番目の Redis 接続が必要です。
ここでは、サーバーでメッセージを受信したら、ブラウザに送信する代わりに Redis を使用してチャネルに発行します。このようにすると、すべての dyno にその通知が届くため、すべてのクライアントでメッセージを受信できます。middlewares/chat_backend#call
を変更します。
ws.on :message do |event|
p [:message, event.data]
@redis.publish(CHANNEL, event.data)
end
以上の手順はすべて、このコミットで確認できます。
セキュリティ
この時点で、アプリケーションは公開されており、多くの攻撃に対して脆弱です。WebSocket アプリケーションのセキュリティ保護のガイドライン全般については、「WebSocket のセキュリティ」を参照してください。github のサンプルアプリでは、WSS をセットアップし、入力をサニタイズします。
Rails での使用
このサンプルアプリでは、Sinatra アプリと並行して Rack ミドルウェアを作成しました。Rails アプリで同じミドルウェアを使用するのは簡単です。
既存の ChatBackend
ミドルウェアを Rails プロジェクトの app/middleware/chat_backend.rb
にコピーします。次に、このミドルウェアをスタックに挿入します。これは config/application.rb
で定義されます。
require 'chat_backend'
config.middleware.use ChatDemo::ChatBackend
Rails アプリ内に埋め込まれたカスタムのチャットミドルウェアによって WebSocket リクエストが処理されるようになります。