Action Cable
Action Cable
Section titled “Action Cable”Action Cableは、RailsのWebSocketフレームワークです。リアルタイム通信を実装できます。Action Cableは「Railsでリアルタイム通信を民主化した」素晴らしい機能ですが、Reactをフロントエンドに使うモダンな開発において、サンプルコードのまま進むと**「スケーラビリティ」と「状態管理」の壁**に激突します。
Action Cableの設定
Section titled “Action Cableの設定”チャンネルの作成
Section titled “チャンネルの作成”class ChatChannel < ApplicationCable::Channel def subscribed stream_from "chat_#{params[:room]}" end
def receive(data) ActionCable.server.broadcast("chat_#{params[:room]}", data) endendクライアント側の実装
Section titled “クライアント側の実装”const cable = ActionCable.createConsumer();
cable.subscriptions.create( { channel: "ChatChannel", room: "general" }, { received(data) { console.log(data); } });⚠️ 「Redis」なしのWebSocketは、ただの飾り
Section titled “⚠️ 「Redis」なしのWebSocketは、ただの飾り”サンプルコードには現れませんが、Action Cableの裏側(Adapter)の設定が最重要です。
重要な理解:
デフォルトのasyncアダプタのまま本番稼働させてはいけません。
問題点:
asyncはメモリ上で動作するため、サーバーを複数台に増やした(スケーリングした)瞬間に、別のサーバーに繋がっているユーザーへメッセージが届かなくなります。
# ❌ 危険: デフォルトのasyncアダプタdevelopment: adapter: async # メモリ上で動作、スケーリング不可
production: adapter: async # 本番では絶対にNG改善案:
本番環境(および開発環境も推奨)では必ずRedisをcable.ymlに設定してください。Redisが「メッセージのハブ」となることで、どのサーバーに接続していても正しく放送(Broadcast)が行われます。
# ✅ 安全: Redisアダプタを使用development: adapter: redis url: redis://localhost:6379/1
test: adapter: test
production: adapter: redis url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> channel_prefix: my_app_productionRedisのインストール:
# HomebrewでRedisをインストールbrew install redis
# Redisを起動brew services start redisこの設定により、複数サーバー間でメッセージが正しく配信されます。
⚠️ Reactとの相性:cable.jsをそのまま持ち込まない
Section titled “⚠️ Reactとの相性:cable.jsをそのまま持ち込まない”提供されたクライアント側コードはRailsのSprockets/Asset Pipeline時代の書き方です。
重要な理解:
Reactコンポーネントの中でcable.subscriptions.createを無造作に呼ぶと、再レンダリングのたびに購読(Subscribe)が増え続け、接続過多でブラウザとサーバーが死にます。
問題点: WebSocketの接続は「副作用」です。
// ❌ 危険: 再レンダリングのたびに購読が増えるfunction ChatComponent() { const cable = ActionCable.createConsumer();
cable.subscriptions.create( { channel: "ChatChannel", room: "general" }, { received(data) { console.log(data); } } );
// 問題: コンポーネントが再レンダリングされるたびに新しい購読が作成される return <div>Chat</div>;}改善案:
Reactでは@rails/actioncable npmパッケージを使用し、useEffect内で購読を行い、クリーンアップ関数で必ずunsubscribe()する設計にしてください。あるいは、アプリ全体で1つの接続を使い回すために、接続をReact Contextで管理するのがプロの定石です。
// ✅ 安全: useEffectで購読を管理import { createConsumer } from '@rails/actioncable';import { useEffect, useRef } from 'react';
function ChatComponent() { const subscriptionRef = useRef(null);
useEffect(() => { const cable = createConsumer('ws://localhost:3000/cable');
const subscription = cable.subscriptions.create( { channel: "ChatChannel", room: "general" }, { received(data) { console.log(data); } } );
subscriptionRef.current = subscription;
// クリーンアップ: コンポーネントのアンマウント時に購読を解除 return () => { subscription.unsubscribe(); cable.disconnect(); }; }, []); // 空の依存配列で、マウント時のみ実行
return <div>Chat</div>;}
// ✅ より良い例: React Contextで接続を管理// contexts/ActionCableContext.jsximport { createContext, useContext, useEffect, useRef } from 'react';import { createConsumer } from '@rails/actioncable';
const ActionCableContext = createContext(null);
export function ActionCableProvider({ children }) { const cableRef = useRef(null);
useEffect(() => { cableRef.current = createConsumer('ws://localhost:3000/cable');
return () => { cableRef.current?.disconnect(); }; }, []);
return ( <ActionCableContext.Provider value={cableRef.current}> {children} </ActionCableContext.Provider> );}
export function useActionCable() { return useContext(ActionCableContext);}
// コンポーネントで使用function ChatComponent() { const cable = useActionCable(); const subscriptionRef = useRef(null);
useEffect(() => { if (!cable) return;
const subscription = cable.subscriptions.create( { channel: "ChatChannel", room: "general" }, { received(data) { console.log(data); } } );
subscriptionRef.current = subscription;
return () => { subscription.unsubscribe(); }; }, [cable]);
return <div>Chat</div>;}この設計により、接続が適切に管理され、メモリリークを防げます。
⚠️ 認証の壁:ApplicationCable::Connection
Section titled “⚠️ 認証の壁:ApplicationCable::Connection”Action Cableは通常のコントローラーとは独立したプロセスで動くため、current_userがそのままでは使えません。
重要な理解:
チャンネル内でparams[:user_id]を受け取って信じるのはセキュリティホールです。誰でも他人のIDを名乗ってメッセージを盗聴・偽造できてしまいます。
問題点: WebSocket接続時(Connection)に、Cookieやトークンからユーザーを特定する厳格な認証が必要です。
# ❌ 危険: paramsからuser_idを受け取るclass ChatChannel < ApplicationCable::Channel def subscribed user_id = params[:user_id] # セキュリティホール! stream_from "chat_#{user_id}" endend改善案:
app/channels/application_cable/connection.rbで、Cookieからセッションを読み取るか、JWTを検証してidentified_by :current_userを設定する「門番」を実装しましょう。
# ✅ 安全: 認証を厳格に実装module ApplicationCable class Connection < ActionCable::Connection::Base identified_by :current_user
def connect self.current_user = find_verified_user end
private
def find_verified_user # 方法1: Cookieからセッションを読み取る if verified_user = User.find_by(id: cookies.signed[:user_id]) verified_user # 方法2: JWTを検証 elsif token = request.headers['Authorization']&.gsub(/^Bearer /, '') decoded_token = JWT.decode(token, Rails.application.credentials.jwt_secret_key, true, { algorithm: 'HS256' }) User.find(decoded_token[0]['user_id']) else reject_unauthorized_connection end end endend
# チャンネルで使用class ChatChannel < ApplicationCable::Channel def subscribed stream_from "chat_#{current_user.id}" # 認証済みユーザーのIDを使用 endendこの設計により、セキュリティホールを防ぎ、適切な認証が行われます。
⚠️ 放送(Broadcast)の「Fatモデル」化
Section titled “⚠️ 放送(Broadcast)の「Fatモデル」化”よくあるアンチパターンとして、Modelのafter_create_commitで直接ActionCable.server.broadcastを叩く手法があります。
重要な理解: モデルを保存するたびに問答無用でWebSocketが飛ぶ設計は、バッチ処理やテスト実行時にノイズを撒き散らします。
問題点: 重いJSON生成処理(Serializer)をWebSocketスレッドで行うと、Rails本体のレスポンス性能が低下します。
# ❌ 危険: モデルで直接放送class Post < ApplicationRecord after_create_commit :broadcast_post
private
def broadcast_post # 問題: モデル保存のたびにWebSocketが飛ぶ # 問題: バッチ処理やテストでノイズになる # 問題: 重いJSON生成がWebSocketスレッドで実行される ActionCable.server.broadcast( "posts", PostSerializer.new(self).as_json ) endend改善案:
放送処理はActive Jobを介して非同期で行うべきです。broadcast_laterメソッドを活用する。重いJSON生成処理(Serializer)をWebSocketスレッドで行わず、バックグラウンドジョブに逃がすことで、Rails本体のレスポンス性能を守ります。
# ✅ 安全: Active Jobで非同期放送class Post < ApplicationRecord after_create_commit :broadcast_post_later
private
def broadcast_post_later # Active Jobで非同期に放送 PostBroadcastJob.perform_later(self) endend
# app/jobs/post_broadcast_job.rbclass PostBroadcastJob < ApplicationJob queue_as :default
def perform(post) # バックグラウンドジョブで重いJSON生成を行う ActionCable.server.broadcast( "posts", PostSerializer.new(post).as_json ) endend
# または、broadcast_laterメソッドを使用(Rails 6.1以降)class Post < ApplicationRecord after_create_commit do broadcast_later_to( "posts", partial: "posts/post", locals: { post: self } ) endendこの設計により、モデルがスッキリし、パフォーマンスも向上します。
🛠️ database.ymlとの関連性
Section titled “🛠️ database.ymlとの関連性”WebSocketを多用すると、接続を維持するためにDBコネクションを長時間占有するケースがあります。
重要な理解:
config/database.ymlのpoolサイズは、「Pumaのスレッド数 + Action Cableの同時処理数」を考慮して、通常より多めに積んでおくのが安全です。
default: &default adapter: postgresql encoding: unicode # Action Cableを使用する場合は、pool数を多めに設定 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 }.to_i + 10 %> checkout_timeout: 5 reaping_frequency: 10🚀 実務設計レビューチェックリスト:Action Cable編
Section titled “🚀 実務設計レビューチェックリスト:Action Cable編”もしあなたがチームのプルリクエストを見るなら、ここを確認してください:
- 大量接続時にサーバーのファイル記述子(File Descriptors)が枯渇しない設定になっているか?
- Pumaの設定で
max_threadsとworkersが適切に設定されているか?
冪等性と順序
Section titled “冪等性と順序”- メッセージが2回届いたり、順番が前後したりしてもフロントエンド(React)側で壊れない設計になっているか?
- メッセージに
idやtimestampを含めているか?
# ✅ 良い例: メッセージにidとtimestampを含めるActionCable.server.broadcast("chat_#{room}", { id: SecureRandom.uuid, timestamp: Time.current.iso8601, message: message, user_id: current_user.id})フォールバック
Section titled “フォールバック”- WebSocketが切断された場合(地下鉄など)、自動再接続(Reconnection)が行われるか?
- あるいはポーリングに切り替わるか?
// ✅ 安全: 自動再接続を実装const cable = createConsumer('ws://localhost:3000/cable');
cable.connection.addEventListener('close', () => { // 自動再接続のロジック setTimeout(() => { cable.connect(); }, 1000);});これらのチェックリストを確認することで、堅牢なリアルタイム通信機能を構築できます。