🛤️

Rails7でさくっとWebSocketする

2022/02/20に公開

はじめに

昨年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のマルチステージビルドは不要です。シンプル!

Dockerfile
FROM ruby:3.1.0

ENV APP_ROOT /myapp
RUN mkdir $APP_ROOT
WORKDIR $APP_ROOT
ADD . $APP_ROOT
docker-compose.yml
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を実行するよう設定します。

Dockerfile
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マウントして永続化させておきます。

docker-compose.yml
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名をネットワーク名として扱えるため)

config/database.yml
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に設定します。

config/routes.rb
Rails.application.routes.draw do
+ root to: 'rooms#show'
  get 'rooms/show'
end

localhost:3000でアクセスすると、ウェルカムページではなくrooms#showが表示されるようになります。

show.html.erbを編集して、投稿フォームを作成します。

app/views/rooms/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を有効化します。

config/routes.rb
Rails.application.routes.draw do
+ mount ActionCable.server => '/cable'
  root to: 'rooms#show'
  get 'rooms/show'
end

room_channel.rbを編集し、バックエンドで通信エンドポイントの設定を行います。

app/channnels/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を編集し、フロントエンドで通信の設定を行います。

app/javascript/channnels/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メソッドで全メッセージを表示されるよう記述します。

app/controllers/rooms_controller.rb
class RoomsController < ApplicationController
  def show
+   @messages = Message.all
  end
end

描画テンプレートを利用する

messageの部分テンプレートを新規作成します。

app/views/messages/_message.html.erb
<div class="message">
  <p><%= message.content %></p>
</div>

show.html.erbを編集してさきほどの部分テンプレートを呼び出します。

app/views/rooms/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を編集し、データの保存ができるようにします。

app/channnels/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メソッドが実行されます。

app/models/message.rb
class Message < ApplicationRecord
+  after_create_commit { MessageBroadcastJob.perform_later self }
end

データ配信処理をActiveJobで実装します(appコンテナ内)

# rails g job MessageBroadcast

先ほど生成されたjobファイルに処理を追記します。

app/jobs/message_broadcast_job.rb
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

フロントエンドで取得したデータを画面へ反映する

メッセージがリアルタイムに描画されるようにします。

app/javascript/channnels/room_channel.js
  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