はじめに
私は個人サービスを開発した時に、ActionCableでルーム別のリアルタイムチャットを実装しましたがかなり手こずりました。なのでどう考えて実装したかを記しておこうと思います。
以下のようなリアルタイムチャットを作りました。野球の試合を実況できるサービスで、試合毎にルームが分かれているのが今回のポイントです。
実際にどんなものか触ってみたい方はこちらをクリックしてください。(https://www.yakyubaka-zikkyo.com/)
前提
- javascriptが書ける(ルーム別リアルタイムチャットは、ほぼjsで実装します)
- ActionCableの用語が理解できる
- ルーム別でないリアルタイムチャットの実装方法がある程度わかる
これらが不安な場合、他で基礎知識を身につけないと読めないかもしれません。
この記事を読んで欲しい人
- リアルタイムチャット機能は完成しているが、ルームの分け方がわからない人。
- ルーム別のリアルタイムチャット機能を実装したが、チャットが複製されるバグが発生している人
- ルーム別のリアルタイムチャットを実装したが、別ルームのチャットが表示されるバグに苦しんでいる人
学んだこと
私が実装した当初は、他にルーム別チャットを実装できている記事は無かったので、めちゃくちゃ悩みました。個人的には研究者の領域だったと感じます(おおげさ)。日本語の記事はほぼ全て読み、あまり参考にならず、英語の記事もたくさん読み漁りました。しかしどれもあまり役に立ちませんでした。結局何が一番解決につながったかというと、gemのソースコードを読むことでした。
環境
- macos
- ruby 3.1.2
- Rails 6.1.6
手順
ルーム別リアルタイムチャットの実装
- ビューからuserのidとchatroomのidを取得できるようにする
- チャンネルをサブスクライブした時にサーバー側にパラメータを送信する
- そのパラメーターを利用して特定のチャンネルに送信する
ルーム別リアルタイムチャットを実装した時に発生するバグに対応する
ルーム別リアルタイムチャットの実装
ここで大体どのように実装するかを説明します。
〇〇_channel.jsのconsumer.subscriptions.create
が呼び出されたタイミングで、サーバー側にパラメーターを送ることができます。これを利用します。サーバー側にチャンネルのidをパラメータとして送信します。ブロードキャストをストリームするのはサーバー側(〇〇_channel.rb)の役割です。受け取ったパラメータを利用して、subscribeメソッドの中で特定のチャンネルにブロードキャストをストリームするように設定します。
consumer.subscriptions.create(
{
以下の箇所の第二引数にパラメーターとして送信したい値を引き渡せる
channel: channel, chatroom_id: チャットルームのid, user_id: ログインしているユーザーのid
},
{
connected() {
// Called when the subscription is ready for use on the server
},
disconnected() {
// Called when the subscription has been terminated by the server
},
received(data) {
}
}
);
1. ビューからuserのidとchatroomのidを取得できるようにする
以下のようにdataで設定します。
#チャットルーム内のビュー
<div id="data" data-chatroom-id="<%= @chatroom.id %>" data-user-id="<%= current_user.id %>"></div>
2. チャンネルをサブスクライブした時にサーバー側にパラメータを送信する
これはなぜやるかというと、サーバー側でブロードキャストをストリームする時に、ルーム毎にチャンネルを識別するためです。
以下では
- consumer.subscriptions.createの前に必要な変数を定義しています
-
channel: channel, chatroom_id: chatroomId, user_id: userId
の箇所でパラメーターを送信しています。
#〇〇_channel.js
以下の変数を定義する(ビューのdata属性のチャットルームとユーザーの情報を格納している。)
const channel = "RoomChannel"
const data = document.getElementById('data').dataset;
const chatroomId = data.chatroomId;
const userId = data.userId;
consumer.subscriptions.create(
{
以下の箇所の第二引数にパラメーターとして送信したい値を引き渡せる
channel: channel, chatroom_id: chatroomId, user_id: userId
},
{
connected() {
},
disconnected() {
},
received(data) {
}
}
);
3. そのパラメーターを利用して特定のチャンネルに送信する
ここで実際にブロードキャストをストリームする時に、チャンネルを識別しています。
以下はsubscribeメソッドの中で2. チャンネルをサブスクライブした時にサーバー側にパラメータを送信する
で送られてきたパラメーターを元に、チャンネルを識別して、ブロードキャストをストリームしています。
ちなみに以下ではcurrent_userやルームの情報がnilの場合にrejectしていますが、してもしなくてもどちらでもいいです。
#〇〇_channel.rb
class RoomChannel < ApplicationCable::Channel
def subscribed
@user = User.find(params[:user_id])
reject if @user.nil?
@chatroom = Chatroom.find(params[:chatroom_id])
reject if @chatroom.nil?
#以下で送信されたパラメータを利用して、ルーム毎にチャンネルを識別して、ブロードキャストを送信している。
stream_for "room_channel_#{params[:chatroom_id]}"
end
def unsubscribed
end
end
これによりルーム毎にチャットが表示されるようになりました。
しかしこのままではバグが発生しうまく動きません。その解消法を次に解説します。
ルーム別リアルタイムチャットを実装した時に発生するバグに対応する
現段階で以下のような問題が発生します。
- チャットルームを切り替えると自分のチャットが複製される
- チャットルームを切り替えると別のルームのチャットが表示されてしまう。
これに対処します。
原因
「一人のコンシューマーが同時に複数のチャンネルをサブスクライブできること」が原因で上記のようなバグが発生しています。(難しいと思うので、詳しくは下記のサブスクライブの情報
で解説しています。)
対処法
一人のコンシューマーが同時に一つのチャンネルしかサブスクライブできないようにすればいいのです。
今回は、コンシューマーがチャンネルをサブスクライブする直前に、前にサブスクライブしていたチャンネルの購読を解除する
という方法で実装したいと思います。
ですがここで気になるのは、それをどうやって実現するのかです。。。
サブスクライブの情報
「どのユーザーがどのチャンネルを購読しているのか」という情報はjavascriptのクロージャ変数に格納されています。そしてこの情報は、〇〇_channel.jsのconsumer.subscriptions.create
が発生した時に扱うことができます。
GoogleChromeの検証ツールでconsumer.subscriptions.create
が発生した時に処理を止めてみます。検証のやり方がわからない方はこちらのサイトを参照。(https://ics.media/entry/190517/)
「どのユーザーがどのチャンネルを購読しているのか」という情報は、以下の赤色で囲っているところに配列の形で入っています。ここでは「id153のユーザーがid42のチャットルームをサブスクライブしている」ということがわかります。
ここで試しに3回同じチャットルームに入って出てを繰り返してみます
すると同じユーザーとチャンネルの組み合わせが3つあります。
これが同じチャットが複製されたり、別のルームのチャットが表示されたりしてしまうバグの要因です!
このように同じコンシューマーが同時に何個でもチャンネルをサブスクライブできてしまうのが原因でした。
なので同じコンシューマーは同時に一つしかチャンネルを購読できないようにしていきましょう!
コンシューマーが同時に一つのチャンネルしか購読できないようにする方法
上記の「どのユーザーがどのチャンネルを購読しているのか」という情報が詰まった配列から、同じユーザーで重複しているものを全て削除すれば良いわけです。
サブスクライブの情報の取得
以下で上記の配列を取得することができます。
consumer.subscriptions.subscriptions;
これを以下のように〇〇_channel.jsに定義します。
#〇〇_channel.js
const subscriptions = consumer.subscriptions.subscriptions;
同じユーザーのサブスクリプション情報を削除する。
以下のような識別子を作ります。
#〇〇_channel.js
const userIdentifier = `"user_id":"${userId}"`;
そして上記の文字列を含むサブスクリプション情報を定義した配列の中から削除します。
if(subscriptions){
subscriptions.map(function userUnsubscribe(subscription){
if(subscription.identifier.includes(userIdentifier)){
subscription.consumer.subscriptions.remove(subscription)
};
});
};
上記について少し解説します。
-
if(subscription.identifier.includes(userIdentifier))
は同じユーザーのサブスクリプションが存在するかどうかを判定しています。 -
subscription.consumer.subscriptions.remove(subscription)
はサブスクリプションの購読を解除しています。
これにて不具合は解消します。
以下が〇〇_channel.jsコードの全容
import consumer from "./consumer"
document.addEventListener('turbolinks:load', () => {
// create.js.erbで使うので定義しておく
window.chatContainer = document.getElementById('chat_container');
// 以下のプログラムがチャットルーム以外で動作しないようにしている
if (chatContainer === null) {
return false
};
const channel = "RoomChannel"
const data = document.getElementById('data').dataset;
const chatroomId = data.chatroomId;
const userId = data.userId;
const userIdentifier = `"user_id":"${userId}"`;
// サブスクリプションのリスト
const subscriptions = consumer.subscriptions.subscriptions;
同じユーザーが2つ以上のチャンネルを同時に購読できないようにしている
if(subscriptions){
subscriptions.map(function userUnsubscribe(subscription){
if(subscription.identifier.includes(userIdentifier)){
subscription.consumer.subscriptions.remove(subscription)
};
});
};
consumer.subscriptions.create(
{
channel: channel, chatroom_id: chatroomId, user_id: userId
},
{
connected() {
},
disconnected() {
},
received(data) {
// データを受け取った時に以下を発動する
document.getElementById('chat_container').insertAdjacentHTML('beforeend', data['chat']);
scrollToBottom();
}
}
);
});
ところでremoveやidentifierなどのメソッドがどのような役割を果たしているかが疑問に感じる人も多いと思います。それについては以下です。
gemのソースコードを読む
私はgemのソースコードを読むことで解消しました。
例えばチャンネルの購読を解除してくれるunsubscribeメソッドはどんな挙動かとか、gemのソースコードに書いてあります。(以下はactioncable ver6.0.0の359行目)
Subscription.prototype.unsubscribe = function unsubscribe() {
return this.consumer.subscriptions.remove(this);
};
return Subscription;
ここで私はサブスクリプションの購読を解除したければ、removeメソッドを使えばいいのだと気づけました。
最後に
ActionCableはマニアックなgemである分難しいです。使っている人も少ないので他のgemと比べると知見が少ないです。検索しても出てこないようなメソッドはたくさん存在しています。上記のremoveメソッドなんかもそうです。私は英語の記事も検索して読み漁りましたがググっても出てきませんでした。でもgemのソースコードを読むことで、存在しないメソッドをたくさん知ることができました。検索しても出てこないようなことはgemのソースコードを読むことで解決できるのだと、勉強になりました。