Help us understand the problem. What is going on with this article?

【Rails6】ActionCableでマルチルームチャット

はじめに

初投稿です!
今回はRailsのActionCableを用いて、リアルタイムチャットアプリを作ります。
マルチルームチャットとは、ユーザごとに複数のチャット部屋があり、それらを行き来できるイメージです。

デモ

chat.gif

ソースコードはこちらです。
https://github.com/yamori-masato/chat-app

動作環境

  • Ruby 2.6.3
  • Rails 6.0.3.4

下準備

$ rails new chat-app
$ cd chat-app

モデルの作成

table.png

今回はこのようなモデル設計にしました。
それでは一通りモデルを作成します。

$ rails g model User name:string
$ rails g model Room name:string
$ rails g model UserRoom user:references room:references
$ rails g model Message content:string user:references room:references
$ rails db:migrate
app/models/user.rb
class User < ApplicationRecord
    has_many :user_rooms, dependent: :destroy
    has_many :rooms, through: :user_rooms
end
app/models/room.rb
class Room < ApplicationRecord
    has_many :messages, dependent: :destroy
    has_many :user_rooms, dependent: :destroy
    has_many :users, through: :user_rooms
end
app/models/user_room.rb
class UserRoom < ApplicationRecord
    belongs_to :user
    belongs_to :room
end
app/models/message.rb
class Message < ApplicationRecord
  belongs_to :user
  belongs_to :room

  validates_presence_of :content
end

コントローラーの作成

$ rails g controller rooms index show
config/routes.rb
Rails.application.routes.draw do
  resources :users, only: [] do
    resources :rooms, only: [:index, :show]
  end
end
  • index... 自分が所属するRoom一覧
  • show... チャットルーム閲覧

コントローラを作成して、routesファイルを編集しました。
実際にルーティングを確認してみましょう。

$ rails routes

    ︙
user_rooms GET  /users/:user_id/rooms(.:format)          rooms#index
user_room GET  /users/:user_id/rooms/:id(.:format)       rooms#show
    ︙

2つのルーティングができています。
resourcesのonlyオプションに空の配列を渡しているので、userに対するルーティングが生成されません。

それでは、コントローラーの中身を書いていきます。

app/controller/rooms_controller.rb
class RoomsController < ApplicationController
  before_action :set_user, only: [:index, :show]

  def index
    @rooms = @user.rooms.all
  end

  def show
    @room = @user.rooms.find(params[:id])
    @messages = @room.messages.all
  end

  private
    def set_user
      @user = User.find(params[:user_id])
    end
end

ビューの作成

最低限ですがビューも作っていきます。

app/views/rooms/index.html.erb
<h1>Chat Rooms</h1>

<ul>
    <% @rooms.each do |room| %>
        <li><%= link_to room.name, user_room_path(@user,room) %></li>
    <% end %>
</ul>
app/views/rooms/show.html.erb
<h1><%= @room.name %></h1>

<div id="container">
    <%= render @messages %>
</div>

<%= link_to 'Back', user_rooms_path(@user) %>
app/views/messages/_message.html.erb
<p>
    <strong><%= message.user.name %></strong>
    > <%= message.content %>
</p>

テストデータの登録

ここまでで最低限の機能が作れているはずです。
テストデータを登録して確認してみましょう!

コンソールでデータを登録します。

$ rails c
> user1 = User.create!(name: "user1")
> user2 = User.create!(name: "user2")
> room1 = Room.create!(name: "room1")
> room1.users << [user1, user2]
> Message.create!(content:"hello!", room_id: room1.id, user_id: user1.id)
$ rails s

index.png

show.png

先ほど登録したメッセージがroom1に表示されているはずです。

メッセージ送信

現時点では送信ボタンを押してもメッセージが送られないので、これを送れるようにしたいと思います。
Ajaxで送信できるようにします。

まず、メッセージを新規追加できるようにコントローラーを修正します。

$ rails g controller message create
config/routes.rb
  Rails.application.routes.draw do
    resouces :usersn, only: [] do
      resources :rooms, only: [:index, :show]
    end
+   resources :messages, only: [:create]
  end
app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  def create
    @message = Message.create!(message_params)
  end

  private
    def message_params
      params.require(:message).permit(:content, :room_id, :user_id)
    end
end

フォーム送信後に、このcreateアクションが呼ばれるようにします。

app/controller/rooms_controller.rb
  class RoomsController < ApplicationController
           ︙
    def show
      @room = @user.rooms.find(params[:id])
      @messages = @room.messages.all
+     @message = @room.messages.build
    end
           ︙
  end
app/views/rooms/show.html.erb
           ︙
+ <%= form_with(model: @message, html:{name: "myform"}) do |f| %>
+     <%= f.text_field :content %>
+     <%= f.hidden_field :user_id, value: @user.id %>
+     <%= f.hidden_field :room_id, value: @room.id %>
+     <%= f.submit '送信' %>
+ <% end %>

  <%= link_to 'Back', user_rooms_path(@user) %>

これで送信ボタンを押すたびにajaxリクエストが送られて、messageが新規作成されるようになりました。
form_withにあるhidden_fieldは、描画はされませんが、post送信時にパラメータの1つとして送信されます。
ヘルパーと実際の出力の対応はrailsガイドをご覧ください。classやname属性も自動的に付与されます。

このままでは送信後に入力した文字がそのままなので、送信したら文字が消えるようにしましょう。

app/views/messages/create.js.erb
document.getElementById("message_content").value = ''

ajaxリクエスト後はデフォルトでviews/messages/create.html.erbが呼ばれます。
ここでは送信後にフォームの値を空にしています。

ここで、idに"message_content"を指定しています。
これはform_withヘルパーによって自動生成されたinput要素に付与されるidです。
実際にChromeの検証ツールで見てみましょう。

developer_tool.png

このようにform_withで自動生成されるフォームにはそれぞれ決まったクラス名やid名がついていることがわかります。今回の場合は、inputタグのidにmessage_contentが付与されているのでこれを指定しています。

これでAjaxでメッセージ送信することができるはずなので実際に確認してみましょう!

<送信前>
before.png
<送信後>
after.png

※この時点ではまだリロードしないと描画に反映されません。

WebSocketを用いた双方向通信

いちいちリロードするのは不便なので、リロードなしで描画に反映されるようにしましょう。
そこで使われるのがActionCableです。

ActionCable

ActionCableとは、Rails5で追加されたWebSocketを扱えるフレームワークです。
これを用いることで、閲覧者が画面の操作を行わなくても受動的に新しい情報をリアルタイムで取得できるようになります。

Channelの作成

$ rails g channel room
       create  app/channels/room_channel.rb
    identical  app/javascript/channels/index.js
    identical  app/javascript/channels/consumer.js
       create  app/javascript/channels/room_channel.js

いくつかファイルが作成されたので確認してみましょう。

app/channels/room_channel.rb
class RoomChannel < ApplicationCable::Channel
  def subscribed
    # stream_from "some_channel"
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end
end
app/javascript/channels/room_channel.js
import consumer from "./consumer"

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
  }
});

これはRoomChannelを購読するクライアント側のコードになります。サブスクリプションを作成することでチャネルを購読することができます。

Channelの購読について

先ほど生成されたコードについて詳しく見ていきましょう。

まず、ページが読み込まれたタイミングでRoomChannelが購読されます。これは、デフォルトでjavascript/channels配下のファイルがコンパイル対象になっているからです。

先ほどのコードを少し変更してみましょう。

app/javascript/channels/room_channel.js
  import consumer from "./consumer"

  consumer.subscriptions.create(
-   "RoomChannel",
+   { channel: "RoomChannel", room_id: 1, user_id: 1 },
    {
      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
      }
  });

第一引数を変更しました。
これらの引数は、サブスクリプション作成時にパラメータとしてサーバー側に渡すことができます。

では、サーバー側ではどのようにパラメータを受け取るのでしょうか?
実際に確認してみましょう。

Gemfile
+ gem 'pry-rails'
app/channels/room_channel.rb
  class RoomChannel < ApplicationCable::Channel
    def subscribed
+     binding.pry 
    end

    def unsubscribed
    end
  end
$ bundle install
$ rails s
[1] pry(#<RoomChannel>)> params
=> {"channel"=>"RoomChannel", "room_id"=>1, "user_id"=>1}

クライアント側でサブスクリプションが作成されると、購読されたチャネルのsubscribedメソッドが呼ばれます。
また、第一引数に渡した値がparamsとして受け取れることがわかります。

streamとbroadcast

クライアント側から受け取ったパラメータ(params)を使うことでマルチルームチャットを実装することができます。
先ほどのコードを次のように変更してみましょう。

app/channels/room_channel.rb
class RoomChannel < ApplicationCable::Channel
  def subscribed
    @user = User.find_by(id: params[:user_id])
    reject if @user.nil?
    @room = @user.rooms.find_by(id: params[:room_id])
    reject if @room.nil?
    stream_for(@room)  
  end

  def unsubscribed
  end
end

今回は、あるチャットルームで発言した時、ルーム内のユーザーのみに送信を知らせるようにします。
例えば、room1で発言した内容はroom1に所属するメンバーにブロードキャストされるといった感じになります。
そこで、チャットルームをストリームと紐付けます。

また、実在しないユーザーや、ユーザーが所属しないチャットルームであった場合はrejectしています。
rejectすると、チャネルの購読を拒否することができます。

チャットルームとストリームを紐づける

今のままではどの部屋に対してもuser_id: 1, room_id: 1としてストリームが作成されてしまいます。これではどのチャットルームで発言しても、user1のroom1での発言と見されてしまうのでこれを修正していきます。

app/views/rooms/show.html.erb
  <h1><%= @room.name %></h1>
+ <div id="data" data-room-id="<%= @room.id %>" data-user-id="<%= @user.id %>"></div>

  <div id="container">
      <%= render @messages %>
  </div>

  <%= form_with(model: @message, html:{name: "myform"}) do |f| %>
      <%= f.text_field :content, rows: '1'%>
      <%= f.hidden_field :user_id, value: @user.id %>
      <%= f.hidden_field :room_id, value: @room.id %>
      <%= f.submit '送信' %>
  <% end %>

  <%= link_to 'Back', user_rooms_path(@user) %>
app/javascript/channels/room_channel.js
import consumer from "./consumer"

document.addEventListener("turbolinks:load", () => {
  const data = document.getElementById("data")
  const room_id = data.getAttribute("data-room-id")
  const user_id = data.getAttribute("data-user-id")

  consumer.subscriptions.create(
    { channel: "RoomChannel", room_id: room_id, user_id: user_id },
    {
      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
      }
    }
  )
})

room_idとuser_idは、view側から渡してあげます。
また、DOMの読み込み完了を待ちたいので"turbolinks:load"を記述します。

これにより、/users/1/rooms/1にアクセスすれば、room_id: 1, user_id: 1としてRoomChannelがサブスクライブされます。

このままではチャットルーム以外のページでも読み込まれてしまう為、document.getElementById("data")が見つからずにエラーになってしまいます。なのでif文で分岐してあげます。

app/javascript/channels/room_channel.js
  import consumer from "./consumer"

  document.addEventListener("turbolinks:load", () => {
    const data = document.getElementById("data")
+   if (data === null) {
+     return
+   }
    const channel = "RoomChannel"
    const room_id = data.getAttribute("data-room-id")
    const user_id = data.getAttribute("data-user-id")
          ︙

また、チャットルームのページを開くたびに同じチャットルームに対するサブスクリプションが複数作成されてしまう恐れがあるのでこれも修正します。

app/javascript/channels/room_channel.js
  import consumer from "./consumer"

  document.addEventListener("turbolinks:load", () => {
    const data = document.getElementById("data")
    if (data === null) {
      return
    }
    const channel = "RoomChannel"
    const room_id = data.getAttribute("data-room-id")
    const user_id = data.getAttribute("data-user-id")
+   if (!isSubscribed(channel, room_id, user_id)) {
      consumer.subscriptions.create(
          ︙
      )
   }
  })

  // helper
+ const isSubscribed = (channel, room_id, user_id) => {
+   const identifier = `{"channel":"${channel}","room_id":"${room_id}","user_id":"${user_id}"}`
+   const subscription = consumer.subscriptions.findAll(identifier)
+   return !!subscription.length
+ }

チャットルームごとにストリームを紐づけることができました。

ブロードキャストする

チャットルームとストリームを紐づけることができたので、ブロードキャストする処理を書いていきます。
今回のチャットアプリでは、メッセージを新規作成したタイミングで知らせたいので、そのままコントローラに記述します。

app/controllers_messages_controller.rb
  class MessagesController < ApplicationController
    def create
      @message = Message.create!(message_params)
      @room = Room.find_by(id: message_params[:room_id])
+     RoomChannel.broadcast_to(@room, message: @message.template)
    end

    private
      def message_params
        params.require(:message).permit(:content, :room_id, :user_id)
      end
  end
app/models/message.rb
  class Message < ApplicationRecord
    belongs_to :user
    belongs_to :room

    validates_presence_of :content

+   def template
+     ApplicationController.renderer.render partial: 'messages/message', locals: { message: self }
+   end
  end
app/javascript/channels/room_channel.js
import consumer from "./consumer"

document.addEventListener("turbolinks:load", () => {
          ︙
  consumer.subscriptions.create(
    { channel: "RoomChannel", room_id: room_id, user_id: user_id },
    {
      connected() {
      },

      disconnected() {
      },

      received(data) {
+       const container = document.getElementById("container")
+       container.insertAdjacentHTML('beforeend', data['message'])
      }
    }
  )
})

broadcastされるとreceived(data)が呼び出されます。
受け取ったデータは、data['message']のようにして受け取れます。
これをチャットログの末尾に挿入してあげれば完成です!!

参考文献

おわりに

次回は、このチャットアプリにログイン機能を追加したいと思います!
ご意見アドバイス等ぜひお願いいたします。

yamooo
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away