GraphQLサブスクリプション:WebSocketとSSEトランスポートレイヤーの徹底解説
Daniel Hayes
Full-Stack Engineer · Leapcell

はじめに
今日のダイナミックなWeb環境において、リアルタイムデータ更新はもはや贅沢品ではなく、基本的な期待となっています。ライブチャットアプリケーションや共同編集ドキュメントから、金融ダッシュボードやIoTモニタリングまで、ユーザーは即時のフィードバックと最新の情報を求めています。GraphQLは、その強力なデータ取得機能により、このニーズにサブスクリプション機能を通じて応え、クライアントがサーバーからリアルタイムで更新を受信できるようにしています。しかし、エレガントなGraphQLサブスクリプションAPIの背後には、重要な決定が隠されています。これらのリアルタイムメッセージはどのように転送されるのでしょうか?これが、2つの主要なWebテクノロジー、WebSocketとServer-Sent Events (SSE)の間の基本的な戦いにつながります。それぞれのニュアンス、強み、弱みを理解することは、堅牢でスケーラブル、かつ効率的なリアルタイムGraphQLアプリケーションの構築を目指すバックエンド開発者にとって不可欠です。この記事では、これらのトランスポートレイヤー間の根本的な違いを掘り下げ、その原則、実装戦略、および実用的なアプリケーションシナリオを探り、情報に基づいた選択をガイドします。
コアコンセプト
この戦いに飛び込む前に、この議論に関わる主要なテクノロジーの明確な理解を確立しましょう。
GraphQLサブスクリプション
GraphQLサブスクリプションは、GraphQLの強力な機能であり、クライアントがサーバーからのイベントを購読できるようにします。クエリ(データを一度取得する)やミューテーション(データを変更する)とは異なり、サブスクリプションはクライアントとサーバー間の永続的な接続を維持します。サーバーで特定のイベントが発生すると、メッセージがリアルタイムですべての購読クライアントにプッシュされます。これは、GraphQLスキーマでSubscriptionタイプを定義し、クライアントが購読できるフィールドを公開することによって実現されます。
type Subscription { commentAdded(postId: ID!): Comment postLiked(postId: ID!): Post }
クライアントがcommentAddedを購読すると、指定されたpostIdに新しいコメントが追加されるたびに、サーバーは新しいCommentオブジェクトをプッシュします。
WebSockets
WebSocketは、単一のTCP接続を介した全二重(full-duplex)の永続的な通信チャネルを提供します。これは、WebSocket接続が確立されると、クライアントとサーバーの両方が独立して同時にメッセージを送受信できることを意味します。この双方向機能により、WebSocketは、インスタントメッセージング、オンラインゲーム、ライブコラボレーションツールなど、頻繁で低遅延の双方向通信を必要とするアプリケーションに最適です。
Server-Sent Events (SSE)
Server-Sent Events (SSE)は、単一のHTTP接続を介してサーバーからクライアントへワンウェイでイベントデータをプッシュするための標準です。WebSocketとは異なり、SSEは一方向です。データはサーバーからクライアントへのみ流れます。これにより、SSEは、クライアントがサーバーに頻繁にデータを送信する必要なく、主に更新を受信する必要があるシナリオに特に適しています。株価チャート、ニュースフィード、またはサーバーが情報の主要なソースであるリアルタイムダッシュボードを考えてみてください。SSEはHTTP上に構築されているため、ファイアウォールに優しく、サーバープッシュの実装が容易になるという利点もあります。
トランスポートレイヤーの戦い
それでは、GraphQLサブスクリプションのコンテキストでWebSocketとSSEを比較し、その原則、実装、およびアプリケーションシナリオを検証しましょう。
GraphQLサブスクリプションとのWebSocketの原則と実装
WebSocketは、その双方向性により、GraphQLサブスクリプションにとって非常に効率的で汎用性の高いトランスポートを提供します。
原則:
- 永続接続: 単一のTCP接続が確立され、維持されます。
- 全二重: クライアントとサーバーの両方が同時にメッセージを送受信できます。
- 低オーバーヘッド: ハンドシェイク完了後、データフレームはHTTPリクエストよりも小さくなります。
- プロトコル非依存: WebSocketは、あらゆる種類のデータ(テキスト、バイナリ)を転送できます。
実装:
WebSocket over GraphQLサブスクリプションの実装には、通常、専用のWebSocketサーバーまたはWebSocketアップグレードをサポートする互換性のあるHTTPサーバーが含まれます。graphql-wsやsubscriptions-transport-ws(ただし、後者はgraphql-wsの前に非推奨)のようなライブラリは、サーバーサイドでGraphQL over WebSocketプロトコルを処理するためによく使用されます。
Expressとwsを使用したgraphql-wsを使用したNode.jsの簡略化された例を見てみましょう。
// server.js import express from 'express'; import { createServer } from 'http'; import { WebSocketServer } from 'ws'; import { useServer } from 'graphql-ws/lib/use/ws'; import { execute, subscribe } from 'graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { PubSub } from 'graphql-subscriptions'; // シンプルなイベント公開用 const pubsub = new PubSub(); const COMMENTS_CHANNEL = 'COMMENTS_CHANNEL'; const typeDefs = ` type Comment { id: ID! content: String! } type Query { hello: String } type Mutation { addComment(content: String!): Comment } type Subscription { commentAdded: Comment } `; const resolvers = { Query: { hello: () => 'Hello GraphQL!', }, Mutation: { addComment: (parent, { content }) => { const newComment = { id: String(Date.now()), content }; pubsub.publish(COMMENTS_CHANNEL, { commentAdded: newComment }); return newComment; }, }, Subscription: { commentAdded: { subscribe: () => pubsub.asyncIterator(COMMENTS_CHANNEL), }, }, }; const schema = makeExecutableSchema({ typeDefs, resolvers }); const app = express(); const httpServer = createServer(app); // GraphQLサブスクリプション用のWebSocketサーバーを作成 const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql', // WebSocketエンドポイント }); useServer( { schema, execute, subscribe, onConnect: (ctx) => { console.log('Client connected for GraphQL Subscription'); // ここで認証または認可を実装できます }, onDisconnect: (ctx, code, reason) => { console.log('Client disconnected from GraphQL Subscription'); }, }, wsServer ); httpServer.listen(4000, () => { console.log('GraphQL server running on http://localhost:4000'); console.log('GraphQL Subscriptions available at ws://localhost:4000/graphql'); });
クライアント側では、subscriptions-transport-ws(またはgraphql-wsクライアント)のようなWebSocketクライアントライブラリが使用されます。
// client.js (簡略化されたクライアント設定例) import { createClient } from 'graphql-ws'; const client = createClient({ url: 'ws://localhost:4000/graphql', }); const COMMENT_SUBSCRIPTION = ` subscription OnCommentAdded { commentAdded { id content } } `; (async () => { const onNext = ({ data }) => { console.log('Received comment:', data.commentAdded); }; const onError = (error) => { console.error('Subscription error:', error); }; const unsubscribe = client.subscribe( { query: COMMENT_SUBSCRIPTION }, { next: onNext, error: onError, complete: () => console.log('Subscription complete') } ); // デモンストレーションのために、しばらくしてから、またはユーザーアクションで購読を解除する場合があります // setTimeout(() => { // unsubscribe(); // }, 10000); })();
WebSocketのアプリケーションシナリオ:
- リアルタイムチャットアプリケーション: メッセージの送受信には双方向通信が不可欠です。
- 共同編集エディタ: 複数のユーザーが同じドキュメントを更新するには、即時の双方向同期が必要です。
- オンラインゲーム: ゲーム状態の更新とプレイヤーアクションのための低遅延双方向通信。
- 金融取引プラットフォーム: 高頻度の更新とユーザー注文の発注。
Server-Sent Eventsの原則とGraphQLサブスクリプションでの実装
SSEは、サーバーからデータをプッシュするための、よりシンプルでHTTPベースのアプローチを提供します。
原則:
- 一方向: データはサーバーからクライアントへのみ流れます。
- HTTPベース: 接続に標準HTTPを使用するため、ファイアウォールに優しいです。
- 自動再接続: 接続が切断された場合、ブラウザは自動的に接続を再確立します。
- シンプルさ: サーバーからクライアントへの通信には、WebSocketよりもシンプルなAPIです。
実装:
GraphQLサブスクリプションにSSEを使用するには、通常、text/event-streamデータをストリーミングするHTTPエンドポイントを用意します。各イベントは`data: {json_payload}
`としてフォーマットされます。SSEによるサブスクリプションをサポートするGraphQLサーバーは、通常、特別なHTTP POSTリクエストをこのSSEストリームにマッピングします。
GraphQLサブスクリプションのためのSSEの概念的(および簡略化された)サーバーサイドの例を以下に示します。これは、GraphQLサブスクリプションのSSEサポートを提供するカスタム実装またはライブラリを想定しています。
// server-sse.js (GraphQLサブスクリプションのための概念的なSSE実装) import express from 'express'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { PubSub } from 'graphql-subscriptions'; import { execute, subscribe } from 'graphql'; const pubsub = new PubSub(); const COMMENTS_CHANNEL = 'COMMENTS_CHANNEL'; const typeDefs = ` type Comment { id: ID! content: String! } type Query { hello: String } type Mutation { addComment(content: String!): Comment } type Subscription { commentAdded: Comment } `; const resolvers = { Query: { hello: () => 'Hello GraphQL SSE!' }, Mutation: { addComment: (parent, { content }) => { const newComment = { id: String(Date.now()), content }; pubsub.publish(COMMENTS_CHANNEL, { commentAdded: newComment }); return newComment; }, }, Subscription: { commentAdded: { subscribe: () => pubsub.asyncIterator(COMMENTS_CHANNEL), }, }, }; const schema = makeExecutableSchema({ typeDefs, resolvers }); const app = express(); app.use(express.json()); // ミューテーションエンドポイント (通常のHTTP POST) app.post('/graphql', async (req, res) => { const { query, variables } = req.body; const result = await execute({ schema, document: query, variableValues: variables }); res.json(result); }); // サブスクリプション用のSSEエンドポイント app.post('/graphql-sse', async (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); // クライアントにヘッダーをフラッシュ const { query, variables, operationName } = req.body; try { const subscriber = await subscribe({ schema, document: query, variableValues: variables, operationName, contextValue: {}, // 必要に応じてコンテキストを追加 }); if (subscriber.errors) { res.write(`event: error\ndata: ${JSON.stringify(subscriber.errors)}\n\n`); res.end(); return; } // 非同期イテレータを購読 const iterator = subscriber[Symbol.asyncIterator](); const sendEvent = async () => { try { const { value, done } = await iterator.next(); if (done) { res.write('event: complete\ndata: Subscription completed\n\n'); res.end(); return; } res.write(`event: message\ndata: ${JSON.stringify(value)}\n\n`); // 次のイベント送信をスケジュール process.nextTick(sendEvent); // より制御されたループ/エミッターを使用することも可能 } catch (error) { res.write(`event: error\ndata: ${JSON.stringify({ message: error.message })}\n\n`); res.end(); } }; sendEvent(); // イベント送信を開始 req.on('close', () => { // クライアントが切断されたときにサブスクリプションをクリーンアップ if (subscriber.return) { subscriber.return(); // 非同期イテレータを終了 } console.log('Client disconnected from SSE subscription'); }); } catch (error) { res.write(`event: error\ndata: ${JSON.stringify({ message: error.message })}\n\n`); res.end(); } }); app.listen(4001, () => { console.log('GraphQL server with SSE running on http://localhost:4001 and sse at http://localhost:4001/graphql-sse'); });
クライアント側では、ブラウザのネイティブEventSource APIが使用されます。ただし、GraphQL over SSEと連携する場合は、fetch APIを使用してPOSTリクエストでサブスクリプションを開始し、レスポンスストリームを処理する必要があります。
// client-sse.js const COMMENT_SUBSCRIPTION_QUERY = ` subscription OnCommentAdded { commentAdded { id content } } `; // SSEストリームを開始するためにPOSTリクエストをシミュレート async function subscribeWithSSE() { const response = await fetch('http://localhost:4001/graphql-sse', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' // クライアントがSSEを期待していることを示す }, body: JSON.stringify({ query: COMMENT_SUBSCRIPTION_QUERY }) }); if (!response.ok) { console.error('Failed to initiate SSE subscription:', response.statusText); return; } // イベントストリームを処理するためにReadableStreamReaderを使用 const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) { console.log('SSE stream finished.'); break; } buffer += decoder.decode(value, { stream: true }); // バッファから完全なイベントを処理 let eventBoundary; while ((eventBoundary = buffer.indexOf(' ')) !== -1) { const eventData = buffer.substring(0, eventBoundary).trim(); buffer = buffer.substring(eventBoundary + 2); // バッファをイベントの後に移動 if (eventData.startsWith('event: message')) { const dataPayload = eventData.substring('event: message data: '.length); try { const parsedData = JSON.parse(dataPayload); console.log('Received SSE comment:', parsedData.data.commentAdded); } catch (e) { console.error('Failed to parse SSE data:', e, dataPayload); } } else if (eventData.startsWith('event: error')) { console.error('SSE Error:', eventData); } else if (eventData.startsWith('event: complete')) { console.log('SSE Subscription complete notification.'); reader.cancel(); // 読み取りを停止 break; } } } } subscribeWithSSE();
注意: EventSource APIはクライアントサイドの消費を簡素化しますが、GraphQL over SSEのサーバーサイドでのサブスクリプション開始プロトコルは、通常、サブスクリプションクエリを指定する初期HTTP POSTリクエストを含み、その後サーバーはtext/event-streamレスポンスをストリーミングします。@graphql-yoga/graphql-sseライブラリなどは、より堅牢なサーバーサイド実装を提供できます。
SSEのアプリケーションシナリオ:
- リアルタイムダッシュボード: データが主にサーバーからクライアントへ流れるメトリクス、分析、株価の表示。
- ニュースフィード: 新しい記事や更新をユーザーにプッシュする。
- ライブスポーツスコア: スコアとゲームイベントをリアルタイムで更新する。
- 通知システム: ユーザーにプッシュ通知を送信する。
結論
GraphQLサブスクリプションのトランスポートレイヤーとしてWebSocketとSSEのどちらを選択するかは、結局のところ、アプリケーションの特定の要件にかかっています。WebSocketは優れた双方向通信を提供し、低遅延の双方向データ交換を必要とするチャットや共同編集などのインタラクティブなシナリオに最適です。逆に、SSEは、クライアントが主に更新を消費するリアルタイムダッシュボード、ニュースフィード、通知システムに最適な、よりシンプルでHTTPフレンドリーな一方向サーバーからクライアントへのデータプッシュソリューションを提供します。リアルタイムデータフローの性質を慎重に評価することで、GraphQLサブスクリプションのパフォーマンス、複雑さ、およびリソース使用率を最適化するトランスポートレイヤーを選択できます。
本質的に、WebSocketはインタラクティブなリアルタイムエクスペリエンスを可能にし、SSEは効率的なパッシブデータ消費に優れています。