はじめに
初投稿です!
今回はRailsのActionCableを用いて、リアルタイムチャットアプリを作ります。
マルチルームチャットとは、ユーザごとに複数のチャット部屋があり、それらを行き来できるイメージです。
デモ
ソースコードはこちらです。
https://github.com/yamori-masato/chat-app
動作環境
- Ruby 2.6.3
- Rails 6.0.3.4
下準備
$ rails new chat-app
$ cd chat-app
モデルの作成
今回はこのようなモデル設計にしました。
それでは一通りモデルを作成します。
$ 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
class User < ApplicationRecord
has_many :user_rooms, dependent: :destroy
has_many :rooms, through: :user_rooms
end
class Room < ApplicationRecord
has_many :messages, dependent: :destroy
has_many :user_rooms, dependent: :destroy
has_many :users, through: :user_rooms
end
class UserRoom < ApplicationRecord
belongs_to :user
belongs_to :room
end
class Message < ApplicationRecord
belongs_to :user
belongs_to :room
validates_presence_of :content
end
コントローラーの作成
$ rails g controller rooms index show
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に対するルーティングが生成されません。
それでは、コントローラーの中身を書いていきます。
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
ビューの作成
最低限ですがビューも作っていきます。
<h1>Chat Rooms</h1>
<ul>
<% @rooms.each do |room| %>
<li><%= link_to room.name, user_room_path(@user,room) %></li>
<% end %>
</ul>
<h1><%= @room.name %></h1>
<div id="container">
<%= render @messages %>
</div>
<%= link_to 'Back', user_rooms_path(@user) %>
<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
先ほど登録したメッセージがroom1に表示されているはずです。
メッセージ送信
現時点では送信ボタンを押してもメッセージが送られないので、これを送れるようにしたいと思います。
Ajaxで送信できるようにします。
まず、メッセージを新規追加できるようにコントローラーを修正します。
$ rails g controller message create
Rails.application.routes.draw do
resouces :usersn, only: [] do
resources :rooms, only: [:index, :show]
end
+ resources :messages, only: [:create]
end
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アクションが呼ばれるようにします。
class RoomsController < ApplicationController
︙
def show
@room = @user.rooms.find(params[:id])
@messages = @room.messages.all
+ @message = @room.messages.build
end
︙
end
︙
+ <%= 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属性も自動的に付与されます。
このままでは送信後に入力した文字がそのままなので、送信したら文字が消えるようにしましょう。
document.getElementById("message_content").value = ''
ajaxリクエスト後はデフォルトでviews/messages/create.html.erb
が呼ばれます。
ここでは送信後にフォームの値を空にしています。
ここで、idに"message_content"を指定しています。
これはform_withヘルパーによって自動生成されたinput要素に付与されるidです。
実際にChromeの検証ツールで見てみましょう。
このようにform_withで自動生成されるフォームにはそれぞれ決まったクラス名やid名がついていることがわかります。今回の場合は、inputタグのidにmessage_contentが付与されているのでこれを指定しています。
これでAjaxでメッセージ送信することができるはずなので実際に確認してみましょう!
※この時点ではまだリロードしないと描画に反映されません。
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
いくつかファイルが作成されたので確認してみましょう。
class RoomChannel < ApplicationCable::Channel
def subscribed
# stream_from "some_channel"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
end
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
配下のファイルがコンパイル対象になっているからです。
先ほどのコードを少し変更してみましょう。
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
}
});
第一引数を変更しました。
これらの引数は、サブスクリプション作成時にパラメータとしてサーバー側に渡すことができます。
では、サーバー側ではどのようにパラメータを受け取るのでしょうか?
実際に確認してみましょう。
︙
+ gem 'pry-rails'
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)を使うことでマルチルームチャットを実装することができます。
先ほどのコードを次のように変更してみましょう。
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での発言と見されてしまうのでこれを修正していきます。
<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) %>
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文で分岐してあげます。
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")
︙
また、チャットルームのページを開くたびに同じチャットルームに対するサブスクリプションが複数作成されてしまう恐れがあるのでこれも修正します。
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
+ }
チャットルームごとにストリームを紐づけることができました。
ブロードキャストする
チャットルームとストリームを紐づけることができたので、ブロードキャストする処理を書いていきます。
今回のチャットアプリでは、メッセージを新規作成したタイミングで知らせたいので、そのままコントローラに記述します。
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
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
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']のようにして受け取れます。
これをチャットログの末尾に挿入してあげれば完成です!!
参考文献
- 描いて理解する Action Cable
- Rails5のActionCableでイカゲームもどきを作ってみた
- 【Rails6】(送信時のリロード無し!)Action CableでSlack風チャットアプリを作成
- 【Rails6.0】ActionCableを使用したライブチャットアプリを実装する手順を解説
- Rails 6: Subscribing to Multiple Channels in Action Cable
おわりに
次回は、このチャットアプリにログイン機能を追加したいと思います!
ご意見アドバイス等ぜひお願いいたします。