Rails7でさくっとWebSocketする
はじめに
昨年2021年12月に、突然Rails7がリリースされました。
このメジャーアップデートによりimportmapがデフォルトで利用できるようになりました。
いままでは少ししかjsを使わない場合でもnodeへの依存が必要だったり、webpackのラッパーであるwebpackerの扱い方を覚えなければならなかったりと少々ややこしかったのですが、今後はより気軽にアプリケーションが作れそうです。
今回つくるもの
ゼロからRails7環境を構築し、チャット(っぽい)システムを動かします。
アカウントの制御など細かいことはやりません。
新規データ追加を購読しリアルタイムにテキストを画面に描画するだけの、シンプルにWebSocketをお試しできる機能を実装します。
DockerでRails7環境を構築
ゼロからRails7が動く環境を構築していきます。
docker(できればver20~)が動く環境を準備してください。
ベースとなるrubyのイメージを作る
プロジェクト用のディレクトリを作成し、Dockerfileとdocker-compose.ymlを作成します。
sample-prj
├ Dockerfile
└ docker-compose.yml
node×rubyのマルチステージビルドは不要です。シンプル!
FROM ruby:3.1.0
ENV APP_ROOT /myapp
RUN mkdir $APP_ROOT
WORKDIR $APP_ROOT
ADD . $APP_ROOT
version: '3'
services:
app:
restart: always
build: .
volumes:
- .:/myapp
imageをbuildします。
~/path/sample-prj$ docker-compose build
buildが完了したらRailsをinstallしていきます。
Railsを導入する
appコンテナに入り
~/path/sample-prj$ docker-compose run app sh
Rails7のインストールを実行します(appコンテナ内)
# gem i -v 7.0 rails
プロジェクトの作成(appコンテナ内)
# rails new . -d mysql
DBはmysqlを選択しました。
javascriptライブラリはオプション指定していないため、デフォルトでimportmapとなります。
ここでsample-prjディレクトリにrailsの各種ファイルが自動生成されます。
image作成時に bundle installを実行するよう設定します。
FROM ruby:3.1.0
ENV APP_ROOT /myapp
RUN mkdir $APP_ROOT
WORKDIR $APP_ROOT
ADD . $APP_ROOT
+RUN bundle install
dbコンテナを追加し、appコンテナの起動コマンドを追加します。
また、dbのデータとbundleのデータはvolumeマウントして永続化させておきます。
version: '3'
services:
+ db:
+ restart: always
+ image: mysql:5.7
+ volumes:
+ - mysql-db:/var/lib/mysql
+ environment:
+ MYSQL_ROOT_PASSWORD: password
+ MYSQL_DATABASE: root
+ ports:
+ - 3306:3306
app:
restart: always
build: .
+ command: rails s -b 0.0.0.0 -p 3000
volumes:
- .:/myapp
+ - bundle:/usr/local/bundle
+ ports:
+ - 3000:3000
+ depends_on:
+ - db
+volumes:
+ mysql-db:
+ bundle:
RailsのDB接続設定を編集し、mysqlへ接続できるようにします。
usernameとpasswordはdocker-compose.ymlで設定した値、hostはDBのネットワーク名とします。
(docker-composeではservice名をネットワーク名として扱えるため)
default: &default
adapter: mysql2
encoding: utf8mb4
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
+ username: root
+ password: password
+ host: db
development:
<<: *default
database: myapp_development
test:
<<: *default
database: myapp_test
production:
<<: *default
database: myapp_production
username: myapp
password: <%= ENV["MYAPP_DATABASE_PASSWORD"] %>
imageを再buildし、サーバーを起動します。
~/path/sample-prj$ docker-compose build
~/path/sample-prj$ docker-compose up -d
localhost:3000へアクセスすると...
welcomeページが表示されます。
ActionCableを動かす
rails newができたので、ActionCableを導入してWebSocketしていきます。
controllerとviewを構築する
roomsコントローラーを作成し、showメソッドを作成します(appコンテナ内)
# rails g controller rooms show
routes.rbを編集しトップページをroomsのshowに設定します。
Rails.application.routes.draw do
+ root to: 'rooms#show'
get 'rooms/show'
end
localhost:3000でアクセスすると、ウェルカムページではなくrooms#showが表示されるようになります。
show.html.erbを編集して、投稿フォームを作成します。
<h1>Chat room</h1>
<div id ='messages'>
</div>
+<form>
+ <label>Say something:</label><br>
+ <input type="text" data-behavior="room_speaker">
+</form>
ActionCableの設定を行う
チャンネルを作成します(appコンテナ内)
# rails g channel room speak
このコマンドで必要なjsやrbファイルが自動生成されます。
また、ESModulesはimportmapで管理する設定となっているため、ActionCableのために必要な設定はconfig/importmap.rbへ自動的に追加されます。
尚、自動生成されたroom_channel.rbがサーバーサイドを担うチャンネル、room_channel.jsがクライアントサイドを担うチャンネルとなります。
ActionCableを有効化します。
Rails.application.routes.draw do
+ mount ActionCable.server => '/cable'
root to: 'rooms#show'
get 'rooms/show'
end
room_channel.rbを編集し、バックエンドで通信エンドポイントの設定を行います。
class RoomChannel < ApplicationCable::Channel
def subscribed
# stream_from "some_channel"
+ stream_from "room_channel"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
def speak(data)
+ ActionCable.server.broadcast('room_channel', {message: data['message']})
end
end
room_channel.jsを編集し、フロントエンドで通信の設定を行います。
import consumer from "channels/consumer"
+ // appRoomという定数に格納
+const appRoom = consumer.subscriptions.create("RoomChannel", {
connected() {
// Called when the subscription is ready for use on the server
},
disconnected() {
// Called when the subscription has been terminated by the server
},
received(data) {
// Called when there's incoming data on the websocket for this channel
+ return alert(data['message']);
},
speak: function(message) {
+ return this.perform('speak', {message: message});
}
});
+window.document.onkeydown = function(event) {
+ if(event.key == 'Enter') {
+ appRoom.speak(event.target.value);
+ event.target.value = '';
+ event.preventDefault();
+ }
+}
ブラウザにてフォームにテキストを入れてEnterすると入力文字がアラートされ、WebSocket通信ができていることが確認ができます。
送信したテキストをリアルタイム表示する
WebSocket通信ができていることが確認できたので、アラートとして表示されていたテキストが画面に描画されるように修正し、チャットっぽくしていきます。
データの保存先をつくる
チャットのデータを扱うmessageモデルを作成します(appコンテナ内)
# rails g model message content:text
# rails db:migrate
controllerのshowメソッドで全メッセージを表示されるよう記述します。
class RoomsController < ApplicationController
def show
+ @messages = Message.all
end
end
描画テンプレートを利用する
messageの部分テンプレートを新規作成します。
<div class="message">
<p><%= message.content %></p>
</div>
show.html.erbを編集してさきほどの部分テンプレートを呼び出します。
<h1>Chat room</h1>
<div id ='messages'>
+ <%= render @messages %>
</div>
<form>
<label>Say something:</label><br>
<input type="text" data-behavior="room_speaker">
</form>
データを保存した直後にフロントエンドへ配信する
room_channel.rbを編集し、データの保存ができるようにします。
def speak(data)
- ActionCable.server.broadcast('room_channel', {message: data['message']})
+ Message.create! content: data['message']
end
Messageモデルを修正し、保存後の処理を追記します。
RailsのCallbackにより、データ保存後にMessageBroadcastJobのperformメソッドが実行されます。
class Message < ApplicationRecord
+ after_create_commit { MessageBroadcastJob.perform_later self }
end
データ配信処理をActiveJobで実装します(appコンテナ内)
# rails g job MessageBroadcast
先ほど生成されたjobファイルに処理を追記します。
class MessageBroadcastJob < ApplicationJob
queue_as :default
+ def perform(message)
+ ActionCable.server.broadcast 'room_channel', message: render_message(message)
+ end
+ private
+ def render_message(message)
+ ApplicationController.renderer.render(partial: 'messages/message', locals: { message: message })
+ end
end
フロントエンドで取得したデータを画面へ反映する
メッセージがリアルタイムに描画されるようにします。
received(data) {
// Called when there's incoming data on the websocket for this channel
- return alert(data['message']);
+ const messages = document.getElementById('messages');
+ messages.insertAdjacentHTML('beforeend', data['message']);
},
2つのブラウザでlocalhost:3000へアクセスし、片方のブラウザでフォームへテキストを入力してEnterすると、両方の画面へテキストがリアルタイムに反映されることが確認できるはずです。
Discussion