Rails(6系)でActionCableを使用してリアルタイムDM機能を実装する
サンプルリポジトリ
こちらから、実装済みソースコードが見られます。
注意
サンプルのリポジトリは、Dockerで起動することを前提に作成されています。
Dockerを使用しない場合は、appフォルダ以下で通常のrailsの起動処理を行ってください。
なお、記事ではDockerを使用せず導入する手順で記載していますのでご注意ください。
この記事は、一旦最後まで実装した後に動く仕様になっています。
適宜動かす場合は、それなりにアレンジしてください。
ソースコードのコメントについて
基本的にコメントを多めに記載しています。こちらを参考に実装して、ロジックの理解を深めてください。
Seedファイルにユーザーの初期情報が入っています。ユーザーの作成が面倒な場合にご利用ください。
今回のゴール
ActionCableを使用してリアルタイムDM機能を実装する
前提条件
- jQueryの導入は完了していること。
- Deviseの導入が完了していること。
- 今回は、BootstrapなどのCSSを一切使用しません。
トップページの作成
Homes#indexを作成します。
rails g controller homes index
表示だけなので、簡潔に記述します。
class HomesController < ApplicationController
def index; end
end
ビューでは、ログインしている人とそうでない人を分岐しています。
<h1>Homes#index</h1>
<p>Find me in app/views/homes/index.html.erb</p>
<ul>
<li>
<%= link_to '新規登録', new_user_registration_path %>
</li>
<li>
<%= link_to 'ログイン', new_user_session_path %>
</li>
<%# ログイン済みならログアウト・ユーザー一覧リンクを表示 %>
<% if user_signed_in? %>
<li>
<%= link_to 'ログアウト', destroy_user_session_path, method: :delete %>
</li>
<li>
<%= link_to 'DM対象ユーザー一覧', chats_path %>
</li>
<% end %>
</ul>
モデル作成
rails g model Room
rails g model Chat
rails g model UserRoom
各種マイグレーションの中身は、以下の通りにします。
references型と外部キー制約に関しては、こちらが詳しい記事ですので参考にしてみてください。
class CreateRooms < ActiveRecord::Migration[6.1]
def change
create_table :rooms do |t|
t.timestamps
end
end
end
class CreateChats < ActiveRecord::Migration[6.1]
def change
create_table :chats do |t|
t.references :user, foreign_key: true
t.references :room, foreign_key: true
t.text :message, null: false
t.timestamps
end
end
end
class CreateUserRooms < ActiveRecord::Migration[6.1]
def change
create_table :user_rooms do |t|
t.references :user, foreign_key: true
t.references :room, foreign_key: true
t.timestamps
end
end
end
アソシエーション
今回のアソシエーションは、以下のER図の感じになります。
class Room < ApplicationRecord
has_many :user_rooms
has_many :chats
end
class Chat < ApplicationRecord
belongs_to :user
belongs_to :room
end
class UserRoom < ApplicationRecord
belongs_to :user
belongs_to :room
end
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
has_many :user_rooms
has_many :chats
end
ChatRoom作成のコントローラー
rails g controller chats index show
class ChatsController < ApplicationController
before_action :authenticate_user!
def index
@users = User.all
end
def show
# チャットのターゲットユーザー情報を取得
@user = User.find(params[:id])
# 自分と関連づいたチャットルームを配列で取得
rooms = current_user.user_rooms.pluck(:room_id)
# ターゲットユーザーと自分が、関連づいたチャットルームを取得
user_room = UserRoom.find_by(user_id: @user.id, room_id: rooms)
if user_room.nil?
# ターゲットユーザーと自分が、関連づいたチャットルームが「ない」場合
# ---
# チャットルームを生成(ルームID)
@room = Room.create()
# ---
# ターゲットユーザーと自分を関連付けるチャットルームレコードを生成
UserRoom.create(user_id: current_user.id, room_id: @room.id)
UserRoom.create(user_id: @user.id, room_id: @room.id)
else
# ターゲットユーザーと自分が、関連づいたチャットルームが「ある」場合
# ---
# チャットルームの情報(ルームID)を返す
@room = user_room.room
end
end
end
ビュー
<h1>Chats#index</h1>
<p>Find me in app/views/users/index.html.erb</p>
<ul>
<% @users.each do |user| %>
<% if current_user != user # 自分以外のユーザー %>
<li>
<%= link_to"#{user.email}とチャットする", chat_path(user.id) %>
</li>
<% end %>
<% end %>
</ul>
<h1>Chats#show</h1>
<p>Find me in app/views/chats/show.html.erb</p>
<div id="chats" data-room_id="<%= @room.id %>">
<label for="message">Chat Text</label><br>
<textarea name="message" id="message" cols="30" rows="10"></textarea><br>
<small>Shift + Enterで送信</small>
<h2>Chats --></h2>
<% @room.chats.each do |message| %>
<%= render partial: 'message', locals: { message: message, current_user: nil } %>
<% end %>
</div>
<div class="message">
<p>
<%# 部分テンプレートで渡ってくるcurrent_user内に、ユーザー情報がなければActionCable経由として判断 %>
<% if current_user.nil? %>
<%# DBから受け取る場合の表示 %>
<small><%= message.user.email %></small><br>
<%= message.message %>
<% else %>
<%# ActionCableから受け取る場合の表示 %>
<small><%= current_user.email %></small><br>
<%= message['message'] %>
<% end %>
</p>
</div>
ルーティング
Rails.application.routes.draw do
devise_for :users
resources :chats, only: %w(index show)
root to: 'homes#index'
end
ActionCable
ActionCable内部では、通常Deviseのcurrent_userが使用できないので、新たに定義します。
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
protected
# wardenを使用して、ユーザー情報を取得する
# 情報が見つからなかった場合、例外として処理される
# 例外処理で未ログインとして処理
def find_verified_user
User.find_by(id: env['warden'].user.id)
rescue
reject_unauthorized_connection
end
end
end
chatチャンネルで発言(speak)用のメソッドを用意します。
rails g channel chat speak
該当のファイルを書き換えます。
params[‘room_id’]となっているのは、params[:room_id]の間違いではありません。
jsからデータを受け取るためにこのような記述になっています。
class ChatsChannel < ApplicationCable::Channel
def subscribed
# WebSocketのチャンネル設定
# チャンネル名のroom_idは、chats_channel.jsで生成している。
stream_from "chats_channel_#{params['room_id']}"
end
def unsubscribed; end
# dataの追加
def speak(data)
# Chatにデータを保存
Chat.create(
user_id: current_user.id,
room_id: params['room_id'],
message: data['message']
)
# 部分テンプレートをWebSocket経由で送り出す。
# render_messageで部分テンプレートに文字を埋め込みmessageとして送り出している。
ActionCable.server.broadcast "chats_channel_#{params['room_id']}", message: render_message(data)
end
private
def render_message(data)
# renderではなくrendererに注意してください
# rendererは、コントローラの制約を受けずに任意のビューテンプレートをレンダリングします
ApplicationController.renderer.render(partial: 'chats/message', locals: { message: data, current_user: current_user })
end
end
import consumer from "./consumer"
$(document).on('turbolinks:load', function () {
const chats = $('#chats') // chatsのエレメント
// ルームIDを#chats内のdata-room-idから取得し、
// chats_channel.rbでも使えるようにする
const room = consumer.subscriptions.create(
{channel: "ChatsChannel", room_id: chats.data('room_id')}, {
connected() {
// 未使用
},
disconnected() {
// 未使用
},
received(data) {
// 受け取ったデータを追加する
// dataには、chats_channel.rbから送られるパラメーター名に
// 部分テンプレートに文字が埋め込まれているものが送られてくるので
// 配列からmessageを抜き出し追加している。
chats.append(data['message'])
},
speak: function (message) {
return this.perform('speak', {message: message})
}
});
// 入力フォームの制御
$(document).ready(function () {
// 入力エリアのエレメント
const textElement = $('#message')
// 入力エリアのEnterKey検出
textElement.keypress(function (event) {
if (event.shiftKey && event.keyCode === 13) {
// speakを呼び出し
room.speak(textElement.val())
// 入力エリアを空にする
textElement.val('')
// submitイベントの取り消し
// 改行が含まれてしまうための回避
event.preventDefault()
}
})
})
})
最後に
以上で、実装は完了のはずです。うまく動かない場合は、サンプルリポジトリを確認してください。
うまく活用すれば、グループチャットなんかも実装できそうな感じです。
動いているかの確認は、プライベートウインドウか違うブラウザを使って違うアカウントでログインして確認してみてください。