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

[Rails]ActionCableを使用してリアルタイムチャットの実装

実装すること

ActionCableを使用して特定のユーザーとリアルタイムチャットをできるようにしていきます。

完成イメージ

チャットイメージ図.png

ActionCableとは

RailsにおいてWebsocketによる双方向通信を実現してくれているものです。
(Webにおいて双方向通信を低コストで行うための仕組み)
参考:Railsガイドhttps://railsguides.jp/action_cable_overview.html

ER図

「EntryテーブルとDirectMessageテーブル」が「UserテーブルとRoomテーブル」の中間テーブルになっています。

チャットER図.png

ページ設計

①ユーザーの詳細ページ(users/show)から新規でダイレクトメッセージを送ることができる。
②チャットルーム(rooms/show)に入ると、
(1)メッセージフォームまでスクロールされる(新着メッセージは下に蓄積されていくため)
(2)チャットルームでは、ログインユーザーが右に相手が左に表示される
③チャットルーム一覧(rooms/index)はヘッダーのリンクから飛べる

Gemの追記

jQueryを使えるようにします。

Gemfile.
gem 'jquery-rails'
app/assets/javascripts/application.js
//
//= require rails-ujs
//= require activestorage
//= require turbolinks
//= require jquery ←追記
//= require jquery_ujs ←追記
//= require_tree .
$ bundle install

モデルの作成

Userモデルはデバイスを元に作成したものとします。
作成手順は下記リンク先で説明しております。
[Rails]Ajaxを用いて非同期で投稿機能といいね機能の実装https://qiita.com/yuto_1014/items/78d8b52d33a12ec33448

それではRoomモデル、Entryモデル、DirectMessageモデルを作成していきます。

$ rails g model Room name:string
$ rails g model Entry user_id:integer room_id:integer
$ rails g model DirectMessage content:string user_id:integer room_id:integer

アソシエーションの確認

多対多の関連がある時はthroughオプションをつけてあげると中間テーブルを経由して関連先のモデルを取得できるようになります。

Userモデル

app/models/user.rb
   has_many :entries
   has_many :direct_messages
   has_many :rooms, through: :entries

Roomモデル

dependent: :destroyで、roomの特定のレコードが消えれば、そのレコードの紐づいたentryレコードとdirect_messageレコードも消えるようにしています。

app/models/room.rb
   has_many :entries, dependent: :destroy
   has_many :direct_messages, dependent: :destroy
   has_many :users, through: :entries

Entryモデル

app/models/room.rb
  belongs_to :user
  belongs_to :room

DirectMessageモデル

データが作成されたら非同期でブロードキャスト処理を実行するようにします。
after_createではなく、after_create_commitを使っている点に注意してください。
トランザクションをコミットしたあとでブロードキャストしないと、他のクライアントからデータが見えない恐れがあります。

app/models/direct_message.rb
  belongs_to :user
  belongs_to :room
  #ブロードキャスト
  after_create_commit { DirectMessageBroadcastJob.perform_later self }

コントローラーの作成

$ rails g controller users
$ rails g controller rooms

ルーティングの作成

config/routes.rb
 devise_for :users
 resources :users
 resources :rooms

ユーザー詳細ページ(コントローラー・ビューの編集)

users_controller

app/controllers/users_controller.rb
    def show
        @user = User.find(params[:id])
        #チャット
        if user_signed_in?
            #Entry内のuser_idがcurrent_userと同じEntry
            @currentUserEntry = Entry.where(user_id: current_user.id)
            #Entry内のuser_idがMYPAGEのparams.idと同じEntry
            @userEntry = Entry.where(user_id: @user.id)
                #@user.idとcurrent_user.idが同じでなければ
                unless @user.id == current_user.id
                  @currentUserEntry.each do |cu|
                    @userEntry.each do |u|
                      #もしcurrent_user側のルームidと@user側のルームidが同じであれば存在するルームに飛ぶ
                      if cu.room_id == u.room_id then
                        @isRoom = true
                        @roomId = cu.room_id
                      end
                    end
                  end
                  #ルームが存在していなければルームとエントリーを作成する
                  unless @isRoom
                    @room = Room.new
                    @entry = Entry.new
                  end
                end
        end
    end

ユーザー詳細ページ(users/show.html.erb)

<% if @isRoom == true %>で、roomが存在していれば既存のroomへ飛び、まだroomが存在していなければroomを作成するようにしています。
<%= fields_for @entry do |e| %>で、roomと同時にentryも作成するようにしています。

app/views/users/show.html
<% if user_signed_in? %>
    <% unless @user.id == current_user.id %>
        <!-- 既にroomが存在していれば既存のroomへ -->
        <% if @isRoom == true %>
            <!-- メールアイコンでroomに飛べるようにする -->
            <%= link_to room_path(@roomId) do %>
                <button id="dm_submit"><i class="fas fa-envelope"></i></button>
            <% end %>
        <% else %>
            <!-- roomが存在していなければroomを作成する  -->
            <%= form_for @room, url: rooms_path do |f| %>
                <%= fields_for @entry do |e| %>
                   <%= e.hidden_field :user_id, :value=> @user.id %>
                <% end %>
                <button type="submit" id="dm_submit"><i class="fas fa-envelope"></i></button>
            <% end %>
        <% end %>
    <% end %>
<% end %>

ルームの作成・閲覧ページ(コントローラー・ビューの編集)

rooms_controller

『index』では、ログインユーザーのroom一覧の相手の名前を表示しています。
ログインユーザーが所属しているroomの中でログインユーザー以外のユーザー名を取って来ています。
『show』は、roomの詳細を表示しています。
roomが存在していれば、そのroomに紐づいたdirect_messagesとentriesを取ってきます。
『create』で、roomを作成します。
roomの作成と同時に、そのroomに紐づいたentryユーザーを2名(ログインユーザーとユーザー詳細ページのユーザー)を作成します。
『destroy』で、roomを削除します。

app/controllers/rooms_controller.rb
  def index
    @user = current_user
    @currentEntries = current_user.entries
    #@currentEntriesのルームを配列にする
    myRoomIds = []
    @currentEntries.each do |entry|
      myRoomIds << entry.room.id
    end
    #@currentEntriesのルーム且つcurrent_userでないEntryを新着順で取ってくる
    @anotherEntries = Entry.where(room_id: myRoomIds).where.not(user_id: @user.id).order(created_at: :desc)
  end

  def show
    @room = Room.find(params[:id])
    #ルームが作成されているかどうか
    if Entry.where(:user_id => current_user.id, :room_id => @room.id).present?
      @direct_messages = @room.direct_messages
      @entries = @room.entries
    else
      redirect_back(fallback_location: root_path)
    end
  end

  def create
    @room = Room.create(:name => "DM")
    #entryにログインユーザーを作成
    @entry1 = Entry.create(:room_id => @room.id, :user_id => current_user.id)
    #entryにparamsユーザーを作成
    @entry2 = Entry.create(params.require(:entry).permit(:user_id, :room_id).merge(:room_id => @room.id))
    redirect_to room_path(@room.id)
  end

  def destroy
      room = Room.find(params[:id])
      room.destroy
      redirect_to users_rooms_path
  end

ルーム一覧(rooms/index.html.erb)

チャットメッセージ一覧.png

app/views/rooms/index.html
<h2>メッセージ一覧</h2>
<% @anotherEntries.each do |e| %>
    <table class="table table-striped">
        <tr>
            <td>
         <!-- 名前からroomの詳細に飛べるようにリンク化 -->
                <%= link_to room_path(e.room.id) do %>
                    <%= attachment_image_tag e.user, :profile_image, format: 'jpeg', class: "rounded-circle", fallback: "no_image.jpg", size: "30x30" %>
                    <%= e.user.name %>
                <% end %>
            </td>
            <td>
               <!-- 最新メッセージ内容の最初の7文字を表示する -->
                <% dm = DirectMessage.find_by(id: e.room.direct_message_ids.last).content %>
                <%= truncate(dm, length: 10) %>
            </td>
            <td>
                <h6 style="color: #C0C0C0;"><%= e.updated_at.strftime("%Y/%m/%d %H:%M") %></h6>
            </td>
            <td>
               <!-- ゴミ箱アイコンクリックでroomを消せるようにする -->
                <%= link_to room_path(e.room.id), method: :delete do %>
                    <i class="fas fa-trash" style="color: black;"></i>
                <% end %>
            </td>
        </tr>
    </table>
<% end %>

ルーム詳細(rooms/show.html.erb)

チャットイメージ図.png

app/views/rooms/show.html
<% @entries.each do |e| %>
   <!-- eachで回したentryユーザーがログインユーザーであれば、@classに"current_user"の文字列を代入する。ログインユーザーであれば、空白を代入する -->
    <% current_user == e.user ? @class = "current_user" : @class = ""  %>
       <!-- eachで回したentryユーザーがログインユーザーでなければ、entryユーザー名を表示する -->
        <% if e.user != current_user %>
           <h5 class="text-left <%= @class %>" id="<%= @class %>" style="font-size: 30px;" data-id="<%= e.user.id %>"><%= link_to "@#{e.user.name}", user_path(e.user_id) %></h5>
        <% else %>
           <h5 class="text-left <%= @class %>" id="<%= @class %>" data-id="<%= e.user.id %>"></h5>
        <% end %>
<% end %>
<!-- メッセージ内容は、パーシャルにします -->
<div id="direct_messages" data-room_id="<%= @room.id %>">
    <%= render @direct_messages %>
</div>
<!-- メッセージフォーム -->
<form>
    <label style="color: white;" id="target">新しいメッセージを作成</label><br>
    <input type="text" id="chat-input" data-behavior="room_speaker" class="form-control">
</form>

<script>
    //トークルーム遷移時に入力フォーム記載場所にスクロールする(最新トークは下にあるため)
    var element = document.getElementById('target'); // 移動させたい位置の要素を取得
    var rect = element.getBoundingClientRect();
    var position = rect.top;
    setTimeout( function() {
        scrollTo(0, position);}
        , 1000);
</script>

メッセージ内容(direct_messages/_direct_message.html.erb)

app/views/direct_messages/_direct_message.html
<p class="<%= direct_message.id %>" style= "color: white; margin-top: 0;">
    <%= attachment_image_tag direct_message.user, :profile_image, fallback: "no_image.jpg", class:"profile-img-circle", size: "40x40" %>
    <%= direct_message.user.name %>
</p>
<p class="<%= direct_message.id %>">
    <span class="balloon1-top">
        <h7 class="dm_content"><%= direct_message.content %></h7><br>
        <h7 style="color: #C0C0C0;"><%= direct_message.created_at.strftime("%Y/%m/%d %H:%M") %></h7>
    </span>
</p><br>

<!-- メッセージの表示位置を指定する -->
<script>
    //メッセージのユーザーidを取ってくる
    var direct_message = <%= direct_message.user.id %>;
    //id="current_user"の内容を取得
    var dm_user = document.getElementById('current_user');
    //dm_userのdata-idを取得する
    var current_user = dm_user.getAttribute('data-id')
    //dm_userがログインユーザーであれば右に表示する
    if(direct_message == current_user){
      $('p.' + <%= direct_message.id %>).css('text-align', 'right');
    }else{
      $('p.' + <%= direct_message.id %>).css('text-align', 'left');
    }
</script>

roomチャンネルの作成

各ユーザーは、複数のケーブルチャネルにサブスクライブできます。各チャネルには機能の論理的な単位がカプセル化され、そこで行われることは、コントローラが通常のMVPセットアップで行うことと似ています。
参考:Railsガイドhttps://railsguides.jp/action_cable_overview.html

今回はspeakメソッドを持っているroomチャネルを作成します。

$ rails g channel room speak

これを実行することで下記のファイルが作成されます。
room_channel.rbに、サーバー側の記述を記載します。
room.coffeeに、クライアント側の記述を記載します。

app/channels/room_channel.rb

app/assets/javascripts/channels/room.coffee

room_channel

speakメソッドで、メッセージ内容(content)、user_id、room_idを作成します。

app/channels/room_channel.rb
class RoomChannel < ApplicationCable::Channel
  #接続されたとき
  def subscribed
    # stream_from "some_channel"
    stream_from "room_channel_#{params['room']}"
  end
#切断されたとき
  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end

  def speak(data)
    DirectMessage.create! content: data['direct_message'], user_id: current_user.id, room_id: params['room']
  end
end

room.coffee

app/assets/javascripts/channels/room.coffee
document.addEventListener 'turbolinks:load', ->
    if App.room
      App.cable.subscriptions.remove App.room
    App.room = App.cable.subscriptions.create { channel: "RoomChannel", room: $('#direct_messages').data('room_id') },
      #通信が確立された時
      connected: ->
      #通信が切断された時
      disconnected: ->
      #値を受け取った時
      received: (data) ->
        #投稿を追加
        $('#direct_messages').append data['direct_message']
      #サーバーサイドのspeakアクションにdirect_messageパラメータを渡す
      speak: (direct_message) ->
        @perform 'speak', direct_message: direct_message
    $('#chat-input').on 'keypress', (event) ->
      #return キーのキーコードが13
      if event.keyCode is 13
        #speakメソッド,event.target.valueを引数に.
        App.room.speak event.target.value
        event.target.value = ''
        event.preventDefault()

jobの作成・編集

非同期でブロードキャストするためのDirectMessageBroadcastジョブを作成します。

$ rails g job DirectMessageBroadcast 

direct_message_broadcast_job.rb

performメソッドでブロードキャストを実行します。
このとき、direct_messageに単純な文字列ではなく、direct_messages/direct_messagパーシャルのHTMLを返しています。
ApplicationController.renderer.renderメソッドを使うと、コントローラ以外の場所でビューをレンダリングできます。

app/jobs/direct_message_broadcast_job.rb
class DirectMessageBroadcastJob < ApplicationJob
  queue_as :default

    def perform(direct_message)
      ActionCable.server.broadcast "room_channel_#{direct_message.room_id}", direct_message: render_direct_message(direct_message)
    end

    private

    def render_direct_message(direct_message)
      ApplicationController.renderer.render partial: 'direct_messages/direct_message', locals: { direct_message: direct_message }
    end
end

ログインユーザーの情報を取得する(connection.rb)

ApplicationCable::Connectionクラスを使って、認証情報を定義します。
identified_byはコネクションを識別するキーとなるものです。connectメソッドはコネクションの接続時に呼ばれるメソッドです。ここではコネクションの識別キーとして、ログイン時に設定したCookieからuser_idを取り出しています。

app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = find_verified_user
    end

    protected
    def find_verified_user
        if verified_user = User.find_by(id: cookies.signed['user.id'])
          verified_user
        else
          reject_unauthorized_connection
        end
    end
  end
end

最後に

最後までご覧いただきありがとうございます。
初学者ですので間違っていたり、分かりづらい部分もあるかと思います。
何かお気付きの点がございましたら、お気軽にコメントいただけると幸いです。
twitter:https://twitter.com/yto_oct
note:https://note.com/yto_oty

参考

ActionCableを用いてリアルタイムチャットの実装
https://freecamp.life/rails-realtimechat/
Rails 5 + ActionCableで作る!シンプルなチャットアプリ(DHH氏のデモ動画より)
https://qiita.com/bisque33/items/1360477c2260b361ec03
[Rails5]Action Cableのサンプルを読み解いてみる
https://qiita.com/bisque33/items/1360477c2260b361ec03
リアルタイムチャットは誰でもつくれる~Action CableでDM機能を作ろう~
https://qiita.com/OgawaNorihiro/items/6d9f85d8e89d1def4f15
【Ruby on Rails】DM機能でDM相手の一覧ページを作成!
https://novice-programmer.com/dm_index/
Railsガイド Action Cable の概要
https://railsguides.jp/action_cable_overview.html

yuto_1014
ご覧いただきありがとうござます。 飲料メーカー営業マン→スクールにてRubyを中心に勉強→Javaエンジニア [twitter]:https://twitter.com/yto_oct [note]:https://note.com/yto_oty
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
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  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
ユーザーは見つかりませんでした