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

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

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

:::note warn
プログラミング初学者なので、内容に誤り等ある可能性があります
誤りがありましたら教えてくださると幸いです
:::
# 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`が作成される
```ruby:message.rb
class Message < ApplicationRecord
  belongs_to :request
  belongs_to :user
end
```
```ruby: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とのアソシエーションを設定する
```ruby:request.rb
class Request < ApplicationRecord
# 省略
  has_many :messages, dependent: :destroy
# 省略
end
```
```ruby:user.rb
class User < ApplicationRecord
# 省略
    has_many :messages, dependent: :destroy
# 省略
end
```

## ActionCableの設定
### ActionCableのルーティング設定
`route.rb`を編集し`ActionCable`のサーバーへの経路を設定する。この設定がないと`ActionCable`が有効化されない
```ruby: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.rb`と`app/javascript/channels/message_channel.js`が生成される
`chat_channel.rb`はクライアントとサーバーを結びつけるためのファイル
`chat_channel.js`はサーバーから送られてきたデータをクライアントに描画するためのファイル
### チャネルの設定
`chat_channel.rb`を編集し、サーバーとクライアントを結びつける
```ruby: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メソッド**
```ruby
  def subscribed
    stream_for Request.find(params[:request_id])
  end
```
`subscribedメソッド`
ActionCableのChannelクラスのインスタンスメソッド
クライアントが特定のチャネルに接続（サブスクライブ）するときに自動的に呼び出される
このメソッド内でクライアントがどの情報を受け取るかを指定する
`stream_forメソッド`
特定のオブジェクトに紐づいたストリームをクライアントに提供する
これによりオブジェクトに関する情報が変更された場合、リアルタイムでクライアントにその情報が送信される

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

---
```ruby
  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
```
```ruby
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`で定義する
```ruby
message = request.messages.create(content: data['message'], user: user)
```
変数`message`に取得したRequestオブジェクトに関連するmessagesを作成している
`content`に`data['message]`、アソシエーション設定をしている`user`には先に定義した変数`user`を格納している
```ruby
    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_id`と`user_id`で変数requestとuserを定義し、それらの変数を利用して変数messageオブジェクトを生成している
`broadcast_toメソッド`により`request`に紐づいた情報をブロードキャストしている

---
### stimulusコントローラの作成
今回は先に行った`$ rails g channel chat`の際に生成された`chat_channel.js`を使用せずに独自に`chat_controller.js`を作成した
```javascript: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_id`が`this.requestIdValue`であることを指定することによってどのリクエストのチャットに関連する情報を扱うか特定することができる
```javascript
        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_user`の`message.content`が左側、それ以外のユーザーの`message.content`が右側としたかったため、user_idによって`messageClass`を判別している
テキストをHTMLに追加したあとにチャットログのウィンドウを最下部にするために`this.scrollToBottom()`メソッドを実行している。

```javascript
  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`アクションを実行する
```javasscript
message: this.newMessageTarget.value,
request_id: this.requestIdValue,
user_id: this.currentUserIdValue
```
これらはHTML内の対応する各ターゲットからnewMessageTarget.value)から取得した内容
`this.newMessageTarget.value = ''`
はメッセージを送信したあとに入力フィールドをからにする

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

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

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

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

## ビューファイルの作成
今回はrequest毎にリアルタイムチャット機能を実装したかったためapp/views/request/show.html.erbに下記コードを記述する
```html: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>
```
```html
<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`を取得
```html
<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`をパーシャルとして使用
```html
  <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`要素であることを明示

### パーシャルの作成
```html:_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.user`が`curernt_user`の場合`<div class="chat chat-start">`
それ以外の場合`<div class="chat chat-end">`というクラスで表示されるようにしています

---

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

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