チャットを作る:第1章 — アーキテクチャとセットアップ

Tony Duong

Tony Duong

5月 31, 20262

他の言語:🇫🇷🇬🇧
#rails#nextjs#actioncable#websockets#architecture
チャットを作る:第1章 — アーキテクチャとセットアップ

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

このチュートリアルでは、Next.jsブログ向けに構築されたリアルタイムチャット機能を解説します。訪問者はチャットウィジェットを開いてサイト管理者と会話します。管理者はダッシュボードですべての会話を確認できます。メッセージは双方向にリアルタイムで届きます。

興味深いポイントは、フロントエンドがNext.js 16アプリ、バックエンドがRails 8 APIという構成です。それぞれ別プロセス、別ポートで動作し、WebSocketで接続されています。この章では各パーツがどのように組み合わさるかを説明し、両方のサーバーをローカルマシンで起動するところまで進めます。

2つの独立したアプリ

プロジェクトは2つのコードベースで構成されています:

フロントエンド バックエンド
フレームワーク Next.js 16 (App Router) Rails 8 (APIオンリー)
パス / (リポジトリルート) chat-server/ (gitサブモジュール)
ポート 3000 3100
デプロイ先 Vercel Kamal 2 (Docker)
通信手段 @rails/actioncable (WebSocket) + fetch (HTTP) ActionCable + RESTコントローラー

フロントエンドは訪問者が目にするすべてを担当します:ブログ、フローティングチャットバブル、管理ダッシュボード。バックエンドはリアルタイム処理をすべて担当します:WebSocket接続、メッセージの永続化、プッシュ通知。

4つの基本概念

1. WebSocket vs HTTP

HTTPはリクエスト-レスポンス方式です。クライアントが要求し、サーバーが応答し、接続が閉じます。WebSocketは永続的な双方向パイプで、どちらの側からでも相手の要求なしにいつでもデータを送信できます。

このプロジェクトでは、双方向のすべてのメッセージングにWebSocketを使用します。HTTPは3つの用途にのみ使用されます:管理者ログイン、会話の一覧取得・削除、プッシュサブスクリプションの登録です。

2. ActionCable

ActionCableはRails組み込みのWebSocketフレームワークです。3つのレイヤーがあります:

  • Connection — WebSocketアップグレードリクエストを認証します。chat-server/app/channels/application_cable/connection.rbに配置されています。このコードベースではidentified_byを使って2種類の接続を識別します:
identified_by :visitor_token, :admin
  • Channel — 接続ができることの範囲を定義します。このコードベースには2つあります:VisitorChannel(ブラウザセッションごとに1つ)とAdminChannel(全会話用に1つ)。
  • Consumer — クライアント側の対応物で、@rails/actioncable npmパッケージが提供します。createConsumer(url)関数がWebSocketを開き、subscriptions.create()を呼び出すためのオブジェクトを返します。

3. Solid Cable

従来のActionCableデプロイにはpub/sub用のRedisが必要でした。Rails 8ではSolid Cableがデフォルトアダプターとして搭載されており、pub/subメッセージをRedisの代わりにSQLiteに保存します。

設定は最小限です。chat-server/config/cable.ymlより:

development:
  adapter: solid_cable
  connects_to:
    database:
      writing: cable
  polling_interval: 0.1.seconds
  message_retention: 1.day

トレードオフ:Redisプロセスの管理は不要になりますが、水平スケーリングはできません。1つのPumaプロセス、1つのSQLiteファイル。個人ブログにはこれが正しい選択です。

4. クロスオリジン認証

フロントエンドはlocalhost:3000、バックエンドはlocalhost:3100で動作します。ブラウザはデフォルトでクロスオリジンCookieをブロックするため、2つの設定が必要です:

サーバー側chat-server/config/initializers/cors.rbがフロントエンドオリジンをcredentials: trueで許可します:

origins(*ENV.fetch("ALLOWED_ORIGINS", "http://localhost:3000").split(",").map(&:strip))
resource "*", headers: :any, methods: [...], credentials: true

クライアント側src/lib/chat-client.tsのすべてのfetch呼び出しでcredentials: "include"を設定し、ブラウザがクロスオリジンでCookieを送信するようにします。

管理者認証にはRailsセッションCookieを使用します。訪問者認証はよりシンプルで、WebSocket URLにクエリパラメータとしてUUIDトークンを渡します:

visitorConsumer = createConsumer(`${WS_URL}?token=${sessionToken}`);

リポジトリ構成

src/
  components/
    ChatWidget.tsx           # 訪問者用のフローティングバブル
    ChatPanel.tsx            # 共有メッセージ表示コンポーネント
    AdminChat.tsx            # 会話一覧付きの管理ダッシュボード
  lib/
    chat-client.ts           # ActionCable Consumerファクトリー + HTTPヘルパー
    chat-types.ts            # チャンネルデータ用TypeScript判別共用体
  types/
    actioncable.d.ts         # @rails/actioncable用の手書き型宣言
  app/
    admin/
      login/page.tsx         # 認証フォーム -> POST /auth/login
      chats/page.tsx         # AdminChatコンポーネントをレンダリング

chat-server/
  app/
    channels/
      application_cable/
        connection.rb        # デュアル認証: 訪問者トークン OR 管理者セッション
      visitor_channel.rb     # ブラウザセッションごとに1チャンネル
      admin_channel.rb       # 全会話用に1チャンネル
    controllers/
      auth_controller.rb     # ENV変数ベースのログイン、session[:admin]を設定
      conversations_controller.rb
      push_subscriptions_controller.rb
    models/
      conversation.rb        # session_token + visitor_name + unread_count
      message.rb             # sender (visitor|admin) + content
      push_subscription.rb   # Web Pushサブスクリプションデータ
    services/
      push_notification_service.rb
  config/
    cable.yml                # Solid Cableアダプター設定
    puma.rb                  # ポート3100、シングルモード
    routes.rb                # ActionCableを/cableにマウント

src/app/admin/の管理セクションはi18nの[locale]ルートグループの外に配置されています。独自のlayout.tsx<html>タグと<body>タグを持ちます。ブログとは完全に別のページシェルです。

アプリ実行時の動作

ターミナル1 — フロントエンドを起動:

npm run dev

Next.jsがポート3000で起動します。重要な2つの環境変数は.env.localにあります:

NEXT_PUBLIC_CHAT_WS_URL=ws://localhost:3100/cable
NEXT_PUBLIC_CHAT_HTTP_URL=http://localhost:3100

ターミナル2 — バックエンドを起動:

cd chat-server
ADMIN_USER=tony ADMIN_PASSWORD=secret bin/rails server -p 3100

Pumaがポート3100で起動します(config/puma.rbport ENV.fetch("PORT", 3100)で設定)。ActionCableはconfig/routes.rb/cableにマウントされています:

mount ActionCable.server => "/cable"

RailsはAPIオンリーモードで設定されており(config/application.rbconfig.api_only = true)、セッションミドルウェアが除外されています。しかし管理者認証にはセッションが必要なため、2行で追加し直しています:

config.middleware.use ActionDispatch::Cookies
config.middleware.use ActionDispatch::Session::CookieStore, key: "_chat_server_session"

訪問者がチャットウィジェットを開くと、フロントエンドはsrc/lib/chat-client.tsgetVisitorConsumer(sessionToken)を呼び出します。この関数はWebSocket URLに?token=クエリパラメータを付けてcreateConsumer()を呼びます。ブラウザがws://localhost:3100/cable?token=<uuid>へのWebSocketを開きます。RailsのConnection#connectメソッドがrequest.params[:token]をチェックし、存在を確認して接続を受け入れます。

セットアップ

前提条件:Node.js 18以上、Ruby 3.2以上、SQLite3。

# フロントエンドの依存関係
npm install

# バックエンドの依存関係
cd chat-server
bundle install
bin/rails db:prepare

# 環境ファイル
cd ..
cp .env.example .env
cp .env.local.example .env.local

.env.localを編集して、デフォルト値がお使いの環境と合わない場合はNEXT_PUBLIC_CHAT_WS_URLNEXT_PUBLIC_CHAT_HTTP_URLを設定してください。その後、別々のターミナルで両方のサーバーを起動します。


2つの半分が動作するのを確認できたので、第2章ではRailsバックエンドに踏み込みます。ActionCableが接続をどう認証するか、VisitorChannelAdminChannelの中で何が起こるか、そしてメッセージがSolid Cableを通じてあるブラウザから別のブラウザにどう流れるかを見ていきます。

やってみよう

演習1. 両方のサーバーを起動してください。ブラウザのDevToolsを開き、Networkタブで「WS」でフィルタリングします。ブログのチャットウィジェットを開いてください。ws://localhost:3100/cable?token=...へのWebSocket接続が表示されるはずです。フレームを確認してください — ハンドシェイク後にサーバーが送信する最初のメッセージは何ですか?

最初のメッセージは{"type":"welcome"}というJSONオブジェクトです。これはActionCableのハンドシェイク確認です。その後、クライアントがVisitorChannelに対するsubscribeコマンドを送信し、サーバーが{"type":"confirm_subscription",...}で応答します。

演習2. chat-server/config/puma.rbでデフォルトポートを3100から3200に変更してください。Railsサーバーを再起動します。何が壊れますか?どう修正しますか?

チャットウィジェットが接続できなくなります。フロントエンドはまだws://localhost:3100/cableを指しています。修正するには、.env.localの両方の環境変数を更新します:

NEXT_PUBLIC_CHAT_WS_URL=ws://localhost:3200/cable
NEXT_PUBLIC_CHAT_HTTP_URL=http://localhost:3200

Rails側のALLOWED_ORIGINSも更新する必要があります(ただし、フロントエンドのオリジンが変わっていないため、デフォルトのhttp://localhost:3000はそのまま機能します — バックエンドのポートだけが移動しました)。両方のサーバーを再起動してください。

演習3. chat-server/config/application.rbで、CookiesSession::CookieStoreを追加する2行のconfig.middleware.useをコメントアウトしてください。Railsを再起動します。/admin/loginで管理者ログインを試してください。何が起こりますか?なぜですか?

/auth/loginへのログインPOSTは成功します({ "ok": true }を返します)が、CookieミドルウェアがなくなったためセッションCookieが設定されません。その後フロントエンドが管理者用のWebSocketを開くとき、Connection#connectrequest.session[:admin]をチェックしますが、セッションが存在しないためnilです。接続は拒否されます。管理ダッシュボードは切断状態を表示します。行のコメントアウトを元に戻して再起動すれば修正できます。

演習4. src/lib/chat-types.tsVisitorChannelData型を読んでください。typeフィールドによる判別共用体です。仮想的な4番目のバリアント{ type: "typing"; is_typing: boolean }を共用体に追加してください。フロントエンドコードのどこでこの新しいバリアントを処理する必要がありますか?

この型はsrc/lib/chat-client.tssubscribeVisitorChannel()に渡されるreceivedコールバックで使用されます。この関数を呼び出すすべてのコンポーネントがreceivedコールバックでデータを処理します — 主にChatWidget.tsxです。受信メッセージを処理するswitch文/条件分岐にcase "typing":ブランチ(またはif (data.type === "typing"))を追加し、タイピングインジケーターを表示するようUIを更新します。


🌐 Claudeによる翻訳

Tony Duong

著者: Tony Duong

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