LoginSignup
2
1

More than 1 year has passed since last update.

rails5.2.6で複数モデルのDM機能(ActionCable)を実装

Last updated at Posted at 2022-01-16

はじめに

DM機能でActionCableを使ってみた。
ActionCableを使えばページをリロードしなくてもメッセージがリアルタイムに表示されるのでかっこいい。

お世話になった記事

ほとんど知識がなかったので下記のサイトを参考にさせていただきながら作成しました。

リアルタイムチャットは誰でもつくれる~Action CableでDM機能を作ろう~
[Rails5.2 ActionCable]シンプルなチャットアプリを作ってみた!!
Rails 5 + ActionCableで作る!シンプルなチャットアプリ(DHH氏のデモ動画より)

開発環境

ruby 2.6.3
Rails 5.2.6

前提条件

DM機能を一から作っているので前半不要な方は読み飛ばしてください。
今回はユーザーIDを用いて「誰とチャットしているのか、誰が発言しているのか」を明確にするため、以下のようなER図で開発します。
2_ActionCable.drawio.png

*User・・・deviseを使用
*Room・・・トークルーム
*Message・・・1対1でのやり取り
*RoomUser・・・中間テーブル RoomとUserをつなげるイメージ。Userが2人入る。Room1つに対してRoomUserは2つできる感じ。

作成開始

rails new

rails new ActionCable

jqueryの導入

Gemfile
gem 'jquery-rails'
$ bundle install

application.jsに下記2行を追加

app/assets/javascripts/application.js
//= require rails-ujs
//= require activestorage
//= require turbolinks
//= require jquery *追加*
//= require jquery_ujs *追加*
//= require_tree .

ユーザー認証機能の導入

Gemfile
gem 'devise'
$ bundle install
deviseの初期設定
$ rails g devise:install
モデルの作成+カラム(name)の追加
$ rails g devise User name:string
$ rails db:migrate
nameカラムでログインできるようにする
app/controllers/application_controller.rb
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
新規登録画面の表示内容を編集
app/views/devise/registrations/new.html.erb
:
<!-- 追加 -->
 <div class="field">
   <%= f.label :name %><br />
   <%= f.text_field :name, autofocus: true %>
 </div>
<!-- ここまで -->

 <div class="field">
   <%= f.label :email %>
:
ログアウトボタンの表示

新規登録orログイン後はログアウトボタンがないと一生ログインしたままになるので、ログアウトボタンを表示させます。

app/views/layouts/application.html.erb
:
<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

モデルと必要カラムの作成が完了しました。

アソシエーションの設定
app/models/user.rb
:
  has_many :messages, dependent: :destroy
  has_many :room_users, dependent: :destroy
:
app/models/room.rb
class Room < ApplicationRecord
  has_many :messages, dependent: :destroy
  has_many :room_users, dependent: :destroy
end
app/models/room_user.rb
class RoomUser < ApplicationRecord
  belongs_to :user
  belongs_to :room
end
app/models/message.rb
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
ルーティングの設定
config/routes.rb
 root to: 'users#index'
 devise_for :users
 resources :users, only: [:show]
 resources :rooms, only: [:show, :create]

ユーザー関連のページを表示させる

ユーザー一覧ページ

ユーザーの一覧をまとめて表示させるページ。
ここから、ユーザー詳細ページに飛ぶ。

app/controllers/users_controller.rb
  def index
    @users = User.all
  end

  def show
  end
app/views/users/index.html.erb
<h1>ユーザー一覧ページ</h1>
  <% @users.each do |user| %>
    <%= link_to user.name, user_path(user.id) %>
  <% end %>
ユーザー詳細画面を表示

ユーザー詳細画面から、トークを始められるようにする

app/controllers/users_controller.rb
:
  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
:
app/views/users/show.html.erb
<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を作る
app/controllers/rooms_controller.rb
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を呼び出す。

トークルームでメッセージを表示させる
app/controllers/rooms_controller.rb
:
  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
:
ビューページを作る
app/views/rooms/show.html.erb
<h1>ルーム詳細ページ</h1>
  <h2>参加者</h2>
    <!-- roomに入っているroom_user2人いるので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から
メッセージを表示させる部分だけ部分テンプレートとして切り出します。

app/views/rooms/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 %>
app/views/rooms/show.html.erb
    <% if @messages.present? %>
      <% @messages.each do |m| %>
        <!-- 追加 -->
        <%= render "messages/message" %>
        <!-- ここまで -->
      <% end %>
    <% end %>
:

viewsフォルダ直下に、messagesフォルダと_message.html.erbを作成します。

app/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ファイルをいじりながら実装していきます。

設定

クライアント-サーバー間のやり取りを設定
app/channels/room_channel.rb
class RoomChannel < ApplicationCable::Channel
  def subscribed
    #クライアントが受信するストリームを設定
    stream_from "room_channel" #追加
  end
:
app/assets/javascripts/channels/room.coffee
:
  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キーを押したときに発火されサーバーサイドへデータが送信されるようにする。

受け取ったデータをアラートで表示してみる
app/assets/javascripts/channels/room.coffee
:
  received: (data) ->
    alert data['message']
    # Called when there's incoming data on the websocket for this channel
:

フォームに入力したデータが、アラートで表示されました。

今度はブラウザ上に表示してみる
app/assets/javascripts/channels/room.coffee
:
  received: (data) ->
    alert data['message'] #削除
    $('#messages_all').append data['message'] #追加
:

ブラウザに表示されました。データベースに保存していないので、ページ更新すると消えてなくなります。
次にMessageモデルにデータを保存する処理を作っていきたいと思います。

データ保存&非同期処理

Active Jobというものを導入して、非同期処理できるようにする。

room_channel.rbのspeakアクションを書き換える
app/channels/room_channel.rb
  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が渡るように記述を変更
app/views/messages/_message.html.erb
    <% 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 %>
app/views/rooms/show.html.erb
:
  <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>
:
app/assets/javascripts/channels/room.coffee
:
  speak: (message) ->
    #performメソッドでブラウザから入力されたデータをサーバーサイドへ送信
    @perform 'speak', { message: message, room_id: $('#message').data('room_id'), user_id: $('#message').data('user_id') }

:
ブロードキャスト処理の実行
rails g job MessageBroadcast

生成されたファイルを編集していきます。

app/models/message.rb
  #無記入投稿とエンター長押し連続投稿を防ぐ
  validates :text, presence: true
  #MessageBroadcastJobを走らせるタイミングはMessageがcreateされた後
  after_create_commit {MessageBroadcastJob.perform_later self}

Messageがクリエイトされた後、ブロードキャスト処理が走るように一文を記述します。

app/jobs/message_broadcast_job.rb
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

デプロイするまで完成したかわからない

本当はブラウザを二つ開いてそれぞれ別のユーザーでログインして動作チェックをしたいんだけど、本番環境でないとできないので、ここで終わりです。

間違っている内容がありましたら、コメントでぜひ教えてください。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1