チャットを作る:第2章 — ActionCableバックエンド

Tony Duong

Tony Duong

5月 31, 20263

他の言語:🇫🇷🇬🇧
#rails#actioncable#solid-cable#sqlite#websockets
チャットを作る:第2章 — ActionCableバックエンド

💬 これは、このサイトの右下に表示されているライブチャットそのものの実装です。 このシリーズでは、それをどう作ったかを順を追って解説します。チャットの吹き出しを開いて試してから、その仕組みを読み進めてみてください。

この章では、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/loginAuthControllerで設定された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_nameunread_count ソート用のupdated_at
messages conversation_id(FK)、sendercontent(text) 複合(conversation_id, created_at)
push_subscriptions endpoint(ユニーク)、p256dhauth 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.ymlpolling_interval2.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による翻訳

Tony Duong

著者: Tony Duong

デジタル日記。思考、経験、そして人生についての考え。