チャットを作る:第2章 — ActionCableバックエンド
Tony Duong
5月 31, 2026 ・ 3 分
💬 これは、このサイトの右下に表示されているライブチャットそのものの実装です。 このシリーズでは、それをどう作ったかを順を追って解説します。チャットの吹き出しを開いて試してから、その仕組みを読み進めてみてください。
この章では、RailsのWebSocketサーバーを解説します。接続がどのように認証されるか、各チャンネルの内部で何が起こるか、そしてデータがSolid Cableを通じてどう流れるかを見ていきます。第1章のリポジトリ構成を理解していることが前提です。
コネクション認証
chat-server/app/channels/application_cable/connection.rbを開いてください:
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :visitor_token, :admin
def connect
if request.params[:token].present?
self.visitor_token = request.params[:token]
self.admin = false
elsif request.session[:admin] == true
self.visitor_token = nil
self.admin = true
else
reject_unauthorized_connection
end
end
end
end
同じWebSocketエンドポイントを2種類のユーザーが共有します:
- 訪問者はWebSocket URLに
?token=<UUID>を渡します。トークンはブラウザのlocalStorageから取得されます。 - 管理者は
POST /auth/loginのAuthControllerで設定されたRailsセッションCookieで認証します。 - それ以外は
reject_unauthorized_connectionで、HTTP 403を送信しソケットを閉じます。
identified_byは理解する価値があります。:visitor_tokenと:adminを各接続インスタンスのID属性として登録します。ActionCableはこれらを使って後から接続を検索します。ActionCable.server.connectionsを呼び出すと、各接続オブジェクトがこれらの属性を公開します。これによりVisitorChannelが管理者のオンライン状態を確認でき、AdminChannelがアクセスを制限できます。
VisitorChannel
完全なソース:chat-server/app/channels/visitor_channel.rb(84行)。
**subscribed**はconnection.visitor_tokenからトークンを読み取り、stream_from "visitor_#{@session_token}"を呼び出し、そのトークンのConversationレコードが既に存在する場合は既存のメッセージ履歴を送信します。
**send_message(data)**が主要なロジックです。フローをステップごとに見ていきます:
1. 訪問者が { action: "send_message", content: "Hello!" } を送信
2. VisitorChannel#send_message がcontentを検証(空チェック、2000文字上限)
3. Conversation.find_or_create_by!(session_token:) でレコードを作成または検索
4. conversation.messages.create!(sender: "visitor", content: "Hello!")
5. conversation.increment!(:unread_count)
6. "visitor_{token}" にブロードキャスト --> 訪問者にメッセージ確認が届く
7. "admin_channel" にブロードキャスト --> 管理者にnew_messageイベントが届く
8. admin_connected? --> falseなら PushNotificationService.notify
バリデーションはシンプルで、空メッセージと2000文字を超えるメッセージはtransmit({ type: "error", ... })で送信者に返却されます:
content = data["content"].to_s.strip
return transmit({ type: "error", error: "Message is empty" }) if content.blank?
return transmit({ type: "error", error: "Message too long (max 2000)" }) if content.length > 2000
admin_connected?はライブ接続を検査します:
def admin_connected?
ActionCable.server.connections.any? { |c| c.admin == true }
rescue
false
end
これが機能するのは、identified_by :adminが.adminをすべての接続オブジェクトでアクセス可能にしたからです。管理者ソケットが開いていない場合、チャンネルは代わりにWeb Push通知を送信します。
設計上の判断: メッセージはHTTP POSTではなく、ActionCableを通じて作成されます。WebSocket接続が書き込みパスそのものです。トレードオフ:シンプル(メッセージ用のRESTエンドポイントが不要)ですが、ソケットがダウンしていると送信できません。個人ブログのチャットウィジェットなら、メッセージキューやリトライロジックは不要です。
AdminChannel
完全なソース:chat-server/app/channels/admin_channel.rb(110行)。
**subscribed**は管理者でない接続を拒否し、"admin_channel"からストリームし、プライベートなserialized_conversationsメソッドで会話リスト全体を送信します:
def subscribed
unless connection.admin
reject
return
end
stream_from "admin_channel"
transmit({
type: "conversations",
conversations: serialized_conversations
})
end
**send_message(data)**はIDで会話を検索し、sender: "admin"でメッセージを作成し、_2つの_ストリームにブロードキャストします:
ActionCable.server.broadcast("visitor_#{conversation.session_token}", { ... })
ActionCable.server.broadcast("admin_channel", { ... })
管理者チャンネルがconversation.session_tokenを使って訪問者のストリームにアクセスしている点に注目してください。ブロードキャストメカニズムを通じてチャンネルの境界を越えています — チャンネルはサブスクリプションのスコープであり、分離の壁ではありません。
**mark_read(data)はconversation.unread_countを0にリセットします。get_history(data)は指定された会話のすべてのメッセージを送信します。list_conversations**はオンデマンドで会話リスト全体を再送信します。
データモデル
chat-server/db/schema.rbを参照してください。3つのテーブルがあります:
| テーブル | 主要カラム | 注目すべきインデックス |
|---|---|---|
conversations |
session_token(ユニーク)、visitor_name、unread_count |
ソート用のupdated_at |
messages |
conversation_id(FK)、sender、content(text) |
複合(conversation_id, created_at) |
push_subscriptions |
endpoint(ユニーク)、p256dh、auth |
endpointにユニーク |
重要なモデルの詳細はchat-server/app/models/message.rbにあります:
class Message < ApplicationRecord
belongs_to :conversation, touch: true
end
touch: trueは、すべてのmessages.create!が自動的にconversation.updated_atを更新することを意味します。管理パネルは会話をupdated_at DESCでソートするため、最近アクティブな会話が自動的に一番上に表示されます — 追加のクエリは不要です。
Solid Cable — pub/subアダプター
ActionCableにはブロードキャストをルーティングするpub/subバックエンドが必要です。このプロジェクトではSolid Cableを使用しており、chat-server/config/cable.ymlで設定されています:
development:
adapter: solid_cable
connects_to:
database:
writing: cable
polling_interval: 0.1.seconds
message_retention: 1.day
Solid Cableはブロードキャストメッセージを専用のSQLiteデータベースに保存し、新しいエントリをポーリングします。polling_interval: 0.1.secondsで約100msのレイテンシーになります。message_retention: 1.dayは古いブロードキャスト行を自動削除し、テーブルを小さく保ちます。
他のアダプターとの比較:
| アダプター | 外部依存 | レイテンシー | 水平スケーリング |
|---|---|---|---|
solid_cable |
なし(SQLite) | 〜100ms | 不可(シングルプロセス) |
redis |
Redisサーバー | 〜1ms | 可 |
async |
なし | 〜0ms | 不可(同一プロセスのみ) |
このコードベースがsolid_cableを選んだ理由は、管理するRedisがなく、SQLiteがアプリケーションデータベースとして既にあり、個人ブログに水平スケーリングは不要だからです。後にサブ10msの配信や複数のRailsプロセスが必要になったら、アダプターをredisに切り替えるだけで済みます — チャンネルのコード変更は不要です。
バックエンドはこれで名前付きストリームにメッセージをブロードキャストしています。第3章では、Next.jsフロントエンドが@rails/actioncable npmパッケージを使ってこれらのストリームをサブスクライブする方法と、ReactコンポーネントがWebSocketライフサイクルを無限再レンダリングの罠に陥ることなく管理する方法を示します。
やってみよう
演習1. Railsコンソールを開き、訪問者ストリームに手動でテストメッセージをブロードキャストしてください。トークンabc-123で接続中の訪問者がいる場合:
ActionCable.server.broadcast("visitor_abc-123", {
type: "message",
message: { id: 999, sender: "admin", content: "Test from console", created_at: Time.current.iso8601 }
})
フロントエンドの表示を確認してください。(注意:solid_cableでは、別のbin/rails consoleからではなく、Railsサーバープロセス内からブロードキャストする必要があります。cable.ymlの先頭のコメントを参照してください。)
訪問者のチャットウィジェットに「Test from console」が管理者メッセージとして表示されるはずです。フロントエンドが既にそのストリームをサブスクライブしているため、即座に表示されます。このメッセージはデータベースに永続化されません — Message.create!をスキップして生データをブロードキャストしただけです。訪問者がリフレッシュすると、このゴーストメッセージは消えます。
演習2. config/cable.ymlのpolling_intervalを2.secondsに変更し、Railsを再起動してメッセージを送信してください。レイテンシーの増加に注目してください。
メッセージの受信側での表示に最大2秒かかるようになります。Solid Cableのポーラーが100msごとではなく2秒ごとにブロードキャストテーブルをチェックするためです。終わったら0.1.secondsに戻してください。
演習3. VisitorChannel#send_messageに、「spam」を含むメッセージを拒否するbanned_wordsチェックを追加してください。訪問者にエラーを返してください。
chat-server/app/channels/visitor_channel.rbの既存のバリデーション行の後に以下を追加してください:
banned = %w[spam]
if banned.any? { |w| content.downcase.include?(w) }
return transmit({ type: "error", error: "Message contains a banned word" })
end
空チェック/長さチェックの後、find_or_create_by!の前に配置してください。訪問者は{ type: "error", error: "Message contains a banned word" }を受け取り、メッセージは永続化されません。
演習4. chat-server/app/models/message.rbを開き、belongs_to :conversationからtouch: trueを削除してください。メッセージを送信して、管理パネルで会話リストが正しくソートされるか確認してください。
正しくソートされなくなります。touch: trueがないと、新しいメッセージが作成されてもconversation.updated_atが更新されません。管理パネルはConversation.order(updated_at: :desc)でクエリするため、新しいメッセージのある会話が上に浮かんでこなくなります。動作を元に戻すにはtouch: trueを再追加してください。
🌐 Claudeによる翻訳