🚀 RailsとReactで作るリアルタイムチャット: Action CableとGraphQL Subscription実践入門
0. はじめに
「誰かがメッセージを送った瞬間、他のユーザーの画面にもリアルタイムで表示される」
そんなチャットアプリケーションを一度は作ってみたいと思ったことはありませんか?
この記事では、Ruby on RailsとReact(Apollo Client)を使って、GraphQL SubscriptionとAction Cableを連携させたリアルタイムチャット機能を実装する方法を解説します。
📝 対象読者
- GraphQLの基本(Query, Mutation)は理解しているが、Subscriptionははじめて
- Rails + Reactでのアプリケーション開発経験がある
- WebSocketやリアルタイム通信に興味がある
🎯 この記事で学べること
- WebSocketとGraphQL Subscriptionの基本概念 - なぜリアルタイム通信が必要なのか?
-
Action Cableと
graphql-ruby
の連携方法 - Rails流のWebSocket実装 - Apollo ClientでのSubscription購読 - React側でのリアルタイム更新
- 実装でハマりがちなポイント - 接続切断、キャッシュ更新、エラーハンドリング
🛠️ 使用技術スタック
バックエンド: Ruby on Rails 8.0 + graphql-ruby + Action Cable + Redis
フロントエンド: React 19 + Apollo Client + ActionCableLink
通信プロトコル: HTTP (Query/Mutation) + WebSocket (Subscription)
1. 💡 リアルタイム通信の舞台裏:WebSocket, Action Cable, Subscription
なぜポーリングではダメなのか?
従来のWebアプリケーションでリアルタイム性を実現するには、ポーリングという手法がありました。これは、クライアントが定期的(例:3秒おき)にサーバーに「新しいメッセージはありますか?」と問い合わせる方法です。
// ポーリングの例(非効率)
setInterval(() => {
fetch('/api/messages/latest')
.then(response => response.json())
.then(messages => updateUI(messages));
}, 3000); // 3秒ごとに問い合わせ
ポーリングの問題点:
- 📡 無駄な通信 - 新しいメッセージがなくても定期的にリクエスト
- ⏱️ 遅延 - 最大でポーリング間隔分の遅延が発生
- 🔋 リソース消費 - サーバーとクライアント両方に負荷
WebSocketが解決する課題:
- ✅ 真のリアルタイム - イベント発生と同時に通知
- ✅ 効率的 - 必要なときだけデータ送信
- ✅ 双方向 - サーバーからクライアントへも自由に送信可能
1.1. WebSocketとは? - 双方向通信の実現
従来のHTTP通信との違い
HTTPはクライアントからのリクエストがあってはじめてサーバーが応答を返す一方向の通信です。一方、WebSocketは一度接続を確立すると、サーバーとクライアントがいつでも自由にデータを送り合える「双方向通信」を可能にします。
この「サーバーからいつでもデータを送れる」という特性が、チャットや通知、ライブフィードといったリアルタイムアプリケーションの根幹を支えています。
1.2. Action Cableとは? - RailsにおけるWebSocketの相棒
Action Cableは、Rails 5から標準で導入された、WebSocket通信を簡単かつRailsらしく扱うためのフレームワークです。
主な構成要素:
-
チャンネル(Channel) - 特定の話題(たとえば
ChatChannel
)に関する通信路。クライアントはこのチャンネルを「購読」します -
ストリーム(Stream) - チャンネル内のさらに具体的な配信先。たとえば、特定のチャットルーム(
chat_room_1
)などがストリームになります - ブロードキャスト(Broadcast) - 特定のストリームに接続しているすべてのクライアントに対して、サーバーからデータを一斉送信すること
# Action Cableの基本的な使い方
ActionCable.server.broadcast(
"chat_room_#{room_id}", # ストリーム名
{ message: "こんにちは!", user: "田中" } # ペイロード
)
1.3. GraphQL Subscriptionとは? - リアルタイムなデータ購読
GraphQLにはデータの取得(Query)、変更(Mutation)に加えて、第三の操作としてSubscriptionがあります。
Subscriptionは、特定のイベントが発生した際にサーバーからプッシュ通知を受け取るための仕組みです。クライアントは「このイベントが起きたら教えて」とサーバーに伝え、長期的な接続(通常はWebSocket)を確立して待機します。
# Subscriptionの例
subscription {
messageAdded {
message {
id
content
senderName
createdAt
}
}
}
1.4. 今回の技術スタックの連携図
これら3つの技術がどのように連携するかを図で確認しましょう:
処理の流れ:
- Apollo Client(フロントエンド)が
ActionCableLink
を使ってAction Cable(サーバー)にWebSocket接続を要求 - Action CableはGraphQL Subscriptionのリクエストを受け取り、
graphql-ruby
gemのActionCableSubscriptions
機能がこれを解釈 - Mutationによってデータが更新されると、
trigger
が発火し、Action Cableを通じて購読しているすべてのクライアントにリアルタイムでデータがブロードキャスト - GraphQLの型安全性を保ちつつ、リアルタイムな通信が実現される
2. 🏗️ アーキテクチャの全体像
実装に入る前に、クライアントとサーバーがどのように連携してリアルタイム通信を実現するのか、全体の流れを把握しましょう。
登場人物の紹介
- クライアント(Apollo Client, ActionCableLink)
- サーバー(Rails, Action Cable, GraphqlChannel)
- Redis(Pub/Subサーバー)
リアルタイム通信の処理フロー
- クライアントがWebSocket接続を確立し、Subscriptionを購読する
- Action CableがRedisのPub/Subを購読する
- 別のクライアントがMutation(メッセージ投稿)を実行する
- サーバーがイベントをトリガーし、RedisにメッセージをPublishする
- Action CableがRedisからメッセージを受け取り、購読しているすべてのクライアントにデータをブロードキャストする
- クライアントがデータを受信し、UIを更新する
3. 🔧 バックエンド実装(Ruby on Rails)
それでは、サーバーサイドから実装していきましょう。
3.1. 準備:Gemとルーティング
Gemfile
に必要なgemを追加します。関連部分のみ抜粋します。
source "https://rubygems.org"
gem "rails", "~> 8.0.2"
# ...
gem "redis" # Action CableのPub/Sub用
# ...
gem "graphql", "~> 2.4.16" # GraphQLコア機能
# ...
group :development do
gem "graphiql-rails" # GraphQLの開発ツール
end
config/routes.rb
にAction Cableのマウントポイントを追加します。
Rails.application.routes.draw do
# ...
post "/graphql", to: "graphql#execute"
# ...
mount ActionCable.server => "/cable" # WebSocket接続用エンドポイント
end
config/cable.yml
でRedisアダプターを設定します。
development:
adapter: redis
url: redis://redis:6379
channel_prefix: gql-chat-cable
test:
adapter: test
production:
adapter: redis
channel_prefix: gql-chat-cable
3.2. GraphQLスキーマ全体像
本記事で扱うGraphQLスキーマの全体像は以下の通りです。Query
、Mutation
、そして今回の主役であるSubscription
が定義されています。
"""
Autogenerated input type of CreateMessage
"""
input CreateMessageInput {
content: String!
}
"""
Autogenerated return type of CreateMessage.
"""
type CreateMessagePayload {
message: Message!
}
"""
An ISO 8601-encoded datetime
"""
scalar ISO8601DateTime @specifiedBy(url: "https://tools.ietf.org/html/rfc3339")
type Message implements Node {
content: String!
createdAt: ISO8601DateTime!
id: ID!
senderName: String!
}
"""
Autogenerated return type of MessageAdded.
"""
type MessageAddedPayload {
"""
新しく追加されたメッセージ
"""
message: Message!
}
"""
The connection type for Message.
"""
type MessageConnection {
"""
A list of edges.
"""
edges: [MessageEdge]
"""
A list of nodes.
"""
nodes: [Message]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type MessageEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: Message
}
type Mutation {
"""
チャットを投稿する
"""
createMessage(
"""
Parameters for CreateMessage
"""
input: CreateMessageInput!
): CreateMessagePayload
"""
チャットを更新する
"""
updateMessage(
"""
Parameters for UpdateMessage
"""
input: UpdateMessageInput!
): UpdateMessagePayload
}
interface Node {
id: ID!
}
"""
Information about pagination in a connection.
"""
type PageInfo {
"""
When paginating forwards, the cursor to continue.
"""
endCursor: String
"""
When paginating forwards, are there more items?
"""
hasNextPage: Boolean!
"""
When paginating backwards, are there more items?
"""
hasPreviousPage: Boolean!
"""
When paginating backwards, the cursor to continue.
"""
startCursor: String
}
type Query {
"""
Fetch messages
"""
messages(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
): MessageConnection!
}
type Subscription {
"""
新しいメッセージが追加された時に発火するサブスクリプション
"""
messageAdded: MessageAddedPayload!
}
"""
Autogenerated input type of UpdateMessage
"""
input UpdateMessageInput {
content: String!
messageId: ID!
}
"""
Autogenerated return type of UpdateMessage.
"""
type UpdateMessagePayload {
message: Message!
}
3.3. GraphQL Subscriptionの定義
app/graphql/types/subscription_type.rb
でSubscriptionのエントリーポイントを定義します。field
のsubscription:
オプションに、後述するSubscriptionクラスを指定します。
module Types
class SubscriptionType < Types::BaseObject
field :message_added, subscription: Subscriptions::MessageAdded, broadcastable: true
end
end
app/graphql/subscriptions/message_added.rb
でmessageAdded
イベントの具体的な処理を実装します。
🔍 重要なポイント:update
メソッドの役割
graphql-ruby
のActionCableSubscriptions
は、クライアントが購読を開始した直後に、このupdate
メソッドを一度呼び出します。このとき、スキーマでmessage
フィールドがnull: false
(Message!
)と定義されているため、nil
ではない有効なMessage
オブジェクトを返さないとエラーになります。
ここでは、Message.last
またはダミーのMessage
オブジェクトを返すことで、この制約を満たし、Subscriptionのセットアップを正常に完了させています。
module Subscriptions
class MessageAdded < Base
description "新しいメッセージが追加された時に発火するサブスクリプション"
field :message, Types::Message, null: false, description: "新しく追加されたメッセージ", broadcastable: true
def subscribe
{}
end
def update(payload = nil)
message_record = Message.last || Message.new(
id: "dummy",
sender_name: "System",
content: "No message available.",
created_at: Time.current,
updated_at: Time.current
)
{ message: message_record }
end
end
end
3.4. Action Cableとの連携
app/graphql/gql_chat_schema.rb
にuse GraphQL::Subscriptions::ActionCableSubscriptions
を追加して、graphql-ruby
にAction Cableを使うことを伝えます。これにより、Subscriptionのトランスポート層としてAction Cableが利用されるようになります。
class GqlChatSchema < GraphQL::Schema
query { Types::QueryType }
mutation { Types::MutationType }
subscription { Types::SubscriptionType }
use GraphQL::Dataloader
use GraphQL::Subscriptions::ActionCableSubscriptions, broadcast: true
rescue_from(ActiveRecord::RecordNotFound) do |err, obj, args, ctx, field|
raise GraphQL::ExecutionError, "#{field.type.unwrap.graphql_name} not found", code: "RESOURCE_NOTFOUND"
end
rescue_from(StandardError) do |err, obj, args, ctx, field|
raise err if Rails.env.development? || Rails.env.test?
Rails.logger.error(err)
Rails.logger.error(err.backtrace.join("\n"))
GraphQL::ExecutionError.new("エラーが発生しました。", extensions: { code: "INTERNAL_SERVER_ERROR" })
end
def self.type_error(err, context)
# ...
end
end
app/channels/graphql_channel.rb
でクライアントからのWebSocket接続を処理するチャネルを作成します。
🔍 重要なポイント:context
にchannel: self
を渡す理由
context
にchannel: self
を渡すことが重要です。これにより、ActionCableSubscriptions
が内部で現在のクライアント接続(チャネル)を認識し、その接続に対して正しくストリームをセットアップしたり、データを配信したりできるようになります。
class GraphqlChannel < ApplicationCable::Channel
def subscribed
@subscription_ids = []
end
def execute(data)
query = data["query"]
variables = ensure_hash(data["variables"])
operation_name = data["operationName"]
context = {
channel: self # ActionCableSubscriptionsで必要
}
result = GqlChatSchema.execute(
query,
variables:,
context:,
operation_name:
)
@subscription_ids << result.context[:subscription_id] if result.context[:subscription_id]
rescue StandardError => e
Rails.logger.error "GraphQL execution error: #{e.message}\n#{e.backtrace.join("\n")}"
transmit({ errors: [ { message: e.message } ] })
end
def unsubscribed
@subscription_ids.each do |sid|
GqlChatSchema.subscriptions.delete_subscription(sid)
end
end
private
def ensure_hash(ambiguous_param)
case ambiguous_param
when String
if ambiguous_param.present?
ensure_hash(JSON.parse(ambiguous_param))
else
{}
end
when Hash, ActionController::Parameters
ambiguous_param
when nil
{}
else
raise ArgumentError, "Unexpected parameter: #{ambiguous_param}"
end
end
end
3.5. イベントの発火
app/graphql/mutations/create_message.rb
でメッセージが作成された後、GqlChatSchema.subscriptions.trigger
を呼び出して、messageAdded
イベントを発火させます。
引数の説明:
- 第1引数
messageAdded
:subscription_type.rb
で定義したフィールド名と一致させます - 第2引数
{}
: 特定の条件で購読を絞り込むための引数ですが、今回は使用しないため空のハッシュを渡します - 第3引数
{ message: }
: クライアントに送信するペイロードです。messageAdded
フィールドの返り値の型(MessageAddedPayload
)と一致するデータを渡します
module Mutations
class CreateMessage < Mutations::Base
field :message, Types::Message, null: false
argument :content, String, required: true
def resolve(**args)
user = context[:current_user]
raise GraphQL::ExecutionError, "You need to authenticate to perform this action" unless user
message = Message.create!(sender_name: user.name, content: args[:content])
GqlChatSchema.subscriptions.trigger(
"messageAdded",
{},
{ message: }
)
{ message: }
end
end
end
4. ⚛️ フロントエンド実装(React & Apollo Client)
次に、クライアントサイドでサーバーからの更新情報を受け取る仕組みを実装します。
4.1. 準備:ライブラリのインストール
package.json
に@apollo/client
, @rails/actioncable
, graphql
などをインストールします。graphql-ruby-client
は、RailsのAction CableとApollo Clientを連携させるための便利なライブラリです。関連する部分のみ抜粋します。
{
"dependencies": {
"@apollo/client": "^3.13.2",
"@rails/actioncable": "^8.0.100",
"graphql": "^16.10.0",
"graphql-ruby-client": "^1.14.7",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
4.2. Apollo ClientとAction Cableの接続
ActionCableLink
を作成し、split
関数で操作の種類に応じて通信をHTTPとWebSocketに振り分けます。
🔍 重要なポイント:ApolloLink.split
の動作
ApolloLink.split
は、第一引数のテスト関数(ここではhasSubscriptionOperation
)の結果に応じて、リクエストを振り分けるための強力な仕組みです。
- テスト関数が
true
を返した場合(操作がsubscription
の場合):第二引数のcableLink
(WebSocket)が使われます - テスト関数が
false
を返した場合(操作がquery
やmutation
の場合):第三引数のhttpLink
(HTTP)が使われます
これにより、各操作に最適な通信プロトコルを効率的に利用できます。
import { ApolloClient, InMemoryCache, ApolloLink, HttpLink } from '@apollo/client';
import ActionCableLink from 'graphql-ruby-client/subscriptions/ActionCableLink';
import { createConsumer } from '@rails/actioncable'
// HTTP通信用のリンク
const httpLink = new HttpLink({
uri: '/graphql',
headers: {
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content || null,
}
});
// WebSocket用のリンク
const cableLink = new ActionCableLink({
cable: createConsumer('/cable')
});
const hasSubscriptionOperation = ({ query: { definitions } }) => {
return definitions.some(
({ kind, operation }) => kind === 'OperationDefinition' && operation === 'subscription'
)
}
const link = ApolloLink.split(
hasSubscriptionOperation,
cableLink,
httpLink
);
export const createClient = (options) => {
return new ApolloClient({
link,
cache: new InMemoryCache(),
...options,
});
};
4.3. Reactコンポーネントでの利用
useSubscription
フックを使ってSubscriptionを購読し、キャッシュを更新します。
🔍 重要なポイント:手動でのキャッシュ更新
useSubscription
はサーバーからのリアルタイム更新を受け取るためのフックです。onData
コールバック内で、新しいメッセージ(newMessage
)を受け取った後の処理がUI更新の鍵となります。
単にデータを受け取るだけでは画面は自動で更新されません。client.readQuery
で現在のキャッシュを読み込み、新しいメッセージがまだキャッシュに存在しないことを確認した上で、client.writeQuery
を使ってキャッシュの先頭に新しいメッセージを追加しています。
この手動でのキャッシュ更新により、他のユーザーが投稿したメッセージが自分の画面にもリアルタイムで表示されるようになります。
import { useMutation, useQuery, useSubscription } from "@apollo/client";
import { graphql } from "./gql";
import React, { useState } from "react";
import { useApolloClient } from "@apollo/client";
const FETCH_LIMIT = 10;
const GET_MESSAGES = graphql(`
query GetMessages($first: Int!, $after: String!) {
messages(first: $first, after: $after) {
edges {
node {
id
senderName
content
createdAt
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
`);
const CREATE_MESSAGE = graphql(`
mutation CreateMessage($content: String!) {
createMessage(input: { content: $content }) {
message {
id
senderName
content
createdAt
}
}
}
`);
const MESSAGE_ADDED_SUBSCRIPTION = graphql(`
subscription MessageAdded {
messageAdded {
message {
id
senderName
content
createdAt
}
}
}
`);
// ... Form, Listコンポーネントは省略 ...
export const App = function () {
const client = useApolloClient();
const { data, loading, error, fetchMore } = useQuery(GET_MESSAGES, {
variables: { first: FETCH_LIMIT, after: "" },
});
const [createMessage] = useMutation(CREATE_MESSAGE);
useSubscription(MESSAGE_ADDED_SUBSCRIPTION, {
onData: ({ data: subscriptionData }) => {
const newMessage = subscriptionData.data?.messageAdded?.message;
if (newMessage) {
const existingData = client.readQuery({
query: GET_MESSAGES,
variables: { first: FETCH_LIMIT, after: "" },
});
if (
existingData &&
!existingData.messages.edges.some(
(edge) => edge.node.id === newMessage.id
)
) {
client.writeQuery({
query: GET_MESSAGES,
variables: { first: FETCH_LIMIT, after: "" },
data: {
messages: {
...existingData.messages,
edges: [
{ __typename: "MessageEdge", node: newMessage },
...existingData.messages.edges,
],
},
},
});
}
}
},
});
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
const messages = data.messages.edges.map((edge) => edge.node);
const { hasNextPage, endCursor } = data.messages.pageInfo;
// ... return JSX は省略 ...
};
5. 🛠️ 実装でつまずいた点と解決策
リアルタイムアプリケーションの開発では、WebSocket接続の安定性が非常に重要です。開発初期段階では、しばらく操作しないとWebSocket接続が意図せず切断され、Subscriptionの更新が届かなくなるという問題に直面しました。ここでは、その原因と現在のコードでどのように解決されているかを解説します。
😰 課題:WebSocket接続がアイドル状態になると切断される
現象
アプリケーションを開いたまましばらく放置すると、クライアントとサーバー間のWebSocket接続が切れてしまい、他のユーザーがメッセージを投稿してもリアルタイムで表示が更新されなくなりました。
原因
この種の切断は、多くの場合、経路上にあるプロキシサーバーやロードバランサーが、通信のないアイドル状態の接続をタイムアウトとして処理し、強制的に閉じてしまうために発生します。また、ブラウザーの省電力機能やネットワークの不安定さも原因となり得ます。
✅ 解決策:Action Cableの接続維持機能とRedisによる堅牢なバックボーン
現在の実装でこの問題が解決されているのは、特定のコードを追加したからというよりは、Action Cableと@rails/actioncable
ライブラリが持つ標準機能が正しく連携して動作しているためです。そして、Redisがその信頼性を高めています。
-
Action Cableのハートビート機能(サーバーサイド)
Railsに標準で組み込まれているAction Cableは、接続がアクティブであることを確認するための「ハートビート」機能を備えています。サーバーは定期的(デフォルト3秒間隔)にクライアントにpingメッセージを送信し、クライアントからのpong応答を監視します。この定期的な通信により、経路上の中間サーバーは接続がアクティブであると判断し、タイムアウトで切断するのを防ぎます。
-
自動再接続機能(クライアントサイド)
フロントエンドで利用している
@rails/actioncable
ライブラリは、接続が予期せず切断された場合に、自動的にサーバーへの再接続を試みる機能を内蔵しています。これにより、一時的なネットワークの問題などで接続が途切れても、ユーザーが意識することなく接続が回復します。 -
Redisの役割(Pub/Subによる信頼性の向上)
config/cable.yml
で設定している通り、このアプリケーションではAction CableのアダプターとしてRedisを使用しています。Redisの役割は、WebSocket接続そのものを永続化することではありません。Redisは、Pub/Sub(出版/購読)メッセージブローカーとして機能し、複数のサーバープロセスやコンテナー間でメッセージを確実にブロードキャストする責務を担います。もしクライアントの接続が一時的に切れ、その後再接続した場合でも、Redisを介したPub/Subシステムのおかげで中断していたメッセージストリームに再び参加できます。これにより、アプリケーション全体として非常に堅牢なリアルタイム通信が実現されています。
🎯 結論
「WebSocketが切れない」のはAction Cableのハートビート機能のおかげであり、「万が一切れても、自動で復帰し、メッセージを取りこぼさない」のはクライアントの再接続機能とRedisによるPub/Sub機構のおかげと理解するのが正確です。
6. 📊 ログで見るリアルタイム通信の裏側
実装の動作を理解するために、実際にメッセージを投稿した際のサーバーログを詳しく解析してみましょう。まず、クライアント側でどのような通信が行われているかを確認してから、サーバーログを見ていきます。
ブラウザーの開発者ツールで確認するWebSocket接続
ブラウザーの開発者ツール(F12)のネットワークタブを開くと、WebSocket接続の確立過程を確認できます。
重要なポイント:
-
/cable
への接続: Action CableのWebSocketエンドポイント -
101 Switching Protocols
: HTTPからWebSocketプロトコルへの切り替えが成功 - 接続の維持: 一度確立されると、接続が持続的に維持される
この101 Switching Protocols
レスポンスが、HTTPからWebSocketへのプロトコル切り替えが正常に完了したことを示しています。これにより、その後のリアルタイム通信が可能になります。
実際のサーバーログ(抜粋)
以下は「おはよう」というメッセージを投稿した際の実際のサーバーログです。
22:01:30 web.1 | Started POST "/graphql" for 192.168.65.1 at 2025-06-19 22:01:30 +0000
22:01:30 web.1 | Processing by GraphqlController#execute as */*
22:01:30 web.1 | Parameters: {"operationName" => "CreateMessage", "variables" => {"content" => "おはよう"}, "query" => "mutation CreateMessage($content: String!) {...}"}
22:01:30 web.1 | User Load (0.4ms) SELECT "users".* FROM "users" WHERE "users"."id" = 7 LIMIT 1
22:01:30 web.1 | TRANSACTION (0.0ms) BEGIN immediate TRANSACTION
22:01:30 web.1 | Message Create (3.1ms) INSERT INTO "messages" ("sender_name", "content", "created_at", "updated_at") VALUES ('aoshi', 'おはよう', '2025-06-19 22:01:30.168625', '2025-06-19 22:01:30.168625') RETURNING "id"
22:01:30 web.1 | TRANSACTION (0.3ms) COMMIT TRANSACTION
22:01:30 web.1 | [ActionCable] Broadcasting to graphql-event::messageAdded:: "{\"message\":{\"__gid__\":\"Z2lkOi8vZ3FsLWNoYXQvTWVzc2FnZS8zMDQ\"},\"__sym_keys__\":[\"message\"]}"
22:01:30 web.1 | Completed 200 OK in 63ms
22:01:30 web.1 | Message Load (0.1ms) SELECT "messages".* FROM "messages" ORDER BY "messages"."id" DESC LIMIT 1
22:01:30 web.1 | [ActionCable] Broadcasting to graphql-subscription:b2b4807b-4839-4173-87e3-a369458a6f8e: {result: {"data" => {"messageAdded" => {"message" => {"id" => "TWVzc2FnZS8zMDQ", "senderName" => "aoshi", "content" => "おはよう", "createdAt" => "2025-06-19T22:01:30Z"}}}}}
22:01:30 web.1 | GraphqlChannel transmitting {"result" => {"data" => {"messageAdded" => {...}}}} (via streamed from graphql-subscription:b2b4807b-4839-4173-87e3-a369458a6f8e)
ログの詳細解析
このログを段階的に分解して、リアルタイム通信の仕組みを理解しましょう。
🔸 ステップ1:HTTP経由でのMutation実行
Started POST "/graphql" for 192.168.65.1 at 2025-06-19 22:01:30 +0000
Processing by GraphqlController#execute as */*
Parameters: {"operationName" => "CreateMessage", "variables" => {"content" => "おはよう"}}
解説:クライアントからCreateMessage
mutationがHTTP POST経由で送信されました。Apollo Clientのsplit
機能により、mutationは自動的にHTTPリンク経由で処理されています。
🔸 ステップ2:データベースへの保存
User Load (0.4ms) SELECT "users".* FROM "users" WHERE "users"."id" = 7 LIMIT 1
TRANSACTION (0.0ms) BEGIN immediate TRANSACTION
Message Create (3.1ms) INSERT INTO "messages" ("sender_name", "content", ...) VALUES ('aoshi', 'おはよう', ...)
TRANSACTION (0.3ms) COMMIT TRANSACTION
解説:
- 現在のユーザー情報を取得(認証確認)
- トランザクション開始
- 新しいメッセージをデータベースに保存(ID: 304が割り当て)
- トランザクションコミット
🔸 ステップ3:Subscriptionイベントの発火
[ActionCable] Broadcasting to graphql-event::messageAdded:: "{\"message\":{\"__gid__\":\"Z2lkOi8vZ3FsLWNoYXQvTWVzc2FnZS8zMDQ\"}}"
解説:GqlChatSchema.subscriptions.trigger("messageAdded", {}, { message: })
が実行され、Action Cableを通じてイベントがブロードキャストされました。graphql-event::messageAdded::
は内部的なイベントストリーム名です。
🔸 ステップ4:Subscriptionの処理とデータ配信
Message Load (0.1ms) SELECT "messages".* FROM "messages" ORDER BY "messages"."id" DESC LIMIT 1
[ActionCable] Broadcasting to graphql-subscription:b2b4807b-4839-4173-87e3-a369458a6f8e: {result: {"data" => {"messageAdded" => {"message" => {"id" => "TWVzc2FnZS8zMDQ", "senderName" => "aoshi", "content" => "おはよう"}}}}}
解説:
-
Subscriptions::MessageAdded#update
メソッドが呼ばれ、最新のメッセージを取得 - 購読しているクライアント(
b2b4807b-4839-4173-87e3-a369458a6f8e
)に対してフォーマットされたGraphQLデータを配信
🔸 ステップ5:クライアントへの配信
GraphqlChannel transmitting {"result" => {"data" => {"messageAdded" => {...}}}} (via streamed from graphql-subscription:b2b4807b-4839-4173-87e3-a369458a6f8e)
解説:GraphqlChannel
を通じて、WebSocket接続しているクライアントに実際のデータが送信されました。
🔍 ログから読み取れる重要なポイント
1. 2つの独立した通信経路
-
Mutation:HTTP POST(
/graphql
)→ 同期処理 - Subscription:WebSocket(Action Cable)→ 非同期配信
2. Global IDによるデータ識別
"__gid__":"Z2lkOi8vZ3FsLWNoYXQvTWVzc2FnZS8zMDQ"
RailsのGlobal IDが使用されており、Z2lkOi8vZ3FsLWNoYXQvTWVzc2FnZS8zMDQ
はMessage/304
をBase64エンコードしたものです。
3. 複数クライアントへの同時配信
ログを見ると、複数のgraphql-subscription:xxxxxxxx
に対して同じデータが配信されており、複数のクライアントが同時にSubscriptionを購読していることが分かります。
4. 処理時間の短さ
全体の処理が63msで完了しており、リアルタイム通信として十分な性能を発揮しています。
🚀 処理フローの可視化
このログ解析により、GraphQL SubscriptionとAction Cableの連携が実際にどのように動作しているかが明確に理解できました。理論だけでなく、実際の動作を確認することで、実装の理解がより深まります。
7. 🎉 まとめと今後の展望
本記事では、Action CableとGraphQL Subscriptionを用いて、Rails + Reactアプリケーションにリアルタイムチャット機能を実装する方法を解説しました。
学んだことの振り返り
- WebSocketとGraphQL Subscriptionの連携:従来のHTTPベースのGraphQL通信に加えて、WebSocketを使ったリアルタイム通信を実現
-
Action CableとGraphQL-Rubyの統合:
ActionCableSubscriptions
により、RailsらしいコードでSubscriptionを実装 -
Apollo Clientでの柔軟な通信制御:
ApolloLink.split
により、操作の種類に応じて最適な通信プロトコルを選択 - 実運用を考慮した堅牢性:ハートビート機能、自動再接続、Redisによる分散対応などの重要性
今後の展望
この基盤をベースに、さらに高度な機能を追加できます:
- ユーザー認証と認可:特定のユーザーのみがアクセスできるプライベートチャンネル
- タイピング中インジケーター:「○○さんが入力中…」のようなリアルタイム表示
- メッセージの既読状態管理:既読/未読の管理とリアルタイム同期
- パフォーマンスチューニング:大量のユーザーが参加する際の最適化
- オフライン対応:一時的な接続断絶時のメッセージ保持と同期
この記事が、あなたのリアルタイムアプリケーション開発の一助となれば幸いです。私もGraphQL Subscriptionについてもっと理解を深めていこうと思っています💪この記事でツッコミどころがあれば教えていただけると幸いです🙇♂️
Discussion