2022年2月15日火曜日

ActionCableを用いたチャットアプリの作成

事前準備

今回,下記の手順で動作を検証したバージョンは,Ruby,Railsともに次のとおりである.

$ ruby --version

ruby 3.0.2p107 (2021-07-07 revision 0db68f0233) [arm64-darwin20]

$ bin/rails --version

Rails 7.0.2.2

$ 

Railsの雛形を次の手順で用意する.

$ mkdir chatroom

$ cd chatroom

$ rbenv local 3.0.2

$ bundle init

Writing new Gemfile to /private/tmp/chatroom/Gemfile

$ vi Gemfile

Gemfileの # gem 'rails' の行,「# 」を削除してコメントアウトを外す.

$ bundle config set path vendor/bundle

$ bundle install

Fetching gem metadata from https://rubygems.org/...........

Resolving dependencies...

Fetching rake 13.0.6

Installing rake 13.0.6

Fetching minitest 5.15.0

(中略)

Fetching rails 7.0.2.2

Installing rails 7.0.2.2

Bundle complete! 1 Gemfile dependency, 47 gems now installed.

Bundled gems are installed into `./vendor/bundle`

$ bundle exec rails new . -f

       exist  

      create  README.md

      create  Rakefile

   identical  .ruby-version

      create  config.ru

      create  .gitignore

      create  .gitattributes

       force  Gemfile

         run  git init from "."

(中略)

Import Stimulus controllers

      append  app/javascript/application.js

Pin Stimulus

      append  config/importmap.rb

$ 

以上で準備はOK.

チャットルームへの第1歩

roomsコントローラを作る.とりあえず,今回は一つのルームで一つのページしか表示しないSingle Page Application (SPA)なので,showメソッドだけ作ればよい.

$ bin/rails g controller rooms show

      create  app/controllers/rooms_controller.rb

       route  get 'rooms/show'

      invoke  erb

      create    app/views/rooms

      create    app/views/rooms/show.html.erb

      invoke  test_unit

      create    test/controllers/rooms_controller_test.rb

      invoke  helper

      create    app/helpers/rooms_helper.rb

      invoke    test_unit

$ 

config/routes.rb を次のように修正する.localhost:3000/ にアクセスすると,Roomsのshowメソッドにルーティングされるように記述する.

Rails.application.routes.draw do

  root to: 'rooms#show'

end

データベースを作る.

$ bin/rails db:create

Created database 'db/development.sqlite3'

Created database 'db/test.sqlite3'

$ 

今回はテストなので,デフォルトのSQLite3をそのまま使う.

確認

サーバを起動する.

$ bin/rails server

=> Booting Puma

=> Rails 7.0.2.2 application starting in development 

=> Run `bin/rails server --help` for more startup options

Puma starting in single mode...

* Puma version: 5.6.2 (ruby 3.0.2-p107) ("Birdie's Version")

*  Min threads: 5

*  Max threads: 5

*  Environment: development

*          PID: 32482

* Listening on http://127.0.0.1:3000

* Listening on http://[::1]:3000

Use Ctrl-C to stop

ブラウザを立ち上げて,localhost:3000 にアクセスする.

この画面が出ればOK.

メッセージのモデルを作成

メッセージのモデルを作る.文字列(text)型のbodyカラムを一つだけ持つ,たいへんシンプルなモデルである.

$ bin/rails g model message body:text

      invoke  active_record

      create    db/migrate/20220215115613_create_messages.rb

      create    app/models/message.rb

      invoke    test_unit

      create      test/models/message_test.rb

      create      test/fixtures/messages.yml

$ bin/rails db:migrate

== 20220215115613 CreateMessages: migrating ===================================

-- create_table(:messages)

   -> 0.0006s

== 20220215115613 CreateMessages: migrated (0.0007s) ==========================


$ 

モデルを作成したあとは,マイグレーションを忘れずに.

チャットルームのビューとコントローラを作成

次はルームのビューとコントローラを作成する.既に先のコマンドで雛形が作成されている.

app/controllers/rooms_controllers.rb を次のように修正する.Mssageモデルから,全てを取ってきて(Message.all())@messageに入れている.

class RoomsController < ApplicationController

  def show

    @messages = Message.all()

  end

end

部分レンダリングを使うので,app/views/messages ディレクトリを作成する.部分レンダリングとは,ページの一部分だけをレンダリングするものである.今回は多くのメッセージを順番に表示するので,そのメッセージの表示部分だけを部分テンプレートとして用意しておく.

$ mkdir app/views/messages

そのディレクトリに app/views/messages/_message.html.erb を次の内容で作成する.これが部分テンプレートとなる.メッセージのボディ(内容)だけを表示する.

<div class="message">

  <p><%= message.body %></p>

</div>

Roomsのビューも作る.app/views/rooms/show.html.erb を次の内容で作成する.

<h1>Chatroom</h1>

<div id ='messages'>

  <%= render @messages %>

</div>

render @messages というコマンドで,@messages に含まれるメッセージを,部分レンダリングとして用意したものとしてまとめてレンダリングしてくれる.

テストデータの投入

Railsコンソールから,テストデータを投入する.

$ bin/rails c

Loading development environment (Rails 7.0.2.2)

irb(main):001:0> Message.create! body: '庭には二羽鶏がいる'

   (0.7ms)  SELECT sqlite_version(*)

  TRANSACTION (0.0ms)  begin transaction                      

  Message Create (0.6ms)  INSERT INTO "messages" ("body", "created_at", "updated_at") VALUES (?, ?, ?)  [["body", "庭には二羽鶏がいる"], ["created_at", "2022-02-15 12:25:23.071306"], ["updated_at", "2022-02-15 12:25:23.071306"]]

  TRANSACTION (0.5ms)  commit transaction                     

=> 

#<Message:0x0000000123e157e8

 id: 1,

 body: "庭には二羽鶏がいる",

 created_at: Tue, 15 Feb 2022 12:25:23.071306000 UTC +00:00,

 updated_at: Tue, 15 Feb 2022 12:25:23.071306000 UTC +00:00>

irb(main):002:0> 

$ 

とりあえずは一つ用意しておけばよいだろう(複数,用意すれば,部分レンダリングがきちんと対応していることも確認できる).

確認

サーバを起動する.

$ bin/rails s

ブラウザを立ち上げて,localhost:3000 にアクセスする.

コンソールから入力したデータが表示されればOK.

フォームの作成

フォームを用意する.app/views/rooms/show.html.erb を次のように修正する.

<h1>Chatroom</h1>

<div id ='messages'>

  <%= render @messages %>

</div>

<form>

  <label>Leave your message:</label><br>

  <input type="text" data-behavior="room_speaker">

</form>

Railsのフォーム作成用ヘルパーメソッドも使えるが,今回は割愛.

確認

サーバを起動する.

$ bin/rails s

ブラウザを立ち上げて,localhost:3000 にアクセスする.

フォームが表示されればOK.

ルーム・チャネルの作成

Roomに対するチャネルを次のコマンドで作成する.

$ bin/rails g channel room speak

      invoke  test_unit

      create    test/channels/room_channel_test.rb

   identical  app/channels/application_cable/channel.rb

   identical  app/channels/application_cable/connection.rb

      create  app/channels/room_channel.rb

      create  app/javascript/channels/index.js

      create  app/javascript/channels/consumer.js

      append  app/javascript/application.js

      append  config/importmap.rb

      create  app/javascript/channels/room_channel.js

        gsub  app/javascript/channels/room_channel.js

      append  app/javascript/channels/index.js

$ 

app/channels/room_channel.rb が今回使用するチャネルの主要部分である.

サーバ側の処理

app/channels/room_channel.rb を次のように編集する.

class RoomChannel < ApplicationCable::Channel

  def subscribed

    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

subscribedメソッドで’room_channel' からデータを取得すること,speakメソッドで各クライアントにbroadcastすることを,それぞれ記述する.

クライアント側の処理

app/javascript/channels/room_channel.js を次のように編集する.

import consumer from "channels/consumer"


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.addEventListener('keypress', function(e) {

  if (e.keyCode === 13) {

    appRoom.speak(e.target.value);

    e.target.value = '';

    e.preventDefault();

  }

})

この処理で,フォームからサーバに送られたデータがブロードキャストされて,各クライアントのreceived()が受け取り,それぞれでアラートが表示されるようになる.

サーバを起動し,ブラウザのウィンドウを複数立ち上げて,どこか一つからメッセージを投げると,全てのウィンドウでアラートが出ることを確認せよ.

あとは,受け取ったクライアント側で,アラートを出すのではなく,Chatのメッセージを表示するように画面を書き換えれば,リアルタイムチャットアプリの完成である.

データベースに記録するように修正

app/channels/room_channel.rb のspeak(data) を修正.

class RoomChannel < ApplicationCable::Channel

  def subscribed

    stream_from 'room_channel'

  end


  def unsubscribed

    # Any cleanup needed when channel is unsubscribed

  end


  def speak(data)

    Message.create! body: data['message']

  end

end

データベースに記録するように変更した.チャネルのspeak処理を消してしまったので,データベースにデータをコミットした後に非同期で動作する処理を追加し,そこでspeak処理を行うように修正する.

speak(data) で Message.create! した直後に,そのまま broadcast() してもよいが,クライアント数が多くなったときにそれらへのブロードキャスト対応を別途切り分けて実施することでシステムを安定して動作させることを狙っている.

app/models/message.rb を次のように修正する.

class Message < ApplicationRecord

  after_create_commit { MessageBroadcastJob.perform_later self }

end

commitの後で(after_create_commit),次に作るジョブMessageBroadcastJob を実施する(perform_later)という記述である.

ジョブの作成

次の手順でジョブを作成する.

$ bin/rails g job MessageBroadcast

      invoke  test_unit

      create    test/jobs/message_broadcast_job_test.rb

      create  app/jobs/message_broadcast_job.rb

$ 

app/jobs/message_broadcast_job.rb を次のように修正する.

class MessageBroadcastJob < ApplicationJob

  queue_as :default


  def perform(message)

    ActionCable.server.broadcast('room_channel',

      { message: ApplicationController.renderer

                   .render(partial: 'messages/message',

                           locals: { message: message }) })

  end

end

ApplicationController.renderer.render() で,message がよしなにレンダリングされたものが,全てのクライアントにbroadcastされる.

クライアント側の修正

サーバから各クライアントに broadcast されたメッセージを,クライアント側のブラウザに表示されているHTMLに反映させるように,JavaScriptによるクライアント側の処理を修正する.

app/javascript/channels/room_channel.js を,次のように変更する.

変更するのは received(data) の部分.

import consumer from "channels/consumer"


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) {

    const messages = document.getElementById('messages');

    messages.insertAdjacentHTML('beforeend', data['message']);

  },


  speak: function(message) {

    return this.perform('speak', {message: message});

  }

});

window.addEventListener('keypress', function(e) {

  if (e.keyCode === 13) {

    appRoom.speak(e.target.value);

    e.target.value = '';

    e.preventDefault();

  }

})

以上で完成である.サーバを起動し,複数のブラウザを用意して,それぞれのブラウザからメッセージを入れて同期することを確認せよ.

完成

完成すると,こんな感じで動くよ.

本記事は「【Rails6.0】ActionCableを使用したライブチャットアプリを実装する手順を解説」というブログを参考にした(ただし記事のとおりやっても,Railsの最新版だとうまくいかなかったので,注意.若干の修正を加えている).そちらにも丁寧な解説が示されているので,参考にされたい.

0 件のコメント:

コメントを投稿