TojiTech No Programming, No Life.

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図の感じになります。

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()
            }
        })
    })
})

最後に

以上で、実装は完了のはずです。うまく動かない場合は、サンプルリポジトリを確認してください。

うまく活用すれば、グループチャットなんかも実装できそうな感じです。

動いているかの確認は、プライベートウインドウか違うブラウザを使って違うアカウントでログインして確認してみてください。

Profile

Yuki Tojima

RubyやPhp、JavaScriptまわりのことを徒然と記録に残す技術ブログです。

至らぬところもあると思いますが、見守っていただけると幸いです。

記事のリクエストや、間違いなどありましたら X (旧 Twitter) のDMなどでお気軽にご連絡ください。

ytojima @TojiTech
プロフィール画像