Skip to content

Action Cable

Action Cableは、RailsのWebSocketフレームワークです。リアルタイム通信を実装できます。Action Cableは「Railsでリアルタイム通信を民主化した」素晴らしい機能ですが、Reactをフロントエンドに使うモダンな開発において、サンプルコードのまま進むと**「スケーラビリティ」と「状態管理」の壁**に激突します。

app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
def subscribed
stream_from "chat_#{params[:room]}"
end
def receive(data)
ActionCable.server.broadcast("chat_#{params[:room]}", data)
end
end
app/assets/javascripts/cable.js
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はメモリ上で動作するため、サーバーを複数台に増やした(スケーリングした)瞬間に、別のサーバーに繋がっているユーザーへメッセージが届かなくなります。

config/cable.yml
# ❌ 危険: デフォルトのasyncアダプタ
development:
adapter: async # メモリ上で動作、スケーリング不可
production:
adapter: async # 本番では絶対にNG

改善案: 本番環境(および開発環境も推奨)では必ずRedisをcable.ymlに設定してください。Redisが「メッセージのハブ」となることで、どのサーバーに接続していても正しく放送(Broadcast)が行われます。

config/cable.yml
# ✅ 安全: 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_production

Redisのインストール:

Terminal window
# 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.jsx
import { 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}"
end
end

改善案: app/channels/application_cable/connection.rbで、Cookieからセッションを読み取るか、JWTを検証してidentified_by :current_userを設定する「門番」を実装しましょう。

app/channels/application_cable/connection.rb
# ✅ 安全: 認証を厳格に実装
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
end
end
# チャンネルで使用
class ChatChannel < ApplicationCable::Channel
def subscribed
stream_from "chat_#{current_user.id}" # 認証済みユーザーのIDを使用
end
end

この設計により、セキュリティホールを防ぎ、適切な認証が行われます。

⚠️ 放送(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
)
end
end

改善案: 放送処理は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)
end
end
# app/jobs/post_broadcast_job.rb
class PostBroadcastJob < ApplicationJob
queue_as :default
def perform(post)
# バックグラウンドジョブで重いJSON生成を行う
ActionCable.server.broadcast(
"posts",
PostSerializer.new(post).as_json
)
end
end
# または、broadcast_laterメソッドを使用(Rails 6.1以降)
class Post < ApplicationRecord
after_create_commit do
broadcast_later_to(
"posts",
partial: "posts/post",
locals: { post: self }
)
end
end

この設計により、モデルがスッキリし、パフォーマンスも向上します。

WebSocketを多用すると、接続を維持するためにDBコネクションを長時間占有するケースがあります。

重要な理解: config/database.ymlpoolサイズは、「Pumaのスレッド数 + Action Cableの同時処理数」を考慮して、通常より多めに積んでおくのが安全です。

config/database.yml
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_threadsworkersが適切に設定されているか?
  • メッセージが2回届いたり、順番が前後したりしてもフロントエンド(React)側で壊れない設計になっているか?
  • メッセージにidtimestampを含めているか?
# ✅ 良い例: メッセージにidとtimestampを含める
ActionCable.server.broadcast("chat_#{room}", {
id: SecureRandom.uuid,
timestamp: Time.current.iso8601,
message: message,
user_id: current_user.id
})
  • WebSocketが切断された場合(地下鉄など)、自動再接続(Reconnection)が行われるか?
  • あるいはポーリングに切り替わるか?
// ✅ 安全: 自動再接続を実装
const cable = createConsumer('ws://localhost:3000/cable');
cable.connection.addEventListener('close', () => {
// 自動再接続のロジック
setTimeout(() => {
cable.connect();
}, 1000);
});

これらのチェックリストを確認することで、堅牢なリアルタイム通信機能を構築できます。