5
1

【Ruby on Rails】stimulus+ActionCableでリアルタイムチャット機能の実装(初学者向け)

Last updated at Posted at 2024-01-11

私は現在、未経験からのエンジニア転職に向けてプログラミングスクールで学習をしている、いしかわと申します。

今回Webアプリケーションの個人開発を進めている際、リアルタイムチャット機能を実装したので自身のアウトプットとして記事にしました
どなたかの参考になれば幸いです。

プログラミング初学者なので、内容に誤り等ある可能性があります
誤りがありましたら教えてくださると幸いです

StimlueとActionCabelを用いたリアルタイムチャット機能の実装

今回は既存のRequestオブジェクト毎にリアルタイムチャット機能を実装しています
環境
・Mac M1
・Ruby3.2.2
・Ruby on Rails 7.0.8

知っておくべき知識

ActionCable

WebSocketを利用してサーバーサイドとクライアントサイドで連続的(持続的)な通信を確立している
連続的な通信を確立していることから、サーバーサイドとクライアントサイドでリアルタイムの通信が可能になる
チャットメッセージやリアルタイムの更新など、サーバーサイドとクライアントサイドの状態を同期することができる

Ajax通信

似ているものとしてAjax通信があるが、Ajaxはサーバーサイドとクライアントサイドで連続的に通信しているものではなく、断続的な通信を行なっている
クライアントの指示があった時、Ajaxはそのデータをサーバーに送り、サーバーからの応答に基づいてページの一部の更新などを行う


※ActionCableとAjax通信の違い具体例

仮にチャット機能を実装するとき
Ajax通信を使って実装した場合
チャットの送信ボタンを押して「hoge」という内容を送信した時、送られた内容はテーブルに保存されるし、自身のチャットログにも「hoge」と表示される。
が、仮にチャットの相手がコメント送信時、チャットログ画面を開いていたとしたら、画面遷移・更新等なにか特定のアクションを行わないと送信した「hoge」は表示されない
ActionCableを使って実装した場合
チャットの送信ボタンを押して「hoge」という内容を送信した時、送られた内容はテーブルに保存されるし、自身のチャットログにも「hoge」と表示される。
仮にチャットの相手がコメント送信時、チャットログ画面を開いていても「hoge」という内容は即座に描写される

実装スタート Messageモデルの作成

Messageモデルを作成する。このMessageはリアルタイムチャットのメッセージ内容となる。メッセージの内容として想定しているものは
・メッセージ内容
・ユーザーの名前
・作成日時
上記のとおりRequest毎にリアルタイムチャット機能を実装したかったのでRequestとMessageは1対多のアソシエーション設定を行う
またUserの情報も取得したかったのでUserとMessageで1対多のアソシエーション設定を行う

$ rails g model message content:string request:references user:references

を実行
以下マイグレーションファイルとmessage.rbが作成される

message.rb
class Message < ApplicationRecord
  belongs_to :request
  belongs_to :user
end
hogehoge__create_messages.rb
class CreateMessages < ActiveRecord::Migration[7.0]
  def change
    create_table :messages do |t|
      t.text :content
      t.references :request, null: false, foreign_key: true
      t.references :user, null: false, foreign_key: true
      t.timestamps
    end
  end
end

request.rbとuser.rb`にMessageとのアソシエーションを設定する

request.rb
class Request < ApplicationRecord
# 省略
  has_many :messages, dependent: :destroy
# 省略
end
user.rb
class User < ApplicationRecord
# 省略
    has_many :messages, dependent: :destroy
# 省略
end

ActionCableの設定

ActionCableのルーティング設定

route.rbを編集しActionCableのサーバーへの経路を設定する。この設定がないとActionCableが有効化されない

route.rb
Rails.application.routes.draw do
  mount ActionCable.server => '/cable'
  # その他のルーティング
end

channelの作成

チャット機能用のChatChannelを作成する
channelとはリアルタイム更新機能を実現させるためのサーバー側の仕組みでデータの経路を設定(hoge_channel.rb)したり、送られてきたデータを画面上に表示させたり(hoge_channel.js)する

$ rails g channel chat # チャネル名

上記コードを実行すると
app/channels/chat_channel.rbapp/javascript/channels/message_channel.jsが生成される
chat_channel.rbはクライアントとサーバーを結びつけるためのファイル
chat_channel.jsはサーバーから送られてきたデータをクライアントに描画するためのファイル

チャネルの設定

chat_channel.rbを編集し、サーバーとクライアントを結びつける

chat_channel.rb
class ChatChannel < ApplicationCable::Channel
  def subscribed
    stream_for Request.find(params[:request_id])
  end

  def speak(data)
    request = Request.find(data['request_id'])
    user = User.find(data['user_id'])

    message = request.messages.create(content: data['message'], user: user)

    # 保存されたメッセージをブロードキャスト
    ChatChannel.broadcast_to(request, {
      user_id: user.id,
      user_name: user.profile.name,
      created_at: message.formatted_created_at,
      content: message.content
                             })
  end
end

subscribedメソッドとstream_forメソッド

  def subscribed
    stream_for Request.find(params[:request_id])
  end

subscribedメソッド
ActionCableのChannelクラスのインスタンスメソッド
クライアントが特定のチャネルに接続(サブスクライブ)するときに自動的に呼び出される
このメソッド内でクライアントがどの情報を受け取るかを指定する
stream_forメソッド
特定のオブジェクトに紐づいたストリームをクライアントに提供する
これによりオブジェクトに関する情報が変更された場合、リアルタイムでクライアントにその情報が送信される

このコードでは特定のオブジェクトRequest.find(paramas[:request_id])に関する更新情報がリアルタイムでクライアントに送信されることを示している


  def speak(data)
    request = Request.find(data['request_id'])
    user = User.find(data['user_id'])

    message = request.messages.create(content: data['message'], user: user)

    # 保存されたメッセージをブロードキャスト
    ChatChannel.broadcast_to(request, {
      user_id: user.id,
      user_name: user.profile.name,
      created_at: message.formatted_created_at,
      content: message.content
                             })
  end
request = Request.find(data['request_id'])
user = User.find(data['user_id'])

変数requestにRequestオブジェクトの格納
変数userにUserオブジェクトの格納
data['request_id']data['user_id']はクライアント(javascript側)から送信されるデータで、後述のchat_channel.jsで定義する

message = request.messages.create(content: data['message'], user: user)

変数messageに取得したRequestオブジェクトに関連するmessagesを作成している
contentdata['message]、アソシエーション設定をしているuserには先に定義した変数userを格納している

    ChatChannel.broadcast_to(request, {
      user_id: user.id,
      user_name: user.profile.name,
      created_at: message.formatted_created_at,
      content: message.content
                             })

broadcast_toメソッド
指定されたオブジェクトに関連付けられた特定のチャネルに情報を送信する
今回はrequestに関連づいたuser.id,user.profile.name, message.content, message.created_atの情報をChatCannelに送信している
message.created_atは表示するフォーマットの関係でmessage.formatted_created_atとしていますが、message.created_atと同義です

・コード内容まとめ
subscribeメソッドにより、ChatChannel内で特定のrequestをリアルタイムでクライアントに送信することを定義している
speakメソッドでは、javascriptから送信されたdataを受け取り、それを用いてrequest_iduser_idで変数requestとuserを定義し、それらの変数を利用して変数messageオブジェクトを生成している
broadcast_toメソッドによりrequestに紐づいた情報をブロードキャストしている


stimulusコントローラの作成

今回は先に行った$ rails g channel chatの際に生成されたchat_channel.jsを使用せずに独自にchat_controller.jsを作成した

chat_controller.js
import { Controller } from "stimulus"
import consumer from "../channels/consumer"

export default class extends Controller {
  static targets = [ "messages", "newMessage" ]
  static values = { requestId: Number, currentUserId: Number }

  connect() {
    this.channel = consumer.subscriptions.create(
      { channel: "ChatChannel", request_id: this.requestIdValue },
      {
        received: data => {
          // 送信者が現在のユーザーかどうかを判断
          const isCurrentUser = data.user_id === this.currentUserIdValue;
          const messageClass = isCurrentUser ? 'chat chat-start' : 'chat chat-end';
        
          // ブロードキャストされたメッセージを表示
          this.messagesTarget.innerHTML += `
            <div class="${messageClass}">
              <div class="chat-header">
                ${data.user_name}
                <time class="text-xs opacity-50">${data.created_at}</time>
              </div>
              <div class="chat-bubble">${data.content}</div>
            </div>
          `;
        
          this.scrollToBottom();

        }
      }
    );
    this.scrollToBottom();
  }

  scrollToBottom() {
    this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight;
  }

  send(event) {
    event.preventDefault()
    this.channel.perform('speak', {
      message: this.newMessageTarget.value,
      request_id: this.requestIdValue,
      user_id: this.currentUserIdValue }),
    this.newMessageTarget.value = ''
  }
}

consumer(コンシューマー)
websocket接続を管理するオブジェクト。websocketはサーバーとクライアントでリアルタイム通信を可能にするプロトコル
コンシューマーはサーバーとの通信路を確立し管理する
sunscription(サブスクリプション)
特定の情報源から情報を受け取るために登録する
ユーザーがチャネルにサブスクライブ(登録)することでそのチャネルの更新情報をリアルタイムで受け取ることができる
subscriptions.createにより指定されたチャンネルへのサブスクリプションを作成している
これによりチャネルからのデータの送受信が可能になる
{ channel: "ChatChannel", request_id: this.requestIdValue }
ChatChannelという名前のチャンネルへのサブスクリプションを作成することを明示
request_idthis.requestIdValueであることを指定することによってどのリクエストのチャットに関連する情報を扱うか特定することができる

        received: data => {
          // 送信者が現在のユーザーかどうかを判断
          const isCurrentUser = data.user_id === this.currentUserIdValue;
          const messageClass = isCurrentUser ? 'chat chat-start' : 'chat chat-end';
        
          // ブロードキャストされたメッセージを表示
          this.messagesTarget.innerHTML += `
            <div class="${messageClass}">
              <div class="chat-header">
                ${data.user_name}
                <time class="text-xs opacity-50">${data.created_at}</time>
              </div>
              <div class="chat-bubble">${data.content}</div>
            </div>
          `;
        
          this.scrollToBottom();

        }

サブスクリプションにreceived関数を提供
この関数はChatChannelからデータがブロードキャストされたときに呼び出される
チャットログ画面はLINEのようにcurren_usermessage.contentが左側、それ以外のユーザーのmessage.contentが右側としたかったため、user_idによってmessageClassを判別している
テキストをHTMLに追加したあとにチャットログのウィンドウを最下部にするためにthis.scrollToBottom()メソッドを実行している。

  send(event) {
    event.preventDefault()
    this.channel.perform('speak', {
      message: this.newMessageTarget.value,
      request_id: this.requestIdValue,
      user_id: this.currentUserIdValue }),
    this.newMessageTarget.value = ''
  }

sendメソッド
ユーザーが新しいメッセージを送信するためのもの
event.preventDefault()
フォーム送信のデフォルトの動作(ページの再読み込み等)を防ぐ
this.channel.perform('speak', {...})
ActionCableのサブスクリプションを通じてspeakアクションを実行する

message: this.newMessageTarget.value,
request_id: this.requestIdValue,
user_id: this.currentUserIdValue

これらはHTML内の対応する各ターゲットからnewMessageTarget.value)から取得した内容
this.newMessageTarget.value = ''
はメッセージを送信したあとに入力フィールドをからにする

・まとめ
このstimulusコントローラは特定の要素を参照するためにmessagesnewMessageをtargetsとして定義し、requestIdcurrentUserIdのデータ型をNumberとしている
HTMLファイルにdata-controller="chat"が含まれている場合、このコントローラは呼び出され自動的にconnectメソッドが実行される

connectメソッドChatChannelのサブスクリプションを作成しており、request_idはHTMLに含まれるdata-chat-request-id-valueの値となる
またreceivedメソッドによりdata.messagemessages領域に追加する

sendメソッドによりクライアントによりeventが実行された場合、eventが通常行う動作を妨げ実行させない
その後ChatChannelspeakメソッドを実行し、下記3つの値をspeakメソッドに渡している
messageには参照したthis.newMessageTarget.valueの値
request_idには参照したthis.requestIdの値(Value)
user_idには参照したthis.currentUserIdValueの値(value)

最後にnewMessageを参照しその内容(value)を空にしている

ビューファイルの作成

今回はrequest毎にリアルタイムチャット機能を実装したかったためapp/views/request/show.html.erbに下記コードを記述する

show.html,erb
<!-- チャットウィンドウの実装 -->
<div data-controller="chat" data-chat-request-id-value="<%= @request.id %>" data-chat-current-user-id-value="<%= current_user.id %>">
  <div class="mb-2">ログ</div>
    <div class="border rounded-lg p-2 h-52 mb-4 overflow-y-auto" data-chat-target="messages">
      <% @request.messages.each do |message| %>
        <%= render partial: 'messages/message', locals: {message: message, current_user: current_user} %>
      <% end %>
    </div>

  <!-- フォーム部分 -->
  <div class="mb-2">
    <div class="text-sm mb-2">
      ログコメントを入力してください
    </div>
    <div class="flex">
      <!-- Stimulusの機能を持たせるためにform要素を追加し、data-action属性を設定 -->
      <form class="w-full" data-action="chat#send">
        <!-- textareaをinputタイプに変更し、Stimulusのdata-chat-target属性を追加 -->
        <textarea class="w-11/12 border rounded p-2 text-sm" data-chat-target="newMessage" rows="1"></textarea>
        <!-- 送信ボタンにtype="submit"を追加 -->
        <button type="submit" class="ml-2">
          <svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-message-forward" width="32" height="32" viewBox="0 0 24 24" stroke-width="1.5" stroke="#000000" fill="none" stroke-linecap="round" stroke-linejoin="round">
            <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
            <path d="M4 21v-13a3 3 0 0 1 3 -3h10a3 3 0 0 1 3 3v6a3 3 0 0 1 -3 3h-9l-4 4" />
            <path d="M13 9l2 2l-2 2" />
            <path d="M15 11h-6" />
          </svg>
        </button>
      </form>
    </div>
  </div>
</div>
<div data-controller="chat" data-chat-request-id-value="<%= @request.id %>" data-chat-current-user-id-value="<%= current_user.id %>">

data-controller-="chat"でchat_controller.jsを使用することを明示
data-chat-request-id-value="<%= @request.id%>"chat_controller.jsで使用するrequestIdValueを取得
data-chat-curerrnt-user-id-value="<%= current_user.id %>"でchat_controller.jsで使用するcurrentUserIdValue`を取得

<div class="border rounded-lg p-2 h-52 mb-4 overflow-y-auto" data-chat-target="messages">
    <% @request.messages.each do |message| %>
        <%= render partial: 'messages/message', locals: {message: message, current_user: current_user} %>
    <% end %>
  </div>

<div data-chat-target="messages">chat_controller.jsで定義したmessagesターゲット要素と感れづけている
_message.html.erbをパーシャルとして使用

  <form data-action="chat#send">
    <input type="text" data-chat-target="newMessage">
    <button type="submit">送信</button>
  </form>

<form data-action="chat#send">でこのフォームで送信イベントが発生したときにchat_controller.jsのsendメソッドが実行されることを明示
data-chat-target="newMessage"によりフォームに入力されたテキストがnewMessage要素であることを明示

パーシャルの作成

_message.html.erb
<% if message.user == current_user%>
  <div class="chat chat-start">
    <div class="chat-header">
      <%= message.user.profile.name %>
      <time class="text-xs opacity-50"><%= l message.created_at, format: :chat %></time>
    </div>
    <div class="chat-bubble"><%= message.content%></div>
  </div>
<%else%>
  <div class="chat chat-end">
    <div class="chat-header">
      <%= message.user.profile.name %>
      <time class="text-xs opacity-50"><%= l message.created_at, format: :chat %></time>
    </div>
    <div class="chat-bubble"><%= message.content%></div>
  </div>
<%end%>

daisyUIのクラスを使用しています
パーシャルには登録済みデータをmessage.userの判別を行い
message.usercurernt_userの場合<div class="chat chat-start">
それ以外の場合<div class="chat chat-end">というクラスで表示されるようにしています


これらの記述により、リアルタイムチャット機能を実装することができました

今回記事を書いたことによってchat_channel.jsを使用しなかったり、messageの作成をRailsのコントローラではなくchat_controller.jsで行ったりと個人的に気持ち悪い部分が浮き彫りになった気がします
今後、上記部分や、ビューを整える作業を行い随時修正してさらに理解を深めていきたいと思います!

5
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
5
1