#はじめに
DM機能でActionCableを使ってみた。
ActionCableを使えばページをリロードしなくてもメッセージがリアルタイムに表示されるのでかっこいい。
###お世話になった記事
ほとんど知識がなかったので下記のサイトを参考にさせていただきながら作成しました。
リアルタイムチャットは誰でもつくれる~Action CableでDM機能を作ろう~
[Rails5.2 ActionCable]シンプルなチャットアプリを作ってみた!!
Rails 5 + ActionCableで作る!シンプルなチャットアプリ(DHH氏のデモ動画より)
###開発環境
ruby 2.6.3
Rails 5.2.6
#前提条件
DM機能を一から作っているので前半不要な方は読み飛ばしてください。
今回はユーザーIDを用いて「誰とチャットしているのか、誰が発言しているのか」を明確にするため、以下のようなER図で開発します。
*User・・・deviseを使用
*Room・・・トークルーム
*Message・・・1対1でのやり取り
*RoomUser・・・中間テーブル RoomとUserをつなげるイメージ。Userが2人入る。Room1つに対してRoomUserは2つできる感じ。
#作成開始
###rails new
rails new ActionCable
###jqueryの導入
gem 'jquery-rails'
$ bundle install
application.jsに下記2行を追加
//= require rails-ujs
//= require activestorage
//= require turbolinks
//= require jquery *追加*
//= require jquery_ujs *追加*
//= require_tree .
###ユーザー認証機能の導入
gem 'devise'
$ bundle install
######deviseの初期設定
$ rails g devise:install
######モデルの作成+カラム(name)の追加
$ rails g devise User name:string
$ rails db:migrate
######nameカラムでログインできるようにする
class ApplicationController < ActionController::Base
before_action :configure_permitted_parameters, if: :devise_controller?
protected
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up, keys: [:name])
end
end
######viewページの生成
今のままだと新規登録時にemailとpasswordを入力する項目しかないため。
※http://localhost:3000/
$ rails g devise:views
######新規登録画面の表示内容を編集
:
<!-- 追加 -->
<div class="field">
<%= f.label :name %><br />
<%= f.text_field :name, autofocus: true %>
</div>
<!-- ここまで -->
<div class="field">
<%= f.label :email %>
:
######ログアウトボタンの表示
新規登録orログイン後はログアウトボタンがないと一生ログインしたままになるので、ログアウトボタンを表示させます。
:
<body>
<% if user_signed_in? %>
<li>
<%= link_to "ログアウト", destroy_user_session_path, method: :delete %>
</li>
<% else %>
<li>
<%= link_to "新規登録", new_user_registration_path %>
</li>
<li>
<%= link_to "ログイン", new_user_session_path %>
</li>
<% end %>
<%= yield %>
</body>
:
これで、一度新規登録してみます。
Yay! You’re on Rails! が表示されればOKです。
###必要モデルの作成
先ほどのER図を確認しながら、Roomモデル、RoomUserモデル、Messageモデルを作成していきます。必要なカラムも同時に追加。
(外部キーreference型で追加した方がいいのかと思いつつintegerで追加してしまった、、、
🐰参考サイト Railsの外部キー制約とreference型について)
$ rails g model Room name:string
$ rails g model RoomUser user_id:integer room_id:integer
$ rails g model Message user_id:integer room_id:integer text:string
$ rails db:migrate
モデルと必要カラムの作成が完了しました。
######アソシエーションの設定
:
has_many :messages, dependent: :destroy
has_many :room_users, dependent: :destroy
:
class Room < ApplicationRecord
has_many :messages, dependent: :destroy
has_many :room_users, dependent: :destroy
end
class RoomUser < ApplicationRecord
belongs_to :user
belongs_to :room
end
class Message < ApplicationRecord
belongs_to :user
belongs_to :room
end
######コントローラの作成
usersコントローラ・・・index,show
roomsコントローラ・・・show
$ rails g controller users index show
$ rails g controller rooms show
######ルーティングの設定
root to: 'users#index'
devise_for :users
resources :users, only: [:show]
resources :rooms, only: [:show, :create]
###ユーザー関連のページを表示させる
######ユーザー一覧ページ
ユーザーの一覧をまとめて表示させるページ。
ここから、ユーザー詳細ページに飛ぶ。
def index
@users = User.all
end
def show
end
<h1>ユーザー一覧ページ</h1>
<% @users.each do |user| %>
<%= link_to user.name, user_path(user.id) %>
<% end %>
ユーザー詳細画面を表示
ユーザー詳細画面から、トークを始められるようにする
:
def index
@users = User.all
end
def show
@user = User.find(params[:id])
#RoomUserモデルから自分のものを検索
@current_user_room_user = RoomUser.where(user_id: current_user.id)
#RoomUserモデルから今開いているページのユーザーのものを検索
@user_room_user = RoomUser.where(user_id: @user.id)
#もし今開いてるユーザーページが自分のページじゃなかったら
unless @user.id == current_user.id
#自分が関係あるRoomUserをeachで引っ張り出してくる
@current_user_room_user.each do |cu|
#かつ、今開いているページのユーザーに関係あるRoomUserをeachで引っ張り出してくる
@user_room_user.each do |u|
#もし一致するものがあったら
if cu.room_id == u.room_id then
#すでに部屋があるということ
@is_room = true
#そのroomのid
@room_id = cu.room_id
end
end
end
#一致する部屋がなかったら
unless @is_room
#新しく作る
@room = Room.new
@room_user = RoomUser.new
end
end
end
:
<h1>ユーザー詳細ページ</h1>
<h2>ユーザー名</h2>
<%= @user.name %>
<h2>トーク関連</h2>
<% if @is_room == true %>
<%= link_to "トークを始める", room_path(@room_id) %>
<% else %>
<!-- roomsテーブルに情報を送信 -->
<%= form_with model: @room, local: true do |f|%>
<!-- room_usersテーブルに情報を送信 -->
<%= hidden_field_tag 'room_user[user_id]', @user.id %>
<%= f.submit "チャットを始める", class: "send" %>
<% end %>
<% end %>
すでにトークルームがある時=> @is_room = true トークルームへのリンクへ飛ぶ
新しくトークを始める時=> form_with でルームを新しく作る。RoomUserも一緒に作る。
###トークルームを作る
######トークルームとRoomUserを作る
class RoomsController < ApplicationController
def create
@room = Room.create
# RoomUserを2つ作る
# 自分のRoomUserを作る
@join_current_user = RoomUser.create(user_id: current_user.id, room_id: @room.id)
# 相手のRoomUserを作る
@join_user = RoomUser.create(join_room_params)
redirect_to room_path(@room)
end
def show
@room = Room.find(params[:id])
end
private
def join_room_params
params.require(:room_user).permit(:user_id, :room_id).merge(room_id: @room.id)
end
end
1つのRoomに2つのRoomUserが作られた状態ができた。2人のuserを呼び出す。
######トークルームでメッセージを表示させる
:
def show
@room = Room.find(params[:id])
<!-- 追加 -->
#roomに関するRoomUserが存在していれば
if RoomUser.where(user_id: current_user.id, room_id: @room.id).present?
@messages = @room.messages
@message = Message.new
@room_users = @room.room_users
else
redirect_back(fallback_location: root_path)
end
<!-- ここまで -->
end
:
######ビューページを作る
<h1>ルーム詳細ページ</h1>
<h2>参加者</h2>
<!-- roomに入っているroom_userは2人いるのでeachで引っ張る -->
<% @room_users.each do |e| %>
<%= link_to e.user.name, user_path(e.user_id) %>
<% end %>
<h2>トーク</h2>
<!-- 入力フォーム -->
<!-- form_withだと余計なものが生成されるので<form>タグで実装 -->
<form>
<input type="text" data-behavior="room_speak">
</form>
<% if @messages.present? %>
<% @messages.each do |m| %>
<div>
<%= m.text %>
<%= m.created_at.strftime("%Y-%m-%d %H:%M") %>
</div>
<% end %>
<% end %>
######部分テンプレで切り出し
先ほどのroom/show.html.erbから
メッセージを表示させる部分だけ部分テンプレートとして切り出します。
:
<% if @messages.present? %>
<% @messages.each do |m| %>
<!-- 削除 -->
<div>
<%= m.text %>
<%= m.created_at.strftime("%Y-%m-%d %H:%M") %>
</div>
<!-- ここまで -->
<% end %>
<% end %>
<% if @messages.present? %>
<% @messages.each do |m| %>
<!-- 追加 -->
<%= render "messages/message" %>
<!-- ここまで -->
<% end %>
<% end %>
:
viewsフォルダ直下に、messagesフォルダと_message.html.erbを作成します。
:
<div>
<%= m.text %>
<%= m.created_at.strftime("%Y-%m-%d %H:%M") %>
</div>
:
ここまででUserモデル、Roomモデル、RoomUserモデルの作成、定義と下準備が完了したので
やっと、ActionCable導入へと移ります!
###チャネルを作成する
$ rails g channel room speak
speakはメソッド名です。
######クライアントサイドの処理をするファイル
app/assets/javascripts/channels/room.coffee
app/assets/javascripts/cable.js
######サーバーサイドの処理をするファイル
app/channels/room_channel.rb
以上3ファイルをいじりながら実装していきます。
###設定
######クライアント-サーバー間のやり取りを設定
class RoomChannel < ApplicationCable::Channel
def subscribed
#クライアントが受信するストリームを設定
stream_from "room_channel" #追加
end
:
:
speak: (message) ->
#performメソッドでブラウザから入力されたデータをサーバーサイドへ送信
@perform 'speak', message: message
# 'keypress' キーが押された時発火する
$(document).on 'keypress', '[data-behavior~=room_speak]', (event) ->
# key13= returnキー
if event.keyCode is 13
# event.target.valueは入力されたデータ
App.room.speak event.target.value
event.target.value = ''
event.preventDefault()
:
returnキーを押したときに発火されサーバーサイドへデータが送信されるようにする。
######受け取ったデータをアラートで表示してみる
:
received: (data) ->
alert data['message']
# Called when there's incoming data on the websocket for this channel
:
フォームに入力したデータが、アラートで表示されました。
######今度はブラウザ上に表示してみる
:
received: (data) ->
alert data['message'] #削除
$('#messages_all').append data['message'] #追加
:
ブラウザに表示されました。データベースに保存していないので、ページ更新すると消えてなくなります。
次にMessageモデルにデータを保存する処理を作っていきたいと思います。
###データ保存&非同期処理
Active Jobというものを導入して、非同期処理できるようにする。
######room_channel.rbのspeakアクションを書き換える
def speak(data)
# current_userは使えないので別途定義するか、rooms/showページからidを渡す
# rooms/show.html.erbのdiv idからmessage,user_id,room_idを受け取る
Message.create! text: data['message'], user_id: data['user_id'], room_id: data['room_id']
end
Messageモデルにデータを保存するとき、messageの内容だけでなく、user_idとroom_idを受け取り生成する必要があります。
######user_idとroom_idが渡るように記述を変更
<% if @messages.present? %>
<% @messages.each do |m| %>
<div>
<%= m.text %>
<%= m.user.name %> <!-- 追加 -->
<%= m.created_at.strftime("%Y-%m-%d %H:%M") %>
</div>
<% end %>
<% end %>
:
<h2>トーク</h2>
<!-- 入力フォーム -->
<div id="message" data-room_id="<%= params[:id] %>" data-user_id="<%= current_user.id %>">
<!-- form_withだと余計なものが生成されるので<form>タグで実装 -->
<form>
<input type="text" data-behavior="room_speak">
</form>
</div>
:
:
speak: (message) ->
#performメソッドでブラウザから入力されたデータをサーバーサイドへ送信
@perform 'speak', { message: message, room_id: $('#message').data('room_id'), user_id: $('#message').data('user_id') }
:
######ブロードキャスト処理の実行
rails g job MessageBroadcast
生成されたファイルを編集していきます。
#無記入投稿とエンター長押し連続投稿を防ぐ
validates :text, presence: true
#MessageBroadcastJobを走らせるタイミングはMessageがcreateされた後
after_create_commit {MessageBroadcastJob.perform_later self}
Messageがクリエイトされた後、ブロードキャスト処理が走るように一文を記述します。
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
###完成です
###課題
######current_userが渡らない問題
deviseを導入すると使えるcurrent_user、そのままActionCableに渡せない問題。
解決策は二つありそうです。
①viewページからcurrent_userのIDを渡す
今回はこっちで実装しました
🐰参考にさせていただいたサイト
Action Cableへのidの受け渡し
②ActionCableのconnection.rbで別途current_userを定義する
🐰参考にさせていただいたサイト
Rails公式ドキュメント
【ActionCable】チャンネル接続/購読時にユーザ認証を行う
ActionCableにおいてのcurrent_userについて読み解く|Rails
######デプロイするまで完成したかわからない
本当はブラウザを二つ開いてそれぞれ別のユーザーでログインして動作チェックをしたいんだけど、本番環境でないとできないので、ここで終わりです。
間違っている内容がありましたら、コメントでぜひ教えてください。