😽

🚀 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つの技術がどのように連携するかを図で確認しましょう:

処理の流れ:

  1. Apollo Client(フロントエンド)が ActionCableLink を使ってAction Cable(サーバー)にWebSocket接続を要求
  2. Action CableはGraphQL Subscriptionのリクエストを受け取り、graphql-ruby gemのActionCableSubscriptions機能がこれを解釈
  3. Mutationによってデータが更新されると、triggerが発火し、Action Cableを通じて購読しているすべてのクライアントにリアルタイムでデータがブロードキャスト
  4. GraphQLの型安全性を保ちつつ、リアルタイムな通信が実現される

2. 🏗️ アーキテクチャの全体像

実装に入る前に、クライアントとサーバーがどのように連携してリアルタイム通信を実現するのか、全体の流れを把握しましょう。

登場人物の紹介

  • クライアント(Apollo Client, ActionCableLink)
  • サーバー(Rails, Action Cable, GraphqlChannel)
  • Redis(Pub/Subサーバー)

リアルタイム通信の処理フロー

  1. クライアントがWebSocket接続を確立し、Subscriptionを購読する
  2. Action CableがRedisのPub/Subを購読する
  3. 別のクライアントがMutation(メッセージ投稿)を実行する
  4. サーバーがイベントをトリガーし、RedisにメッセージをPublishする
  5. Action CableがRedisからメッセージを受け取り、購読しているすべてのクライアントにデータをブロードキャストする
  6. クライアントがデータを受信し、UIを更新する

3. 🔧 バックエンド実装(Ruby on Rails)

それでは、サーバーサイドから実装していきましょう。

3.1. 準備:Gemとルーティング

Gemfileに必要なgemを追加します。関連部分のみ抜粋します。

Gemfile
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のマウントポイントを追加します。

config/routes.rb
Rails.application.routes.draw do
  # ...
  post "/graphql", to: "graphql#execute"
  # ...
  mount ActionCable.server => "/cable"  # WebSocket接続用エンドポイント
end

config/cable.ymlでRedisアダプターを設定します。

config/cable.yml
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スキーマの全体像は以下の通りです。QueryMutation、そして今回の主役であるSubscriptionが定義されています。

schema.graphql
"""
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のエントリーポイントを定義します。fieldsubscription:オプションに、後述するSubscriptionクラスを指定します。

app/graphql/types/subscription_type.rb
module Types
  class SubscriptionType < Types::BaseObject
    field :message_added, subscription: Subscriptions::MessageAdded, broadcastable: true
  end
end

app/graphql/subscriptions/message_added.rbmessageAddedイベントの具体的な処理を実装します。

🔍 重要なポイント:updateメソッドの役割

graphql-rubyActionCableSubscriptionsは、クライアントが購読を開始した直後に、このupdateメソッドを一度呼び出します。このとき、スキーマでmessageフィールドがnull: falseMessage!)と定義されているため、nilではない有効なMessageオブジェクトを返さないとエラーになります。

ここでは、Message.lastまたはダミーのMessageオブジェクトを返すことで、この制約を満たし、Subscriptionのセットアップを正常に完了させています。

app/graphql/subscriptions/message_added.rb
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.rbuse GraphQL::Subscriptions::ActionCableSubscriptionsを追加して、graphql-rubyにAction Cableを使うことを伝えます。これにより、Subscriptionのトランスポート層としてAction Cableが利用されるようになります。

app/graphql/gql_chat_schema.rb
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接続を処理するチャネルを作成します。

🔍 重要なポイント:contextchannel: selfを渡す理由

contextchannel: selfを渡すことが重要です。これにより、ActionCableSubscriptionsが内部で現在のクライアント接続(チャネル)を認識し、その接続に対して正しくストリームをセットアップしたり、データを配信したりできるようになります。

app/channels/graphql_channel.rb
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)と一致するデータを渡します
app/graphql/mutations/create_message.rb
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を連携させるための便利なライブラリです。関連する部分のみ抜粋します。

package.json
{
  "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を返した場合(操作がquerymutationの場合):第三引数のhttpLink(HTTP)が使われます

これにより、各操作に最適な通信プロトコルを効率的に利用できます。

app/javascript/client.js
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を使ってキャッシュの先頭に新しいメッセージを追加しています。

この手動でのキャッシュ更新により、他のユーザーが投稿したメッセージが自分の画面にもリアルタイムで表示されるようになります。

app/javascript/app.jsx
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がその信頼性を高めています。

  1. Action Cableのハートビート機能(サーバーサイド)

    Railsに標準で組み込まれているAction Cableは、接続がアクティブであることを確認するための「ハートビート」機能を備えています。サーバーは定期的(デフォルト3秒間隔)にクライアントにpingメッセージを送信し、クライアントからのpong応答を監視します。この定期的な通信により、経路上の中間サーバーは接続がアクティブであると判断し、タイムアウトで切断するのを防ぎます。

  2. 自動再接続機能(クライアントサイド)

    フロントエンドで利用している@rails/actioncableライブラリは、接続が予期せず切断された場合に、自動的にサーバーへの再接続を試みる機能を内蔵しています。これにより、一時的なネットワークの問題などで接続が途切れても、ユーザーが意識することなく接続が回復します。

  3. 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接続の確立過程を確認できます。

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

解説

  1. 現在のユーザー情報を取得(認証確認)
  2. トランザクション開始
  3. 新しいメッセージをデータベースに保存(ID: 304が割り当て)
  4. トランザクションコミット

🔸 ステップ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" => "おはよう"}}}}}

解説

  1. Subscriptions::MessageAdded#updateメソッドが呼ばれ、最新のメッセージを取得
  2. 購読しているクライアント(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が使用されており、Z2lkOi8vZ3FsLWNoYXQvTWVzc2FnZS8zMDQMessage/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についてもっと理解を深めていこうと思っています💪この記事でツッコミどころがあれば教えていただけると幸いです🙇‍♂️

8. 📚 参考資料

合同会社春秋テックブログ

Discussion