私は現在、未経験からのエンジニア転職に向けてプログラミングスクールで学習をしている、いしかわと申します。
今回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
が作成される
class Message < ApplicationRecord
belongs_to :request
belongs_to :user
end
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とのアソシエーションを設定する
class Request < ApplicationRecord
# 省略
has_many :messages, dependent: :destroy
# 省略
end
class User < ApplicationRecord
# 省略
has_many :messages, dependent: :destroy
# 省略
end
ActionCableの設定
ActionCableのルーティング設定
route.rb
を編集しActionCable
のサーバーへの経路を設定する。この設定がないとActionCable
が有効化されない
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
を編集し、サーバーとクライアントを結びつける
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を作成している
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
})
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
を作成した
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
であることを指定することによってどのリクエストのチャットに関連する情報を扱うか特定することができる
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()
メソッドを実行している。
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コントローラは特定の要素を参照するために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メソッド
を実行し、下記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に下記コードを記述する
<!-- チャットウィンドウの実装 -->
<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
要素であることを明示
パーシャルの作成
<% 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
で行ったりと個人的に気持ち悪い部分が浮き彫りになった気がします
今後、上記部分や、ビューを整える作業を行い随時修正してさらに理解を深めていきたいと思います!