GraphQL応用
GraphQLの応用トピック。Subscription(リアルタイム通信)、DataLoader(N+1問題の解決)、キャッシュ戦略、エラーハンドリング、セキュリティ、Federation、パフォーマンスチューニングまで、プロダクション運用に必要な知識を網羅的に習得する。
187 分で読めます93,061 文字
GraphQL応用
GraphQLの応用トピック。Subscription(リアルタイム通信)、DataLoader(N+1問題の解決)、キャッシュ戦略、エラーハンドリング、セキュリティ、Federation、パフォーマンスチューニングまで、プロダクション運用に必要な知識を網羅的に習得する。
この章で学ぶこと
- Subscriptionによるリアルタイム通信を理解する
- DataLoaderでN+1問題を解決する方法を把握する
- GraphQLのキャッシュ戦略を複数レイヤーで学ぶ
- エラーハンドリングの設計パターンを習得する
- GraphQL特有のセキュリティ対策を実装できる
- スキーマ設計の高度なパターンを使いこなす
- Apollo Federationによるマイクロサービス統合を理解する
- パフォーマンスの計測と最適化手法を把握する
- テスト戦略とモニタリングを実践できる
前提知識
- GraphQLの基礎(Query, Mutation, Schema) → 参照: GraphQL基礎
- REST APIの設計原則 → 参照: REST Best Practices
- TypeScriptの型システム → 参照: TypeScript Complete Guide
1. Subscription(リアルタイム通信)
1.1 Subscriptionの基本概念
GraphQL Subscriptionは、サーバーからクライアントへのリアルタイムデータ配信を実現する仕組みである。RESTでのポーリングやWebSocketの直接利用と比較して、型安全なリアルタイム通信を提供する。
Subscriptionの動作フロー:
Client Server
| |
|-- subscription req ---->| ① WebSocket接続確立
| |
| | ② サーバー側でイベント発生
|<-- data push -----------| ③ データをクライアントへ配信
| |
| | ④ 再度イベント発生
|<-- data push -----------| ⑤ 再度データ配信
| |
|-- unsubscribe --------->| ⑥ 購読解除
| |
vs ポーリング:
Client → Server: GET /api/messages?since=xxx (毎秒)
→ 無駄なリクエストが大量に発生
→ リアルタイム性が低い(ポーリング間隔に依存)
vs WebSocket直接利用:
→ 型安全性がない
→ メッセージフォーマットの統一が困難
→ GraphQLのSubscriptionは型付きリアルタイム通信を提供
1.2 スキーマ定義
# Subscription スキーマ定義
type Subscription {
# 新しいメッセージの購読
messageAdded(channelId: ID!): Message!
# 注文ステータスの変更
orderStatusChanged(orderId: ID!): Order!
# ユーザーのオンライン状態
userPresenceChanged: UserPresence!
# 通知のリアルタイム配信
notificationReceived(userId: ID!): Notification!
# ダッシュボードメトリクスの更新
metricsUpdated(dashboardId: ID!): DashboardMetrics!
# タイピングインジケーター
userTyping(channelId: ID!): TypingIndicator!
}
type Message {
id: ID!
content: String!
author: User!
channel: Channel!
attachments: [Attachment!]!
reactions: [Reaction!]!
createdAt: DateTime!
editedAt: DateTime
}
type UserPresence {
user: User!
isOnline: Boolean!
lastSeen: DateTime!
status: PresenceStatus!
}
enum PresenceStatus {
ONLINE
AWAY
DO_NOT_DISTURB
OFFLINE
}
type Notification {
id: ID!
type: NotificationType!
title: String!
body: String!
actionUrl: String
isRead: Boolean!
createdAt: DateTime!
}
enum NotificationType {
MESSAGE
MENTION
ORDER_UPDATE
SYSTEM
PROMOTION
}
type TypingIndicator {
user: User!
channelId: ID!
isTyping: Boolean!
}
type DashboardMetrics {
activeUsers: Int!
requestsPerSecond: Float!
errorRate: Float!
averageResponseTime: Float!
timestamp: DateTime!
}1.3 サーバー側実装(Apollo Server + WebSocket)
// サーバー側(Apollo Server + WebSocket)
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { PubSub, withFilter } from 'graphql-subscriptions';
import express from 'express';
const pubsub = new PubSub();
// イベント名の定数定義
const EVENTS = {
MESSAGE_ADDED: 'MESSAGE_ADDED',
ORDER_STATUS_CHANGED: 'ORDER_STATUS_CHANGED',
USER_PRESENCE_CHANGED: 'USER_PRESENCE_CHANGED',
NOTIFICATION_RECEIVED: 'NOTIFICATION_RECEIVED',
METRICS_UPDATED: 'METRICS_UPDATED',
USER_TYPING: 'USER_TYPING',
};
const resolvers = {
Subscription: {
// withFilterでチャンネルIDによるフィルタリング
messageAdded: {
subscribe: withFilter(
() => pubsub.asyncIterator(EVENTS.MESSAGE_ADDED),
(payload, variables) => {
// 指定されたチャンネルのメッセージのみ配信
return payload.messageAdded.channel.id === variables.channelId;
}
),
},
orderStatusChanged: {
subscribe: withFilter(
() => pubsub.asyncIterator(EVENTS.ORDER_STATUS_CHANGED),
(payload, variables) => {
return payload.orderStatusChanged.id === variables.orderId;
}
),
},
userPresenceChanged: {
subscribe: () => pubsub.asyncIterator(EVENTS.USER_PRESENCE_CHANGED),
},
notificationReceived: {
subscribe: withFilter(
() => pubsub.asyncIterator(EVENTS.NOTIFICATION_RECEIVED),
(payload, variables, context) => {
// 認証済みユーザー自身の通知のみ配信
return payload.notificationReceived.userId === variables.userId
&& context.user.id === variables.userId;
}
),
},
userTyping: {
subscribe: withFilter(
() => pubsub.asyncIterator(EVENTS.USER_TYPING),
(payload, variables) => {
return payload.userTyping.channelId === variables.channelId;
}
),
},
metricsUpdated: {
subscribe: withFilter(
() => pubsub.asyncIterator(EVENTS.METRICS_UPDATED),
(payload, variables, context) => {
// 管理者のみメトリクスを購読可能
if (!context.user?.roles?.includes('ADMIN')) {
throw new Error('Not authorized to subscribe to metrics');
}
return payload.metricsUpdated.dashboardId === variables.dashboardId;
}
),
},
},
Mutation: {
sendMessage: async (_, { input }, context) => {
// 認証チェック
if (!context.user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
const message = await context.dataSources.messageAPI.create({
...input,
authorId: context.user.id,
});
// Subscriptionに通知
pubsub.publish(EVENTS.MESSAGE_ADDED, {
messageAdded: message,
});
return { message, errors: [] };
},
updateOrderStatus: async (_, { orderId, status }, context) => {
const order = await context.dataSources.orderAPI.updateStatus(
orderId,
status
);
// 注文ステータス変更を通知
pubsub.publish(EVENTS.ORDER_STATUS_CHANGED, {
orderStatusChanged: order,
});
// 顧客への通知も同時に発行
pubsub.publish(EVENTS.NOTIFICATION_RECEIVED, {
notificationReceived: {
userId: order.customerId,
type: 'ORDER_UPDATE',
title: '注文ステータスが更新されました',
body: `注文 #${orderId} のステータスが「${status}」に変更されました`,
createdAt: new Date().toISOString(),
},
});
return order;
},
setTypingStatus: async (_, { channelId, isTyping }, context) => {
pubsub.publish(EVENTS.USER_TYPING, {
userTyping: {
user: context.user,
channelId,
isTyping,
},
});
return true;
},
},
};
// Express + Apollo Server + WebSocket のセットアップ
const app = express();
const httpServer = createServer(app);
const schema = makeExecutableSchema({ typeDefs, resolvers });
// WebSocketサーバーのセットアップ
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
const serverCleanup = useServer(
{
schema,
context: async (ctx, msg, args) => {
// WebSocket接続時の認証
const token = ctx.connectionParams?.authorization;
if (!token) {
throw new Error('Missing authentication token');
}
const user = await authenticateUser(token);
if (!user) {
throw new Error('Invalid authentication token');
}
return { user };
},
onConnect: async (ctx) => {
console.log('Client connected:', ctx.connectionParams);
// 接続時のバリデーション
const token = ctx.connectionParams?.authorization;
if (!token) {
return false; // 接続を拒否
}
return true;
},
onDisconnect: async (ctx, code, reason) => {
console.log('Client disconnected:', code, reason);
// ユーザーのオフライン状態を通知
if (ctx.extra?.user) {
pubsub.publish(EVENTS.USER_PRESENCE_CHANGED, {
userPresenceChanged: {
user: ctx.extra.user,
isOnline: false,
lastSeen: new Date().toISOString(),
status: 'OFFLINE',
},
});
}
},
onSubscribe: (ctx, msg) => {
console.log('Subscription started:', msg.payload.query);
},
onNext: (ctx, msg, args, result) => {
// 各メッセージ送信時のフック
console.log('Sending subscription data');
},
onError: (ctx, msg, errors) => {
console.error('Subscription error:', errors);
},
onComplete: (ctx, msg) => {
console.log('Subscription completed');
},
},
wsServer
);
// Apollo Server のセットアップ
const server = new ApolloServer({
schema,
plugins: [
// HTTPサーバーの適切なシャットダウン
ApolloServerPluginDrainHttpServer({ httpServer }),
// WebSocketサーバーの適切なシャットダウン
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
},
};
},
},
],
});
await server.start();
app.use(
'/graphql',
express.json(),
expressMiddleware(server, {
context: async ({ req }) => ({
user: await authenticateUser(req.headers.authorization),
dataSources: createDataSources(),
}),
})
);
httpServer.listen(4000, () => {
console.log('Server running on http://localhost:4000/graphql');
console.log('WebSocket running on ws://localhost:4000/graphql');
});1.4 スケーラブルなPubSub実装(Redis)
// プロダクション環境ではインメモリPubSubの代わりにRedis PubSubを使用
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
// Redis接続の設定
const redisOptions = {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD,
retryStrategy: (times) => {
return Math.min(times * 50, 2000);
},
maxRetriesPerRequest: 3,
};
// PubSub用に別々のRedis接続を作成(推奨)
const pubsub = new RedisPubSub({
publisher: new Redis(redisOptions),
subscriber: new Redis(redisOptions),
// メッセージのシリアライゼーション
serializer: (data) => JSON.stringify(data),
deserializer: (message) => JSON.parse(message),
// 接続エラーハンドリング
connectionListener: (err) => {
if (err) {
console.error('Redis connection error:', err);
}
},
});
// 複数サーバーインスタンスでの利用
// Server A で publish → Redis → Server B の subscriber に配信
// → 水平スケーリングが可能
// Kafka を使ったPubSub(大規模システム向け)
import { KafkaPubSub } from 'graphql-kafka-subscriptions';
const kafkaPubSub = await KafkaPubSub.create({
topic: 'graphql-subscriptions',
host: process.env.KAFKA_HOST || 'localhost',
port: process.env.KAFKA_PORT || '9092',
groupIdPrefix: 'graphql-server',
globalConfig: {
'client.id': 'graphql-subscriptions-client',
},
});1.5 クライアント側実装(React + Apollo Client)
// Apollo Client のWebSocket設定
import { ApolloClient, InMemoryCache, split, HttpLink } from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { getMainDefinition } from '@apollo/client/utilities';
// HTTP リンク(Query, Mutation用)
const httpLink = new HttpLink({
uri: 'https://api.example.com/graphql',
headers: {
authorization: `Bearer ${getToken()}`,
},
});
// WebSocket リンク(Subscription用)
const wsLink = new GraphQLWsLink(
createClient({
url: 'wss://api.example.com/graphql',
connectionParams: () => ({
authorization: `Bearer ${getToken()}`,
}),
// 再接続設定
retryAttempts: 5,
shouldRetry: () => true,
retryWait: async (retryCount) => {
// 指数バックオフ
const delay = Math.min(1000 * Math.pow(2, retryCount), 30000);
await new Promise((resolve) => setTimeout(resolve, delay));
},
on: {
connected: () => console.log('WebSocket connected'),
closed: (event) => console.log('WebSocket closed:', event),
error: (error) => console.error('WebSocket error:', error),
},
// KeepAlive設定
keepAlive: 10000, // 10秒ごとにping
})
);
// 操作タイプに応じてリンクを振り分け
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink, // Subscription → WebSocket
httpLink // Query, Mutation → HTTP
);
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
});// React コンポーネントでの利用
import { useSubscription, useQuery, gql } from '@apollo/client';
import { useCallback, useEffect, useState } from 'react';
const MESSAGE_SUBSCRIPTION = gql`
subscription OnMessageAdded($channelId: ID!) {
messageAdded(channelId: $channelId) {
id
content
author {
id
name
avatar
}
createdAt
}
}
`;
const GET_MESSAGES = gql`
query GetMessages($channelId: ID!, $first: Int!, $after: String) {
messages(channelId: $channelId, first: $first, after: $after) {
edges {
node {
id
content
author {
id
name
avatar
}
createdAt
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
function ChatMessages({ channelId }) {
// 既存メッセージの取得
const { data, loading, fetchMore, subscribeToMore } = useQuery(
GET_MESSAGES,
{
variables: { channelId, first: 50 },
}
);
// subscribeToMore で既存クエリに新しいメッセージを追加
useEffect(() => {
const unsubscribe = subscribeToMore({
document: MESSAGE_SUBSCRIPTION,
variables: { channelId },
updateQuery: (prev, { subscriptionData }) => {
if (!subscriptionData.data) return prev;
const newMessage = subscriptionData.data.messageAdded;
// 重複チェック
const exists = prev.messages.edges.some(
(edge) => edge.node.id === newMessage.id
);
if (exists) return prev;
return {
...prev,
messages: {
...prev.messages,
edges: [
{
__typename: 'MessageEdge',
node: newMessage,
cursor: newMessage.id,
},
...prev.messages.edges,
],
},
};
},
});
return () => unsubscribe();
}, [channelId, subscribeToMore]);
if (loading) return <div>Loading...</div>;
return (
<div className="chat-messages">
{data?.messages.edges.map(({ node: message }) => (
<div key={message.id} className="message">
<img src={message.author.avatar} alt={message.author.name} />
<div>
<strong>{message.author.name}</strong>
<p>{message.content}</p>
<small>{new Date(message.createdAt).toLocaleString()}</small>
</div>
</div>
))}
</div>
);
}
// タイピングインジケーターの実装
const TYPING_SUBSCRIPTION = gql`
subscription OnUserTyping($channelId: ID!) {
userTyping(channelId: $channelId) {
user {
id
name
}
isTyping
}
}
`;
function TypingIndicator({ channelId }) {
const [typingUsers, setTypingUsers] = useState(new Map());
const { data } = useSubscription(TYPING_SUBSCRIPTION, {
variables: { channelId },
onData: ({ data: { data } }) => {
if (!data) return;
const { user, isTyping } = data.userTyping;
setTypingUsers((prev) => {
const next = new Map(prev);
if (isTyping) {
next.set(user.id, { name: user.name, timestamp: Date.now() });
} else {
next.delete(user.id);
}
return next;
});
},
});
// 5秒後にタイピング状態を自動クリア
useEffect(() => {
const interval = setInterval(() => {
setTypingUsers((prev) => {
const now = Date.now();
const next = new Map();
prev.forEach((value, key) => {
if (now - value.timestamp < 5000) {
next.set(key, value);
}
});
return next;
});
}, 1000);
return () => clearInterval(interval);
}, []);
if (typingUsers.size === 0) return null;
const names = Array.from(typingUsers.values()).map((u) => u.name);
return (
<div className="typing-indicator">
{names.length === 1
? `${names[0]} is typing...`
: names.length === 2
? `${names[0]} and ${names[1]} are typing...`
: `${names.length} people are typing...`}
</div>
);
}2. N+1問題とDataLoader
2.1 N+1問題の詳細
N+1問題:
query {
users(first: 10) { <- 1回のクエリ(usersテーブル)
edges {
node {
name
orders { <- 10回のクエリ(ordersテーブル x ユーザー数)
total
}
}
}
}
}
実行されるSQL:
SELECT * FROM users LIMIT 10; -- 1回
SELECT * FROM orders WHERE user_id = 1; -- +1回
SELECT * FROM orders WHERE user_id = 2; -- +1回
SELECT * FROM orders WHERE user_id = 3; -- +1回
... -- = N+1回
-> 10ユーザー = 11クエリ(1 + 10)
-> 100ユーザー = 101クエリ
-> ネストが深いと指数的に増加:
query {
users(first: 10) { -- 1
orders(first: 5) { -- 10
items(first: 10) { -- 50
product { -- 500
reviews(first: 5) { -- 500
author { name } -- 2500
}
}
}
}
}
}
-> 合計: 3,061 クエリ!
2.2 DataLoaderによる解決
// DataLoader による解決
import DataLoader from 'dataloader';
// === バッチ関数の基本パターン ===
// パターン1: 1対1(ユーザーIDからユーザーを取得)
const userLoader = new DataLoader(async (userIds) => {
console.log(`Batch loading users: [${userIds.join(', ')}]`);
// 1回のクエリで全ユーザーを取得
const users = await db.query(
'SELECT * FROM users WHERE id = ANY($1)',
[userIds]
);
// 入力IDの順序を保持してマッピング
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id) || null);
});
// パターン2: 1対多(ユーザーIDから注文一覧を取得)
const ordersByUserLoader = new DataLoader(async (userIds) => {
console.log(`Batch loading orders for users: [${userIds.join(', ')}]`);
const orders = await db.query(
'SELECT * FROM orders WHERE user_id = ANY($1) ORDER BY created_at DESC',
[userIds]
);
// userIdでグループ化して返す
const orderMap = new Map();
orders.forEach(order => {
if (!orderMap.has(order.userId)) {
orderMap.set(order.userId, []);
}
orderMap.get(order.userId).push(order);
});
return userIds.map(id => orderMap.get(id) || []);
});
// パターン3: 条件付きローダー(ステータスでフィルタ)
const activeOrdersByUserLoader = new DataLoader(async (keys) => {
// keysは { userId, status } のオブジェクト配列
const userIds = [...new Set(keys.map(k => k.userId))];
const statuses = [...new Set(keys.map(k => k.status))];
const orders = await db.query(
'SELECT * FROM orders WHERE user_id = ANY($1) AND status = ANY($2)',
[userIds, statuses]
);
return keys.map(key =>
orders.filter(o => o.userId === key.userId && o.status === key.status)
);
}, {
// カスタムキャッシュキー(オブジェクトをキーにする場合に必要)
cacheKeyFn: (key) => `${key.userId}:${key.status}`,
});
// リゾルバーでDataLoaderを使用
const resolvers = {
User: {
orders: (user, _, context) => context.loaders.ordersByUserLoader.load(user.id),
activeOrders: (user, _, context) =>
context.loaders.activeOrdersByUserLoader.load({
userId: user.id,
status: 'ACTIVE',
}),
// 集計もDataLoaderで効率化
orderCount: async (user, _, context) => {
const orders = await context.loaders.ordersByUserLoader.load(user.id);
return orders.length;
},
},
Order: {
customer: (order, _, context) => context.loaders.userLoader.load(order.userId),
items: (order, _, context) => context.loaders.orderItemsLoader.load(order.id),
},
OrderItem: {
product: (item, _, context) => context.loaders.productLoader.load(item.productId),
},
};
// 実行されるSQL(DataLoader使用後):
// SELECT * FROM users LIMIT 10; -- 1回
// SELECT * FROM orders WHERE user_id = ANY([1,2,...10]); -- 1回
// SELECT * FROM products WHERE id = ANY([...]); -- 1回
// -> 合計3クエリ(N+1が解消)2.3 DataLoaderのコンテキスト管理
// DataLoaderのコンテキスト設定
// 重要: DataLoaderはリクエストごとに新しいインスタンスを作る
// → キャッシュの不整合を防ぐため
function createLoaders(db) {
return {
// ユーザーローダー
userLoader: new DataLoader(async (ids) => {
const users = await db.query(
'SELECT * FROM users WHERE id = ANY($1)', [ids]
);
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id) || null);
}, {
// オプション設定
batch: true, // バッチ処理を有効化(デフォルト: true)
maxBatchSize: 100, // 1バッチの最大サイズ
cache: true, // キャッシュを有効化(デフォルト: true)
batchScheduleFn: (callback) => setTimeout(callback, 10),
// 10ms待ってからバッチ実行(より多くのリクエストをまとめる)
}),
// 注文ローダー
ordersByUserLoader: new DataLoader(async (userIds) => {
const orders = await db.query(
'SELECT * FROM orders WHERE user_id = ANY($1) ORDER BY created_at DESC',
[userIds]
);
const map = new Map();
orders.forEach(o => {
if (!map.has(o.userId)) map.set(o.userId, []);
map.get(o.userId).push(o);
});
return userIds.map(id => map.get(id) || []);
}),
// 商品ローダー
productLoader: new DataLoader(async (ids) => {
const products = await db.query(
'SELECT * FROM products WHERE id = ANY($1)', [ids]
);
const map = new Map(products.map(p => [p.id, p]));
return ids.map(id => map.get(id) || null);
}),
// 注文アイテムローダー
orderItemsLoader: new DataLoader(async (orderIds) => {
const items = await db.query(
`SELECT oi.*, p.name as product_name, p.price
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.order_id = ANY($1)`,
[orderIds]
);
const map = new Map();
items.forEach(item => {
if (!map.has(item.orderId)) map.set(item.orderId, []);
map.get(item.orderId).push(item);
});
return orderIds.map(id => map.get(id) || []);
}),
// カテゴリ別商品ローダー
productsByCategoryLoader: new DataLoader(async (categoryIds) => {
const products = await db.query(
'SELECT * FROM products WHERE category_id = ANY($1) ORDER BY name',
[categoryIds]
);
const map = new Map();
products.forEach(p => {
if (!map.has(p.categoryId)) map.set(p.categoryId, []);
map.get(p.categoryId).push(p);
});
return categoryIds.map(id => map.get(id) || []);
}),
};
}
// Apollo Server のコンテキスト
const server = new ApolloServer({
typeDefs,
resolvers,
});
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => ({
user: await authenticateUser(req),
loaders: createLoaders(db), // リクエストごとに新規作成
dataSources: createDataSources(),
}),
});2.4 DataLoaderのプライミングとキャッシュ制御
// DataLoaderの高度な利用パターン
// 1. プライミング(事前にキャッシュをセット)
const resolvers = {
Mutation: {
createUser: async (_, { input }, { loaders }) => {
const user = await db.query(
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
[input.name, input.email]
);
// 作成したユーザーをDataLoaderのキャッシュに事前登録
// → 後続のリゾルバーでDBクエリを回避
loaders.userLoader.prime(user.id, user);
return { user, errors: [] };
},
},
};
// 2. キャッシュのクリア
async function updateUser(id, input, loaders) {
const user = await db.query(
'UPDATE users SET name = $2 WHERE id = $1 RETURNING *',
[id, input.name]
);
// 古いキャッシュをクリアして新しいデータをセット
loaders.userLoader.clear(id);
loaders.userLoader.prime(id, user);
return user;
}
// 3. 全キャッシュのクリア
function clearAllLoaderCaches(loaders) {
Object.values(loaders).forEach(loader => {
if (loader instanceof DataLoader) {
loader.clearAll();
}
});
}
// 4. エラーハンドリング付きバッチ関数
const robustUserLoader = new DataLoader(async (ids) => {
try {
const users = await db.query(
'SELECT * FROM users WHERE id = ANY($1)', [ids]
);
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => {
const user = userMap.get(id);
if (!user) {
// 個別のエラーを返す(バッチ全体を失敗させない)
return new Error(`User not found: ${id}`);
}
return user;
});
} catch (error) {
// DBエラーの場合は全IDに対してエラーを返す
return ids.map(() => new Error(`Database error: ${error.message}`));
}
});3. キャッシュ戦略
3.1 GraphQLキャッシュの課題と解決策
GraphQLのキャッシュの課題:
-> RESTはURLベースでキャッシュ可能
GET /api/users/123 → Cache-Control: max-age=3600
-> GraphQLは全てPOST /graphql(URLが同じ)
-> HTTPキャッシュが使えない
解決策(4つのレイヤー):| Layer 1: クライアントサイドキャッシュ |
|---|
| → Apollo Client InMemoryCache |
| → 正規化キャッシュ(__typename + id) |
| Layer 2: CDNキャッシュ |
| → Persisted Queries(GET変換) |
| → Automatic Persisted Queries (APQ) |
| Layer 3: サーバーサイドキャッシュ |
| → Redis/Memcachedによるレスポンスキャッシュ |
| → @cacheControl ディレクティブ |
| Layer 4: データソースキャッシュ |
| → DataLoaderのリクエスト内キャッシュ |
| → RESTDataSourceのHTTPキャッシュ |
3.2 Apollo Client 正規化キャッシュ
// Apollo Client の正規化キャッシュ
import { InMemoryCache, makeVar } from '@apollo/client';
const cache = new InMemoryCache({
typePolicies: {
// ユーザー型のキャッシュ設定
User: {
keyFields: ['id'], // idフィールドでキャッシュのキーを生成
fields: {
// フルネームの計算フィールド
fullName: {
read(_, { readField }) {
const firstName = readField('firstName');
const lastName = readField('lastName');
return `${firstName} ${lastName}`;
},
},
},
},
// 商品型のキャッシュ設定
Product: {
keyFields: ['sku'], // SKUをキーとして使用(idの代わり)
fields: {
// 価格の表示フォーマット
formattedPrice: {
read(_, { readField }) {
const price = readField('price');
return `¥${price.toLocaleString()}`;
},
},
},
},
// クエリフィールドのキャッシュ設定
Query: {
fields: {
// usersクエリのキャッシュとページネーション
users: {
keyArgs: ['filter', 'sort'], // これらの引数でキャッシュを分ける
merge(existing, incoming, { args }) {
// ページネーションのマージ
if (!existing) return incoming;
if (args?.after) {
// 追加読み込み(infinite scroll)
return {
...incoming,
edges: [...existing.edges, ...incoming.edges],
};
}
// 新規取得(フィルタ変更等)
return incoming;
},
read(existing, { args }) {
// キャッシュからの読み取り
return existing;
},
},
// 単一ユーザーのキャッシュ参照
user: {
read(_, { args, toReference }) {
// キャッシュに既にあるユーザーを参照
return toReference({
__typename: 'User',
id: args.id,
});
},
},
// 検索結果のキャッシュ
search: {
keyArgs: ['query', 'type'],
merge(existing = { results: [] }, incoming) {
return {
...incoming,
results: [...existing.results, ...incoming.results],
};
},
},
},
},
// ページネーション接続のキャッシュ設定
UserConnection: {
fields: {
edges: {
merge(existing = [], incoming) {
return [...existing, ...incoming];
},
},
},
},
},
// 可能なタイプの定義(Union/Interfaceの解決用)
possibleTypes: {
SearchResult: ['User', 'Product', 'Order'],
Node: ['User', 'Product', 'Order', 'Category'],
},
});
// === キャッシュの手動操作 ===
// 1. キャッシュの直接更新
client.cache.modify({
id: client.cache.identify({ __typename: 'User', id: '123' }),
fields: {
name: () => 'Updated Name',
email: (prevEmail) => prevEmail, // 変更しない
orderCount: (prevCount) => prevCount + 1,
},
});
// 2. キャッシュへの書き込み
client.cache.writeQuery({
query: GET_USER,
variables: { id: '123' },
data: {
user: {
__typename: 'User',
id: '123',
name: 'New User',
email: 'new@example.com',
},
},
});
// 3. キャッシュからの読み取り
const cachedUser = client.cache.readQuery({
query: GET_USER,
variables: { id: '123' },
});
// 4. キャッシュからの削除(evict)
client.cache.evict({
id: client.cache.identify({ __typename: 'User', id: '123' }),
});
// ガベージコレクション(参照されなくなったオブジェクトを削除)
client.cache.gc();
// 5. Reactive Variables(ローカルステート管理)
const isLoggedInVar = makeVar(false);
const cartItemsVar = makeVar([]);
const cache2 = new InMemoryCache({
typePolicies: {
Query: {
fields: {
isLoggedIn: {
read() {
return isLoggedInVar();
},
},
cartItems: {
read() {
return cartItemsVar();
},
},
},
},
},
});
// Reactive Variableの更新(自動的にUIが再レンダリングされる)
isLoggedInVar(true);
cartItemsVar([...cartItemsVar(), { productId: '123', quantity: 1 }]);3.3 Persisted Queries
// Automatic Persisted Queries (APQ)
// → クエリ文字列をSHA256ハッシュに変換してGETリクエストに
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
const persistedQueryLink = createPersistedQueryLink({
sha256,
useGETForHashedQueries: true, // GETリクエストを使用 → CDNキャッシュ可能
});
const client = new ApolloClient({
link: persistedQueryLink.concat(httpLink),
cache: new InMemoryCache(),
});
// APQの動作フロー:
// 1. 初回: GET /graphql?extensions={"persistedQuery":{"sha256Hash":"abc..."}}
// 2. サーバー: "PersistedQueryNotFound" を返す
// 3. クライアント: POST /graphql でフルクエリを送信
// 4. サーバー: クエリをハッシュと紐付けて保存
// 5. 次回以降: GET /graphql?extensions={"persistedQuery":{"sha256Hash":"abc..."}}
// → CDNでキャッシュ可能!
// サーバー側の設定(Apollo Server)
const server = new ApolloServer({
typeDefs,
resolvers,
persistedQueries: {
// Redisキャッシュで永続化
cache: new KeyValueCache({
url: process.env.REDIS_URL,
ttl: 86400, // 24時間
}),
},
});3.4 サーバーサイドキャッシュ
// @cacheControl ディレクティブによるキャッシュ制御
const typeDefs = gql`
# ディレクティブ定義
enum CacheControlScope {
PUBLIC
PRIVATE
}
directive @cacheControl(
maxAge: Int
scope: CacheControlScope
inheritMaxAge: Boolean
) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION
# 型レベルのキャッシュ設定
type Product @cacheControl(maxAge: 3600) {
id: ID!
name: String!
description: String!
price: Float! @cacheControl(maxAge: 300) # 価格は5分
inventory: Int! @cacheControl(maxAge: 30) # 在庫は30秒
}
type User @cacheControl(maxAge: 0, scope: PRIVATE) {
id: ID!
name: String!
email: String!
orders: [Order!]! @cacheControl(maxAge: 60, scope: PRIVATE)
}
type Query {
products(category: String): [Product!]! @cacheControl(maxAge: 600)
product(id: ID!): Product @cacheControl(maxAge: 3600)
me: User @cacheControl(maxAge: 0, scope: PRIVATE)
}
`;
// Redisを使ったリゾルバーレベルのキャッシュ
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
function withCache(resolver, options = {}) {
const { ttl = 300, keyPrefix = 'gql' } = options;
return async (parent, args, context, info) => {
const cacheKey = `${keyPrefix}:${info.fieldName}:${JSON.stringify(args)}`;
// キャッシュチェック
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// リゾルバー実行
const result = await resolver(parent, args, context, info);
// キャッシュに保存
await redis.setex(cacheKey, ttl, JSON.stringify(result));
return result;
};
}
// 利用例
const resolvers = {
Query: {
products: withCache(
async (_, { category }, { dataSources }) => {
return dataSources.productAPI.getProducts({ category });
},
{ ttl: 600, keyPrefix: 'products' }
),
product: withCache(
async (_, { id }, { dataSources }) => {
return dataSources.productAPI.getProduct(id);
},
{ ttl: 3600, keyPrefix: 'product' }
),
},
};
// キャッシュの無効化
async function invalidateProductCache(productId) {
// 個別商品のキャッシュを削除
await redis.del(`product:product:{"id":"${productId}"}`);
// 商品一覧のキャッシュを全て削除
const keys = await redis.keys('products:products:*');
if (keys.length > 0) {
await redis.del(...keys);
}
}4. エラーハンドリング
4.1 エラーパターンの分類
# GraphQLの2つのエラーパターン
# (1) トップレベルエラー(GraphQL仕様のerrors配列)
# -> 認証エラー、構文エラー、サーバーエラー
# -> クライアントが予期できないエラー
{
"data": null,
"errors": [
{
"message": "Not authenticated",
"locations": [{ "line": 2, "column": 3 }],
"path": ["user"],
"extensions": {
"code": "UNAUTHENTICATED",
"http": { "status": 401 }
}
}
]
}
# (2) ビジネスロジックエラー(Payload内のerrors)
# -> バリデーション、ビジネスルール違反
# -> クライアントが処理すべきエラー
{
"data": {
"createUser": {
"user": null,
"errors": [
{
"field": "email",
"message": "Already exists",
"code": "ALREADY_EXISTS"
}
]
}
}
}
# (3) 部分成功パターン
# -> 一部のフィールドはnull、他は正常に返る
{
"data": {
"user": {
"name": "Alice",
"orders": null,
"profile": {
"bio": "Developer"
}
}
},
"errors": [
{
"message": "Failed to fetch orders",
"path": ["user", "orders"],
"extensions": { "code": "INTERNAL_SERVER_ERROR" }
}
]
}4.2 Result型パターン(Union型によるエラー表現)
# Result型パターン: Union型でエラーを型安全に表現
# → GraphQLの型システムを活用した最も堅牢な方法
# エラー型の定義
interface UserError {
message: String!
path: [String!]
}
type ValidationError implements UserError {
message: String!
path: [String!]
field: String!
constraint: String!
}
type NotFoundError implements UserError {
message: String!
path: [String!]
resourceType: String!
resourceId: ID!
}
type AuthorizationError implements UserError {
message: String!
path: [String!]
requiredPermission: String!
}
type BusinessRuleError implements UserError {
message: String!
path: [String!]
code: String!
details: JSON
}
# Mutation結果のUnion型
union CreateUserResult = CreateUserSuccess | ValidationError | AuthorizationError
type CreateUserSuccess {
user: User!
}
union UpdateOrderResult =
| UpdateOrderSuccess
| NotFoundError
| AuthorizationError
| BusinessRuleError
type UpdateOrderSuccess {
order: Order!
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserResult!
updateOrder(id: ID!, input: UpdateOrderInput!): UpdateOrderResult!
}
# クライアント側のクエリ
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
... on CreateUserSuccess {
user {
id
name
email
}
}
... on ValidationError {
message
field
constraint
}
... on AuthorizationError {
message
requiredPermission
}
}
}4.3 サーバー側のエラーハンドリング実装
// サーバー側のエラーハンドリング
import { GraphQLError } from 'graphql';
// カスタムエラークラスの定義
class AppError extends GraphQLError {
constructor(message, code, extensions = {}) {
super(message, {
extensions: {
code,
...extensions,
},
});
}
}
class AuthenticationError extends AppError {
constructor(message = 'Not authenticated') {
super(message, 'UNAUTHENTICATED', { http: { status: 401 } });
}
}
class ForbiddenError extends AppError {
constructor(message = 'Not authorized') {
super(message, 'FORBIDDEN', { http: { status: 403 } });
}
}
class NotFoundError extends AppError {
constructor(resource, id) {
super(`${resource} not found: ${id}`, 'NOT_FOUND', {
http: { status: 404 },
resource,
resourceId: id,
});
}
}
class ValidationError extends AppError {
constructor(errors) {
super('Validation failed', 'VALIDATION_ERROR', {
http: { status: 400 },
validationErrors: errors,
});
}
}
class RateLimitError extends AppError {
constructor(retryAfter) {
super('Rate limit exceeded', 'RATE_LIMITED', {
http: { status: 429 },
retryAfter,
});
}
}
// リゾルバーでの使用
const resolvers = {
Query: {
user: async (_, { id }, context) => {
// 認証チェック
if (!context.user) {
throw new AuthenticationError();
}
// 権限チェック
if (!context.user.canViewUser(id)) {
throw new ForbiddenError('You do not have permission to view this user');
}
const user = await context.dataSources.userAPI.getUser(id);
if (!user) {
throw new NotFoundError('User', id);
}
return user;
},
},
Mutation: {
createUser: async (_, { input }, context) => {
// バリデーション
const validationErrors = validateCreateUserInput(input);
if (validationErrors.length > 0) {
// Result型パターンの場合
return {
__typename: 'ValidationError',
message: 'Validation failed',
field: validationErrors[0].field,
constraint: validationErrors[0].constraint,
path: ['createUser'],
};
}
try {
const user = await context.dataSources.userAPI.create(input);
return {
__typename: 'CreateUserSuccess',
user,
};
} catch (error) {
if (error.code === 'UNIQUE_VIOLATION') {
return {
__typename: 'ValidationError',
message: 'Email already exists',
field: 'email',
constraint: 'unique',
path: ['createUser', 'input', 'email'],
};
}
throw error; // 予期しないエラーはトップレベルに
}
},
updateOrder: async (_, { id, input }, context) => {
if (!context.user) {
return {
__typename: 'AuthorizationError',
message: 'Authentication required',
requiredPermission: 'orders:write',
path: ['updateOrder'],
};
}
const order = await context.dataSources.orderAPI.getOrder(id);
if (!order) {
return {
__typename: 'NotFoundError',
message: `Order not found: ${id}`,
resourceType: 'Order',
resourceId: id,
path: ['updateOrder'],
};
}
// ビジネスルールチェック
if (order.status === 'SHIPPED' && input.status === 'CANCELLED') {
return {
__typename: 'BusinessRuleError',
message: 'Cannot cancel a shipped order',
code: 'ORDER_ALREADY_SHIPPED',
details: { currentStatus: order.status, requestedStatus: input.status },
path: ['updateOrder'],
};
}
const updated = await context.dataSources.orderAPI.update(id, input);
return {
__typename: 'UpdateOrderSuccess',
order: updated,
};
},
},
};
// グローバルエラーフォーマッター
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError, error) => {
// 内部エラーの詳細をログに記録
console.error('GraphQL Error:', {
message: formattedError.message,
code: formattedError.extensions?.code,
path: formattedError.path,
originalError: error,
});
// プロダクション環境では内部エラーの詳細を隠す
if (process.env.NODE_ENV === 'production') {
if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return {
...formattedError,
message: 'An internal error occurred',
extensions: {
code: 'INTERNAL_SERVER_ERROR',
},
};
}
}
// スタックトレースを削除
delete formattedError.extensions?.stacktrace;
return formattedError;
},
});4.4 クライアント側のエラーハンドリング
// クライアント側のエラーハンドリング(React + Apollo Client)
import { ApolloError, useQuery, useMutation } from '@apollo/client';
// エラーリンクの設定
import { onError } from '@apollo/client/link/error';
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (const error of graphQLErrors) {
switch (error.extensions?.code) {
case 'UNAUTHENTICATED':
// トークンリフレッシュを試みる
const oldHeaders = operation.getContext().headers;
return fromPromise(
refreshToken().then((newToken) => {
operation.setContext({
headers: {
...oldHeaders,
authorization: `Bearer ${newToken}`,
},
});
return forward(operation);
})
).flatMap((result) => result);
case 'FORBIDDEN':
// 権限エラーページへリダイレクト
window.location.href = '/forbidden';
break;
case 'RATE_LIMITED':
// リトライ
const retryAfter = error.extensions?.retryAfter || 60;
console.warn(`Rate limited. Retrying after ${retryAfter}s`);
break;
default:
console.error('GraphQL Error:', error.message);
}
}
}
if (networkError) {
console.error('Network Error:', networkError);
if ('statusCode' in networkError) {
switch (networkError.statusCode) {
case 503:
// サービス一時停止
showMaintenanceNotification();
break;
case 502:
case 504:
// ゲートウェイエラー → リトライ
return forward(operation);
}
}
}
});
// React コンポーネントでのエラーハンドリング
function UserProfile({ userId }: { userId: string }) {
const { data, loading, error } = useQuery(GET_USER, {
variables: { id: userId },
errorPolicy: 'all', // エラーがあっても部分データを受け取る
});
if (loading) return <LoadingSpinner />;
if (error) {
// ネットワークエラー
if (error.networkError) {
return <NetworkErrorMessage onRetry={() => window.location.reload()} />;
}
// GraphQLエラー
const authError = error.graphQLErrors?.find(
(e) => e.extensions?.code === 'UNAUTHENTICATED'
);
if (authError) {
return <LoginPrompt />;
}
const notFoundError = error.graphQLErrors?.find(
(e) => e.extensions?.code === 'NOT_FOUND'
);
if (notFoundError) {
return <NotFoundPage resource="User" />;
}
return <GenericErrorMessage error={error} />;
}
// 部分データの表示(errorsがあってもdataは利用可能)
return (
<div>
<h1>{data.user.name}</h1>
{data.user.orders ? (
<OrderList orders={data.user.orders} />
) : (
<p>注文データの取得に失敗しました</p>
)}
</div>
);
}
// Mutation のエラーハンドリング(Result型パターン)
function CreateUserForm() {
const [createUser, { loading }] = useMutation(CREATE_USER);
const handleSubmit = async (input: CreateUserInput) => {
try {
const { data } = await createUser({ variables: { input } });
const result = data.createUser;
switch (result.__typename) {
case 'CreateUserSuccess':
toast.success('ユーザーが作成されました');
navigate(`/users/${result.user.id}`);
break;
case 'ValidationError':
toast.error(`${result.field}: ${result.message}`);
break;
case 'AuthorizationError':
toast.error('権限がありません');
break;
}
} catch (error) {
// ネットワークエラー等の予期しないエラー
if (error instanceof ApolloError) {
toast.error('通信エラーが発生しました。再度お試しください。');
}
}
};
return <UserForm onSubmit={handleSubmit} loading={loading} />;
}5. セキュリティ
5.1 GraphQL特有のセキュリティリスク
GraphQL特有のセキュリティリスク:
(1) クエリの深さ攻撃(Query Depth Attack):
query {
user(id: "1") {
orders {
items {
product {
reviews {
author {
orders { <- 再帰的にネスト
items { ... }
}
}
}
}
}
}
}
}
-> 対策: クエリ深さの制限
(2) クエリの複雑度攻撃(Query Complexity Attack):
query {
users(first: 1000) {
orders(first: 1000) {
items(first: 1000) { ... }
}
}
}
-> 対策: クエリコストの制限
(3) イントロスペクション悪用:
query { __schema { types { name fields { name } } } }
-> 対策: 本番では無効化
(4) Batch攻撃(Query Batching Attack):
[
{ "query": "query { user(id: \"1\") { ... } }" },
{ "query": "query { user(id: \"2\") { ... } }" },
... x 1000
]
-> 対策: バッチサイズの制限
(5) フィールドサジェスション攻撃:
query { user { passwrd } }
-> "Did you mean 'password'?" がスキーマ情報を漏洩
-> 対策: サジェスションの無効化
(6) Aliasベースの攻撃:
query {
a1: user(id: "1") { email }
a2: user(id: "2") { email }
... x 1000
}
-> 同一フィールドをエイリアスで大量リクエスト
-> 対策: エイリアス数の制限
5.2 セキュリティ対策の実装
// セキュリティ対策の実装
// (1) クエリ深さ制限
import depthLimit from 'graphql-depth-limit';
// (2) クエリコスト分析
import {
createComplexityRule,
simpleEstimator,
fieldExtensionsEstimator,
} from 'graphql-query-complexity';
// (3) クエリ数制限(エイリアス攻撃対策)
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
// 最大7階層のネスト
depthLimit(7, { ignore: ['__schema'] }),
// クエリコスト制限
createComplexityRule({
maximumComplexity: 1000,
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
onComplete: (complexity) => {
console.log('Query Complexity:', complexity);
// メトリクス記録
metrics.recordComplexity(complexity);
},
}),
],
// イントロスペクションの無効化(本番環境)
introspection: process.env.NODE_ENV !== 'production',
// フィールドサジェスションの無効化
includeStacktraceInErrorResponses: false,
plugins: [
// CSRF対策
{
async requestDidStart() {
return {
async didResolveOperation(requestContext) {
// Content-Type チェック
const contentType = requestContext.request.http?.headers.get('content-type');
if (!contentType?.includes('application/json')) {
throw new GraphQLError('Content-Type must be application/json');
}
},
};
},
},
// ロギングプラグイン
{
async requestDidStart(requestContext) {
const start = Date.now();
return {
async willSendResponse(requestContext) {
const duration = Date.now() - start;
console.log({
operation: requestContext.request.operationName,
duration,
errors: requestContext.errors?.length || 0,
});
},
};
},
},
],
});
// (4) レート制限(フィールドレベル)
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
const rateLimitDirectiveTypeDefs = `
directive @rateLimit(
max: Int!
window: String!
message: String
) on FIELD_DEFINITION
`;
function rateLimitDirective(directiveName = 'rateLimit') {
return {
rateLimitDirectiveTypeDefs,
rateLimitDirectiveTransformer: (schema) =>
mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const directive = getDirective(schema, fieldConfig, directiveName)?.[0];
if (!directive) return fieldConfig;
const { max, window: windowStr, message } = directive;
const originalResolve = fieldConfig.resolve;
fieldConfig.resolve = async (source, args, context, info) => {
const key = `rateLimit:${context.user?.id || context.ip}:${info.fieldName}`;
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, parseWindow(windowStr));
}
if (current > max) {
throw new GraphQLError(
message || `Rate limit exceeded for ${info.fieldName}`,
{ extensions: { code: 'RATE_LIMITED' } }
);
}
return originalResolve(source, args, context, info);
};
return fieldConfig;
},
}),
};
}
// スキーマでの使用
const typeDefs = gql`
${rateLimitDirectiveTypeDefs}
type Query {
login(email: String!, password: String!): AuthPayload!
@rateLimit(max: 5, window: "15m", message: "Too many login attempts")
search(query: String!): [SearchResult!]!
@rateLimit(max: 30, window: "1m")
sendPasswordReset(email: String!): Boolean!
@rateLimit(max: 3, window: "1h")
}
`;5.3 認可(Authorization)の実装
// フィールドレベル認可
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
const authDirectiveTypeDefs = `
directive @auth(
requires: [Role!]!
) on FIELD_DEFINITION | OBJECT
enum Role {
USER
ADMIN
SUPER_ADMIN
MODERATOR
}
`;
function authDirective(directiveName = 'auth') {
return {
authDirectiveTypeDefs,
authDirectiveTransformer: (schema) =>
mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const directive = getDirective(schema, fieldConfig, directiveName)?.[0];
if (!directive) return fieldConfig;
const { requires } = directive;
const originalResolve = fieldConfig.resolve;
fieldConfig.resolve = async (source, args, context, info) => {
if (!context.user) {
throw new AuthenticationError();
}
const hasRole = requires.some((role) =>
context.user.roles.includes(role)
);
if (!hasRole) {
throw new ForbiddenError(
`Requires one of: ${requires.join(', ')}`
);
}
return originalResolve
? originalResolve(source, args, context, info)
: source[info.fieldName];
};
return fieldConfig;
},
}),
};
}
// スキーマでの使用
const typeDefs = gql`
${authDirectiveTypeDefs}
type Query {
me: User!
users: [User!]! @auth(requires: [ADMIN])
analytics: Analytics! @auth(requires: [ADMIN, SUPER_ADMIN])
moderationQueue: [Report!]! @auth(requires: [MODERATOR, ADMIN])
}
type User {
id: ID!
name: String!
email: String! @auth(requires: [ADMIN])
phone: String @auth(requires: [ADMIN])
orders: [Order!]!
internalNotes: String @auth(requires: [ADMIN, SUPER_ADMIN])
}
type Mutation {
deleteUser(id: ID!): Boolean! @auth(requires: [SUPER_ADMIN])
banUser(id: ID!): User! @auth(requires: [MODERATOR, ADMIN])
}
`;
// Persisted Queries(許可されたクエリのみ実行)
// → 最も強力なセキュリティ対策
import { readFileSync } from 'fs';
import { join } from 'path';
// ビルド時にクエリを抽出してホワイトリストを作成
const allowedQueries = new Map();
const queryFiles = fs.readdirSync('./queries');
queryFiles.forEach((file) => {
const query = readFileSync(join('./queries', file), 'utf-8');
const hash = crypto.createHash('sha256').update(query).digest('hex');
allowedQueries.set(hash, query);
});
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
async requestDidStart() {
return {
async didResolveOperation(requestContext) {
if (process.env.NODE_ENV === 'production') {
const queryHash = crypto
.createHash('sha256')
.update(requestContext.request.query)
.digest('hex');
if (!allowedQueries.has(queryHash)) {
throw new GraphQLError('Query not allowed', {
extensions: { code: 'PERSISTED_QUERY_NOT_FOUND' },
});
}
}
},
};
},
},
],
});
// バッチサイズ制限
// Express ミドルウェアで実装
app.use('/graphql', (req, res, next) => {
if (Array.isArray(req.body)) {
if (req.body.length > 10) {
return res.status(400).json({
errors: [{ message: 'Batch size exceeds maximum of 10' }],
});
}
}
next();
});6. スキーマ設計パターン
6.1 インターフェースとユニオン型
# (1) インターフェース(共通フィールドの定義)
interface Node {
id: ID!
}
interface Timestamped {
createdAt: DateTime!
updatedAt: DateTime!
}
interface Auditable {
createdBy: User!
updatedBy: User
version: Int!
}
type User implements Node & Timestamped {
id: ID!
name: String!
email: String!
createdAt: DateTime!
updatedAt: DateTime!
}
type Product implements Node & Timestamped & Auditable {
id: ID!
name: String!
price: Float!
createdAt: DateTime!
updatedAt: DateTime!
createdBy: User!
updatedBy: User
version: Int!
}
# (2) ユニオン型(異なる型の集合)
union SearchResult = User | Product | Order | Category
type Query {
search(query: String!, type: SearchResultType): [SearchResult!]!
}
enum SearchResultType {
ALL
USERS
PRODUCTS
ORDERS
}
# クエリ側のフラグメント活用
query Search($q: String!) {
search(query: $q) {
... on User {
id
name
email
}
... on Product {
id
name
price
category { name }
}
... on Order {
id
total
status
customer { name }
}
}
}
# (3) Relay Node仕様(Global Object Identification)
type Query {
node(id: ID!): Node # 任意のNodeをIDで取得
nodes(ids: [ID!]!): [Node]! # 複数のNodeを一括取得
users(first: Int, after: String): UserConnection!
}
# Node IDはBase64エンコードされた "Type:id" 形式
# User:123 → "VXNlcjoxMjM="
# Product:456 → "UHJvZHVjdDo0NTY="
# (4) カスタムスカラー
scalar DateTime # ISO 8601 日時
scalar JSON # 任意のJSON
scalar URL # URL文字列
scalar Email # メールアドレス
scalar Currency # 通貨コード(ISO 4217)
scalar PhoneNumber # E.164形式の電話番号
type User {
id: ID!
email: Email!
phone: PhoneNumber
website: URL
metadata: JSON
registeredAt: DateTime!
}6.2 Relay Connection仕様(カーソルベースページネーション)
# Relay Connection仕様
# → カーソルベースのページネーション標準
# Connection型の定義
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
# 前方ページネーション
users(first: Int!, after: String, filter: UserFilter): UserConnection!
# 後方ページネーション
# users(last: Int!, before: String): UserConnection!
}
input UserFilter {
name: StringFilter
email: StringFilter
status: UserStatus
createdAt: DateRangeFilter
roles: [Role!]
}
input StringFilter {
eq: String
contains: String
startsWith: String
in: [String!]
}
input DateRangeFilter {
gte: DateTime
lte: DateTime
}// Connection リゾルバーの実装
const resolvers = {
Query: {
users: async (_, { first, after, filter }, context) => {
// カーソルのデコード
const cursor = after ? decodeCursor(after) : null;
// フィルタ条件の構築
const where = buildWhereClause(filter);
// N+1 を取得(hasNextPage判定のため)
const limit = first + 1;
let query = db('users').where(where).orderBy('created_at', 'desc').limit(limit);
if (cursor) {
query = query.where('created_at', '<', cursor.createdAt)
.orWhere(function () {
this.where('created_at', '=', cursor.createdAt)
.where('id', '<', cursor.id);
});
}
const rows = await query;
const hasNextPage = rows.length > first;
const nodes = hasNextPage ? rows.slice(0, first) : rows;
// 総件数(オプション、パフォーマンスに注意)
const [{ count: totalCount }] = await db('users').where(where).count('* as count');
return {
edges: nodes.map((node) => ({
node,
cursor: encodeCursor({
id: node.id,
createdAt: node.createdAt,
}),
})),
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: nodes.length > 0
? encodeCursor({ id: nodes[0].id, createdAt: nodes[0].createdAt })
: null,
endCursor: nodes.length > 0
? encodeCursor({
id: nodes[nodes.length - 1].id,
createdAt: nodes[nodes.length - 1].createdAt,
})
: null,
},
totalCount: parseInt(totalCount, 10),
};
},
},
};
// カーソルのエンコード/デコード
function encodeCursor(data) {
return Buffer.from(JSON.stringify(data)).toString('base64');
}
function decodeCursor(cursor) {
return JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8'));
}
// フィルタ条件の構築
function buildWhereClause(filter) {
if (!filter) return {};
const conditions = {};
if (filter.name?.contains) {
conditions.name = ['ILIKE', `%${filter.name.contains}%`];
}
if (filter.status) {
conditions.status = filter.status;
}
if (filter.createdAt?.gte) {
conditions['created_at >='] = filter.createdAt.gte;
}
if (filter.createdAt?.lte) {
conditions['created_at <='] = filter.createdAt.lte;
}
return conditions;
}6.3 ディレクティブの活用
# カスタムディレクティブ
directive @auth(requires: [Role!]!) on FIELD_DEFINITION | OBJECT
directive @deprecated(reason: String!) on FIELD_DEFINITION | ENUM_VALUE
directive @cacheControl(maxAge: Int!, scope: CacheControlScope) on FIELD_DEFINITION | OBJECT
directive @rateLimit(max: Int!, window: String!) on FIELD_DEFINITION
directive @log(level: LogLevel = INFO) on FIELD_DEFINITION
directive @computed on FIELD_DEFINITION
directive @validate(
min: Int
max: Int
minLength: Int
maxLength: Int
pattern: String
email: Boolean
) on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
enum LogLevel {
DEBUG
INFO
WARN
ERROR
}
# ディレクティブの利用例
type Query {
publicData: String!
sensitiveData: String! @auth(requires: [ADMIN])
oldField: String @deprecated(reason: "Use newField instead")
cachedProducts: [Product!]! @cacheControl(maxAge: 3600)
criticalOperation: Result! @rateLimit(max: 10, window: "1m") @log(level: WARN)
}
type User @auth(requires: [USER]) {
id: ID!
name: String!
email: String! @auth(requires: [ADMIN])
fullName: String! @computed
}
input CreateUserInput {
name: String! @validate(minLength: 2, maxLength: 50)
email: String! @validate(email: true)
age: Int @validate(min: 0, max: 150)
password: String! @validate(minLength: 8, pattern: "^(?=.*[A-Z])(?=.*[0-9])")
}6.4 Input型とMutation設計パターン
# Mutation設計のベストプラクティス
# (1) 単一のInput型を使用
input CreateUserInput {
name: String!
email: String!
password: String!
profile: CreateProfileInput
}
input CreateProfileInput {
bio: String
avatar: URL
location: String
}
input UpdateUserInput {
name: String
email: String
profile: UpdateProfileInput
}
input UpdateProfileInput {
bio: String
avatar: URL
location: String
}
# (2) Payload型で結果を返す
type CreateUserPayload {
user: User
errors: [UserError!]!
}
type DeleteUserPayload {
deletedUserId: ID
errors: [UserError!]!
}
# (3) 一貫性のあるMutation命名
type Mutation {
# CRUD操作: create/update/delete + リソース名
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
# アクション: 動詞 + リソース名
activateUser(id: ID!): ActivateUserPayload!
deactivateUser(id: ID!): DeactivateUserPayload!
resetPassword(email: String!): ResetPasswordPayload!
verifyEmail(token: String!): VerifyEmailPayload!
# 関連リソースの操作
addUserToTeam(userId: ID!, teamId: ID!): AddUserToTeamPayload!
removeUserFromTeam(userId: ID!, teamId: ID!): RemoveUserFromTeamPayload!
# バッチ操作
bulkCreateUsers(inputs: [CreateUserInput!]!): BulkCreateUsersPayload!
bulkDeleteUsers(ids: [ID!]!): BulkDeleteUsersPayload!
}
# (4) ファイルアップロード
scalar Upload
type Mutation {
uploadAvatar(file: Upload!): UploadAvatarPayload!
uploadDocument(file: Upload!, metadata: DocumentMetadataInput!): UploadDocumentPayload!
}
input DocumentMetadataInput {
title: String!
description: String
category: DocumentCategory!
tags: [String!]
}7. Apollo Federation(マイクロサービス統合)
7.1 Federationの概要
Apollo Federation アーキテクチャ:| Apollo Gateway |
|---|
| (統合GraphQLエンドポイント) |
| クエリプランニング & 実行 |
│ │ │| Users | Orders | Prods | ||
|---|---|---|---|---|
| Service | Srvce | Srvce | ||
| :4001 | :4002 | :4003 |
│ │ │| UserDB | OrderDB | ProdDB |
|---|
利点:
→ 各サービスが独立してデプロイ可能
→ チームごとにスキーマを管理
→ 単一のGraphQLエンドポイントをクライアントに提供
→ スキーマの型をサービス間で共有(Entity)
7.2 Subgraph(サブグラフ)の定義
# === Users Service (Subgraph) ===
# users-service/schema.graphql
extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@shareable"])
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
role: Role!
createdAt: DateTime!
}
enum Role {
USER
ADMIN
}
type Query {
me: User
user(id: ID!): User
users(first: Int!, after: String): UserConnection!
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
}# === Orders Service (Subgraph) ===
# orders-service/schema.graphql
extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@external", "@requires"])
# Users ServiceのUser型を拡張
type User @key(fields: "id") {
id: ID!
orders(first: Int!, after: String): OrderConnection!
totalSpent: Float! @requires(fields: "id")
}
type Order @key(fields: "id") {
id: ID!
customer: User!
items: [OrderItem!]!
total: Float!
status: OrderStatus!
createdAt: DateTime!
}
type OrderItem {
product: Product!
quantity: Int!
unitPrice: Float!
subtotal: Float!
}
# Products Serviceの型を参照
type Product @key(fields: "id") {
id: ID!
}
enum OrderStatus {
PENDING
CONFIRMED
SHIPPED
DELIVERED
CANCELLED
}
type Query {
order(id: ID!): Order
orders(filter: OrderFilter): OrderConnection!
}
type Mutation {
createOrder(input: CreateOrderInput!): CreateOrderPayload!
cancelOrder(id: ID!): CancelOrderPayload!
}# === Products Service (Subgraph) ===
# products-service/schema.graphql
extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"])
type Product @key(fields: "id") {
id: ID!
name: String!
description: String!
price: Float!
category: Category!
inventory: Int!
reviews: [Review!]!
}
type Category @key(fields: "id") {
id: ID!
name: String!
products(first: Int!, after: String): ProductConnection!
}
type Review {
id: ID!
author: User!
rating: Int!
comment: String!
createdAt: DateTime!
}
type User @key(fields: "id") {
id: ID!
}
type Query {
product(id: ID!): Product
products(filter: ProductFilter, first: Int!, after: String): ProductConnection!
categories: [Category!]!
}7.3 Gatewayの設定
// Apollo Gateway の設定
import { ApolloServer } from '@apollo/server';
import { ApolloGateway, IntrospectAndCompose, RemoteGraphQLDataSource } from '@apollo/gateway';
import { startStandaloneServer } from '@apollo/server/standalone';
const gateway = new ApolloGateway({
supergraphSdl: new IntrospectAndCompose({
subgraphs: [
{ name: 'users', url: 'http://users-service:4001/graphql' },
{ name: 'orders', url: 'http://orders-service:4002/graphql' },
{ name: 'products', url: 'http://products-service:4003/graphql' },
],
pollIntervalInMs: 10000, // 10秒ごとにスキーマ更新をチェック
}),
// カスタムDataSource(認証ヘッダーの転送)
buildService({ url }) {
return new RemoteGraphQLDataSource({
url,
willSendRequest({ request, context }) {
// クライアントの認証情報をサブグラフに転送
if (context.token) {
request.http.headers.set('authorization', context.token);
}
// リクエストIDの伝播(分散トレーシング)
if (context.requestId) {
request.http.headers.set('x-request-id', context.requestId);
}
},
didReceiveResponse({ response, context }) {
// レスポンスヘッダーの処理
const cacheControl = response.http.headers.get('cache-control');
if (cacheControl) {
context.cacheControl = cacheControl;
}
return response;
},
});
},
});
const server = new ApolloServer({
gateway,
// Gateway固有のプラグイン
plugins: [
{
async requestDidStart() {
const start = Date.now();
return {
async willSendResponse(requestContext) {
const duration = Date.now() - start;
// Gateway レベルのメトリクス記録
metrics.recordGatewayLatency(
requestContext.request.operationName,
duration
);
},
};
},
},
],
});
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => ({
token: req.headers.authorization,
requestId: req.headers['x-request-id'] || crypto.randomUUID(),
}),
listen: { port: 4000 },
});
console.log(`Gateway running at ${url}`);7.4 サブグラフのリゾルバー実装
// Users Service のリゾルバー
import { buildSubgraphSchema } from '@apollo/subgraph';
const resolvers = {
Query: {
me: (_, __, context) => context.dataSources.userAPI.getUser(context.userId),
user: (_, { id }, context) => context.dataSources.userAPI.getUser(id),
users: (_, args, context) => context.dataSources.userAPI.getUsers(args),
},
User: {
// __resolveReference: Federation がEntity解決時に呼ぶ
__resolveReference: (ref, context) => {
return context.dataSources.userAPI.getUser(ref.id);
},
},
};
const server = new ApolloServer({
schema: buildSubgraphSchema({ typeDefs, resolvers }),
});
// Orders Service のリゾルバー
const orderResolvers = {
Query: {
order: (_, { id }, context) => context.dataSources.orderAPI.getOrder(id),
orders: (_, { filter }, context) => context.dataSources.orderAPI.getOrders(filter),
},
User: {
// User型の拡張フィールド
orders: (user, args, context) => {
return context.dataSources.orderAPI.getOrdersByUser(user.id, args);
},
totalSpent: async (user, _, context) => {
const orders = await context.dataSources.orderAPI.getOrdersByUser(user.id);
return orders.reduce((sum, order) => sum + order.total, 0);
},
},
Order: {
customer: (order) => ({ __typename: 'User', id: order.customerId }),
items: (order, _, context) => {
return context.dataSources.orderAPI.getOrderItems(order.id);
},
},
OrderItem: {
product: (item) => ({ __typename: 'Product', id: item.productId }),
},
};8. パフォーマンスチューニング
8.1 クエリパフォーマンスの計測
// パフォーマンス計測プラグイン
const performancePlugin = {
async requestDidStart(requestContext) {
const start = process.hrtime.bigint();
const resolverTimings = [];
return {
async executionDidStart() {
return {
willResolveField({ info }) {
const fieldStart = process.hrtime.bigint();
return (error, result) => {
const duration = Number(process.hrtime.bigint() - fieldStart) / 1e6;
resolverTimings.push({
path: info.path,
parentType: info.parentType.name,
fieldName: info.fieldName,
returnType: info.returnType.toString(),
duration,
error: error?.message,
});
};
},
};
},
async willSendResponse(requestContext) {
const totalDuration = Number(process.hrtime.bigint() - start) / 1e6;
// 遅いリゾルバーの検出(100ms以上)
const slowResolvers = resolverTimings.filter((t) => t.duration > 100);
if (slowResolvers.length > 0) {
console.warn('Slow resolvers detected:', {
operation: requestContext.request.operationName,
totalDuration: `${totalDuration.toFixed(2)}ms`,
slowResolvers: slowResolvers.map((r) => ({
path: printPath(r.path),
duration: `${r.duration.toFixed(2)}ms`,
})),
});
}
// メトリクス送信
await metrics.send({
operation: requestContext.request.operationName,
totalDuration,
resolverCount: resolverTimings.length,
slowResolverCount: slowResolvers.length,
errors: requestContext.errors?.length || 0,
});
// 開発環境ではレスポンスにタイミング情報を追加
if (process.env.NODE_ENV === 'development') {
requestContext.response.extensions = {
...requestContext.response.extensions,
tracing: {
totalDuration: `${totalDuration.toFixed(2)}ms`,
resolvers: resolverTimings.map((t) => ({
path: printPath(t.path),
duration: `${t.duration.toFixed(2)}ms`,
})),
},
};
}
},
};
},
};
function printPath(path) {
const parts = [];
let current = path;
while (current) {
parts.unshift(current.key);
current = current.prev;
}
return parts.join('.');
}8.2 クエリの最適化テクニック
// 1. フィールドレベルの遅延解決(必要なフィールドのみ解決)
const resolvers = {
User: {
// ordersフィールドがクエリに含まれている場合のみ実行される
orders: async (user, args, context, info) => {
// info.fieldNodesからサブフィールドを確認
const requestedFields = getRequestedFields(info);
// 必要なフィールドのみSELECT
const selectFields = mapFieldsToColumns(requestedFields);
return context.dataSources.orderAPI.getOrdersByUser(user.id, {
select: selectFields,
...args,
});
},
// 重い計算フィールドの最適化
statistics: async (user, _, context, info) => {
// statisticsフィールドが実際にリクエストされたサブフィールドを確認
const requestedStats = getRequestedFields(info);
const result = {};
// リクエストされた統計のみ計算
if (requestedStats.includes('orderCount')) {
result.orderCount = await context.loaders.orderCountLoader.load(user.id);
}
if (requestedStats.includes('totalSpent')) {
result.totalSpent = await context.loaders.totalSpentLoader.load(user.id);
}
if (requestedStats.includes('averageOrderValue')) {
const [count, total] = await Promise.all([
context.loaders.orderCountLoader.load(user.id),
context.loaders.totalSpentLoader.load(user.id),
]);
result.averageOrderValue = count > 0 ? total / count : 0;
}
return result;
},
},
};
// 2. @defer / @stream ディレクティブ(段階的レスポンス)
// GraphQL Incremental Delivery
const GET_USER_WITH_DEFER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
# 重いフィールドを遅延ロード
... @defer(label: "orders") {
orders(first: 10) {
edges {
node {
id
total
status
}
}
}
}
... @defer(label: "recommendations") {
recommendations {
products {
id
name
price
}
}
}
}
}
`;
// クライアント側での @defer 利用
function UserProfile({ userId }) {
const { data, loading } = useQuery(GET_USER_WITH_DEFER, {
variables: { id: userId },
});
return (
<div>
{/* 基本情報は即座に表示 */}
<h1>{data?.user?.name}</h1>
<p>{data?.user?.email}</p>
{/* 注文は遅延ロード */}
<Suspense fallback={<OrdersSkeleton />}>
<OrderList orders={data?.user?.orders} />
</Suspense>
{/* レコメンデーションも遅延ロード */}
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations items={data?.user?.recommendations} />
</Suspense>
</div>
);
}
// 3. クエリプランの最適化
// lookahead パターン: 親リゾルバーで子の必要データを先読み
const resolvers = {
Query: {
users: async (_, args, context, info) => {
// 子フィールドで何が要求されているか確認
const selections = info.fieldNodes[0].selectionSet;
const needsOrders = hasField(selections, 'orders');
const needsProfile = hasField(selections, 'profile');
// JOINまたはサブクエリで一括取得
let query = db('users').select('users.*');
if (needsProfile) {
query = query.leftJoin('profiles', 'users.id', 'profiles.user_id')
.select('profiles.bio', 'profiles.avatar');
}
const users = await query.where(buildFilter(args.filter)).limit(args.first);
// DataLoaderにプライミング
if (needsOrders) {
const userIds = users.map(u => u.id);
const allOrders = await db('orders').whereIn('user_id', userIds);
const ordersByUser = groupBy(allOrders, 'userId');
userIds.forEach(id => {
context.loaders.ordersByUserLoader.prime(id, ordersByUser[id] || []);
});
}
return users;
},
},
};8.3 プロダクション運用のベストプラクティス
// Apollo Server のプロダクション設定
import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled';
import { ApolloServerPluginUsageReporting } from '@apollo/server/plugin/usageReporting';
const server = new ApolloServer({
typeDefs,
resolvers,
// プロダクション設定
introspection: false,
includeStacktraceInErrorResponses: false,
plugins: [
// ランディングページの無効化
ApolloServerPluginLandingPageDisabled(),
// Apollo Studio へのメトリクス送信
ApolloServerPluginUsageReporting({
sendVariableValues: { none: true }, // 変数値を送信しない
sendHeaders: { none: true }, // ヘッダーを送信しない
}),
// パフォーマンス計測
performancePlugin,
// リクエストログ
{
async requestDidStart({ request }) {
return {
async didEncounterErrors({ errors }) {
errors.forEach((error) => {
// エラーログ(Sentry等に送信)
Sentry.captureException(error.originalError || error, {
extra: {
query: request.query,
variables: request.variables,
operationName: request.operationName,
},
});
});
},
};
},
},
],
// リクエストボディサイズの制限
// expressMiddleware側で設定
});
// Express設定
app.use(
'/graphql',
express.json({ limit: '1mb' }), // リクエストサイズ制限
expressMiddleware(server, {
context: async ({ req }) => ({
user: await authenticateUser(req),
loaders: createLoaders(db),
dataSources: createDataSources(),
requestId: req.headers['x-request-id'] || crypto.randomUUID(),
}),
})
);
// ヘルスチェックエンドポイント
app.get('/health', async (req, res) => {
try {
// DB接続チェック
await db.raw('SELECT 1');
// Redisチェック
await redis.ping();
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
} catch (error) {
res.status(503).json({
status: 'unhealthy',
error: error.message,
timestamp: new Date().toISOString(),
});
}
});9. テスト戦略
9.1 リゾルバーの単体テスト
// リゾルバーの単体テスト(Jest)
import { resolvers } from '../resolvers';
describe('User resolvers', () => {
const mockContext = {
user: { id: '1', roles: ['USER'] },
dataSources: {
userAPI: {
getUser: jest.fn(),
create: jest.fn(),
},
},
loaders: {
userLoader: {
load: jest.fn(),
},
ordersByUserLoader: {
load: jest.fn(),
},
},
};
afterEach(() => {
jest.clearAllMocks();
});
describe('Query.user', () => {
it('should return user by id', async () => {
const mockUser = { id: '1', name: 'Alice', email: 'alice@example.com' };
mockContext.dataSources.userAPI.getUser.mockResolvedValue(mockUser);
const result = await resolvers.Query.user(
null,
{ id: '1' },
mockContext
);
expect(result).toEqual(mockUser);
expect(mockContext.dataSources.userAPI.getUser).toHaveBeenCalledWith('1');
});
it('should throw AuthenticationError when not authenticated', async () => {
const unauthContext = { ...mockContext, user: null };
await expect(
resolvers.Query.user(null, { id: '1' }, unauthContext)
).rejects.toThrow('Not authenticated');
});
it('should throw NotFoundError when user does not exist', async () => {
mockContext.dataSources.userAPI.getUser.mockResolvedValue(null);
await expect(
resolvers.Query.user(null, { id: '999' }, mockContext)
).rejects.toThrow('User not found');
});
});
describe('Mutation.createUser', () => {
it('should create user successfully', async () => {
const input = { name: 'Bob', email: 'bob@example.com', password: 'Pass123!' };
const mockUser = { id: '2', ...input };
mockContext.dataSources.userAPI.create.mockResolvedValue(mockUser);
const result = await resolvers.Mutation.createUser(
null,
{ input },
mockContext
);
expect(result.__typename).toBe('CreateUserSuccess');
expect(result.user).toEqual(mockUser);
});
it('should return ValidationError for invalid email', async () => {
const input = { name: 'Bob', email: 'invalid', password: 'Pass123!' };
const result = await resolvers.Mutation.createUser(
null,
{ input },
mockContext
);
expect(result.__typename).toBe('ValidationError');
expect(result.field).toBe('email');
});
});
describe('User.orders', () => {
it('should load orders via DataLoader', async () => {
const mockOrders = [
{ id: 'o1', total: 100 },
{ id: 'o2', total: 200 },
];
mockContext.loaders.ordersByUserLoader.load.mockResolvedValue(mockOrders);
const result = await resolvers.User.orders(
{ id: '1' },
{},
mockContext
);
expect(result).toEqual(mockOrders);
expect(mockContext.loaders.ordersByUserLoader.load).toHaveBeenCalledWith('1');
});
});
});9.2 統合テスト
// GraphQL統合テスト(Apollo Server + Supertest)
import { ApolloServer } from '@apollo/server';
import request from 'supertest';
import { createTestServer, createTestDatabase } from '../test/helpers';
describe('GraphQL API Integration Tests', () => {
let server;
let testDb;
beforeAll(async () => {
testDb = await createTestDatabase();
server = await createTestServer(testDb);
});
afterAll(async () => {
await testDb.destroy();
await server.stop();
});
beforeEach(async () => {
// テストデータの投入
await testDb.seed.run();
});
afterEach(async () => {
// テストデータのクリア
await testDb.raw('TRUNCATE users, orders, products CASCADE');
});
describe('Users Query', () => {
it('should fetch paginated users', async () => {
const query = `
query GetUsers($first: Int!, $after: String) {
users(first: $first, after: $after) {
edges {
node {
id
name
email
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
`;
const response = await request(server.app)
.post('/graphql')
.set('Authorization', 'Bearer test-admin-token')
.send({
query,
variables: { first: 5 },
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
const { users } = response.body.data;
expect(users.edges).toHaveLength(5);
expect(users.pageInfo.hasNextPage).toBe(true);
expect(users.totalCount).toBeGreaterThan(5);
});
it('should handle cursor-based pagination', async () => {
// 1ページ目
const page1 = await graphqlRequest(server, {
query: GET_USERS,
variables: { first: 3 },
});
const endCursor = page1.data.users.pageInfo.endCursor;
// 2ページ目
const page2 = await graphqlRequest(server, {
query: GET_USERS,
variables: { first: 3, after: endCursor },
});
// 重複がないことを確認
const page1Ids = page1.data.users.edges.map(e => e.node.id);
const page2Ids = page2.data.users.edges.map(e => e.node.id);
const intersection = page1Ids.filter(id => page2Ids.includes(id));
expect(intersection).toHaveLength(0);
});
});
describe('Create User Mutation', () => {
it('should create user and return via subscription', async () => {
const mutation = `
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
... on CreateUserSuccess {
user {
id
name
email
}
}
... on ValidationError {
message
field
}
}
}
`;
const response = await graphqlRequest(server, {
query: mutation,
variables: {
input: {
name: 'Test User',
email: 'test@example.com',
password: 'SecurePass123!',
},
},
});
expect(response.data.createUser.__typename).toBe('CreateUserSuccess');
expect(response.data.createUser.user.name).toBe('Test User');
// DBに保存されていることを確認
const dbUser = await testDb('users')
.where({ email: 'test@example.com' })
.first();
expect(dbUser).toBeTruthy();
expect(dbUser.name).toBe('Test User');
});
});
});9.3 スキーマテスト
// スキーマの構造テスト
import { buildSchema, validateSchema, introspectionFromSchema } from 'graphql';
describe('GraphQL Schema', () => {
const schema = buildSchema(typeDefs);
it('should have no validation errors', () => {
const errors = validateSchema(schema);
expect(errors).toHaveLength(0);
});
it('should have required query fields', () => {
const queryType = schema.getQueryType();
const fields = queryType.getFields();
expect(fields).toHaveProperty('user');
expect(fields).toHaveProperty('users');
expect(fields).toHaveProperty('me');
expect(fields).toHaveProperty('products');
});
it('should have required mutation fields', () => {
const mutationType = schema.getMutationType();
const fields = mutationType.getFields();
expect(fields).toHaveProperty('createUser');
expect(fields).toHaveProperty('updateUser');
expect(fields).toHaveProperty('deleteUser');
});
it('should have Node interface implemented correctly', () => {
const userType = schema.getType('User');
const interfaces = userType.getInterfaces();
const nodeInterface = interfaces.find(i => i.name === 'Node');
expect(nodeInterface).toBeDefined();
expect(userType.getFields()).toHaveProperty('id');
});
// スキーマの破壊的変更チェック
it('should not have breaking changes from previous version', async () => {
const { findBreakingChanges } = await import('graphql');
const oldSchema = buildSchema(readFileSync('./schema-v1.graphql', 'utf-8'));
const newSchema = schema;
const breakingChanges = findBreakingChanges(oldSchema, newSchema);
// 許容される破壊的変更がある場合はフィルタ
const unexpectedChanges = breakingChanges.filter(
change => !allowedBreakingChanges.includes(change.description)
);
expect(unexpectedChanges).toHaveLength(0);
});
});10. モニタリングとオブザーバビリティ
10.1 メトリクス収集
// Prometheus メトリクスの収集
import { register, Counter, Histogram, Gauge } from 'prom-client';
// メトリクス定義
const graphqlRequestDuration = new Histogram({
name: 'graphql_request_duration_seconds',
help: 'Duration of GraphQL requests',
labelNames: ['operation', 'operationType', 'status'],
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
});
const graphqlResolverDuration = new Histogram({
name: 'graphql_resolver_duration_seconds',
help: 'Duration of individual GraphQL resolvers',
labelNames: ['parentType', 'fieldName'],
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1],
});
const graphqlErrors = new Counter({
name: 'graphql_errors_total',
help: 'Total number of GraphQL errors',
labelNames: ['code', 'operation'],
});
const graphqlComplexity = new Histogram({
name: 'graphql_query_complexity',
help: 'Complexity of GraphQL queries',
labelNames: ['operation'],
buckets: [10, 50, 100, 200, 500, 1000],
});
const activeSubscriptions = new Gauge({
name: 'graphql_active_subscriptions',
help: 'Number of active GraphQL subscriptions',
labelNames: ['subscription'],
});
// メトリクス収集プラグイン
const metricsPlugin = {
async requestDidStart({ request }) {
const timer = graphqlRequestDuration.startTimer();
return {
async executionDidStart() {
return {
willResolveField({ info }) {
const resolverTimer = graphqlResolverDuration.startTimer({
parentType: info.parentType.name,
fieldName: info.fieldName,
});
return () => resolverTimer();
},
};
},
async willSendResponse({ response }) {
const operationType = request.query?.includes('mutation')
? 'mutation'
: request.query?.includes('subscription')
? 'subscription'
: 'query';
const status = response.body?.singleResult?.errors ? 'error' : 'success';
timer({
operation: request.operationName || 'anonymous',
operationType,
status,
});
},
async didEncounterErrors({ errors }) {
errors.forEach((error) => {
graphqlErrors.inc({
code: error.extensions?.code || 'UNKNOWN',
operation: request.operationName || 'anonymous',
});
});
},
};
},
};
// メトリクスエンドポイント
app.get('/metrics', async (req, res) => {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
});10.2 分散トレーシング
// OpenTelemetry による分散トレーシング
import { trace, SpanStatusCode } from '@opentelemetry/api';
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
import { GraphQLInstrumentation } from '@opentelemetry/instrumentation-graphql';
import { JaegerExporter } from '@opentelemetry/exporter-jaeger';
// トレーサープロバイダーの設定
const provider = new NodeTracerProvider();
provider.addSpanProcessor(
new BatchSpanProcessor(
new JaegerExporter({
endpoint: 'http://jaeger:14268/api/traces',
})
)
);
provider.register();
// GraphQL 自動計装
const graphqlInstrumentation = new GraphQLInstrumentation({
mergeItems: true,
allowValues: process.env.NODE_ENV !== 'production',
depth: 5,
});
graphqlInstrumentation.setTracerProvider(provider);
graphqlInstrumentation.enable();
// カスタムスパンの追加
const tracer = trace.getTracer('graphql-api');
const resolvers = {
Query: {
users: async (_, args, context) => {
return tracer.startActiveSpan('fetchUsers', async (span) => {
try {
span.setAttribute('user.filter', JSON.stringify(args.filter));
span.setAttribute('user.first', args.first);
const result = await context.dataSources.userAPI.getUsers(args);
span.setAttribute('user.count', result.edges.length);
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (error) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message,
});
span.recordException(error);
throw error;
} finally {
span.end();
}
});
},
},
};FAQ
Q1: GraphQL Subscriptionとポーリングの使い分けは?
A: リアルタイム性の要件とコスト、実装複雑度のバランスで判断する。
Subscriptionを選ぶべきケース:
- チャットアプリ、コラボレーションツールなど、低レイテンシーが必須
- サーバー側でイベント駆動の更新が多い(1秒間に複数回の更新)
- 多数のクライアントが同じデータを購読している(PubSubで効率的に配信可能)
- WebSocketインフラが整備されている
ポーリングを選ぶべきケース:
- 更新頻度が低い(数分〜数十分に1回程度)
- 既存のHTTP/RESTインフラを活用したい
- WebSocket接続の維持コストを避けたい(モバイルアプリのバッテリー消費など)
- ファイアウォール環境でWebSocketが使えない
ハイブリッド戦略:
// 優先度の高い更新はSubscription、それ以外はポーリング
const CRITICAL_SUBSCRIPTIONS = ['newMessage', 'orderStatusUpdate'];
const POLLING_QUERIES = ['unreadCount', 'notifications'];
// Subscription(リアルタイム)
useSubscription(NEW_MESSAGE_SUBSCRIPTION, {
onData: ({ data }) => updateUI(data),
});
// ポーリング(30秒ごと)
useQuery(UNREAD_COUNT_QUERY, {
pollInterval: 30000,
});Q2: GraphQLのスキーマ設計でRelay仕様に準拠すべきか?
A: プロジェクトの規模と将来の拡張性を考慮して判断する。
Relay仕様に準拠すべきケース:
- 大規模なアプリケーション(数百以上のエンティティ)
- ページネーションを多用する
- クライアント側で正規化キャッシュを活用したい(Apollo Client、Relay)
- スキーマの一貫性を保ちたい
Relay準拠のメリット:
# Relay Connection仕様
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# Node Interface(グローバルID)
interface Node {
id: ID! # グローバルに一意なID(例: "VXNlcjox")
}
type User implements Node {
id: ID!
name: String!
}Relay準拠のデメリット:
- 初期実装コストが高い
- シンプルなケースでも冗長なスキーマになる
- カーソルベースのページネーションが不要な場合はオーバーエンジニアリング
代替案:
# シンプルなページネーション(小規模アプリ向け)
type UserPage {
users: [User!]!
total: Int!
hasMore: Boolean!
}
type Query {
users(page: Int!, limit: Int!): UserPage!
}推奨アプローチ:
- 新規プロジェクト(中規模以上): Relay仕様を採用(将来の拡張性を確保)
- 既存プロジェクトの小規模な機能追加: 既存のパターンを踏襲
- プロトタイプ/MVP: シンプルなページネーションで開始、必要になったら移行
Q3: GraphQLのキャッシュ戦略はRESTと比べてどう異なるか?
A: GraphQLは正規化キャッシュを活用し、エンティティレベルでキャッシュ管理を行う点がRESTと大きく異なる。
RESTのキャッシュ戦略:
エンドポイント単位のキャッシュ:
GET /api/users/123 → Cache-Control: max-age=3600
GET /api/users/123/posts → Cache-Control: max-age=1800
問題点:
- 同じユーザーデータが複数エンドポイントで重複してキャッシュされる
- 部分的な更新が困難(user.name だけ変更されても全体を再取得)
- キャッシュ無効化が粗い(user が更新されたら全エンドポイントを invalidate)
GraphQLのキャッシュ戦略:
// 正規化キャッシュ(Apollo Client)
const cache = new InMemoryCache({
typePolicies: {
User: {
keyFields: ['id'], // キャッシュキー
fields: {
posts: {
merge(existing = [], incoming) {
return [...existing, ...incoming];
},
},
},
},
},
});
// クエリ1
query GetUser {
user(id: "123") {
id
name
email
}
}
// クエリ2
query GetUserPosts {
user(id: "123") {
id
name # キャッシュから取得(リクエストしない)
posts { title }
}
}
// Mutation後の自動キャッシュ更新
mutation UpdateUserName {
updateUser(id: "123", name: "New Name") {
id
name # キャッシュ内の user:123 の name が自動更新される
}
}GraphQL特有のキャッシュレイヤー:
┌─────────────────────────────────────┐
│ 1. クライアント正規化キャッシュ │ Apollo Client InMemoryCache
│ (User:123, Post:456) │ → エンティティ単位でキャッシュ
└─────────────────────────────────────┘↓┌─────────────────────────────────────┐
│ 2. Persisted Queries │ クエリハッシュでキャッシュ
│ (sha256: abc123 → クエリ文字列) │ → CDN/サーバーで活用
└─────────────────────────────────────┘↓┌─────────────────────────────────────┐
│ 3. サーバーサイドキャッシュ (Redis) │ リゾルバー結果のキャッシュ
│ user:123 → { id, name, email } │ → DataLoader + Redis
└─────────────────────────────────────┘↓┌─────────────────────────────────────┐
│ 4. データベースクエリキャッシュ │ DB層のキャッシュ
└─────────────────────────────────────┘
キャッシュ無効化の違い:
// REST: エンドポイント単位で無効化
cache.invalidate('/api/users/123');
cache.invalidate('/api/users/123/posts');
// GraphQL: エンティティ単位で無効化
cache.evict({ id: 'User:123' }); // User:123に関連する全クエリが無効化
cache.gc(); // 孤立したキャッシュエントリを削除
// または refetchQueries で特定クエリを再取得
await updateUser({
refetchQueries: [{ query: GET_USER, variables: { id: '123' } }],
});まとめ:
| 項目 | REST | GraphQL |
|---|---|---|
| キャッシュ単位 | エンドポイント | エンティティ(型+ID) |
| 重複データ | 多い | 正規化により最小化 |
| 部分更新 | 困難 | 自動的にキャッシュマージ |
| 無効化の粒度 | 粗い | 細かい(フィールド単位も可能) |
| CDN活用 | 容易 | Persisted Queriesで可能 |
まとめ
| 概念 | ポイント |
|---|---|
| Subscription | WebSocket + PubSub でリアルタイム、Redis PubSubでスケーリング |
| DataLoader | バッチ処理でN+1問題を解消、リクエストごとにインスタンス作成 |
| キャッシュ | 正規化キャッシュ + Persisted Queries + サーバーサイドRedis |
| エラーハンドリング | トップレベルエラー vs Payload内エラー、Result型パターン |
| セキュリティ | 深さ制限、コスト制限、レート制限、イントロスペクション無効化 |
| スキーマ設計 | Interface、Union、Relay Connection、カスタムディレクティブ |
| Federation | マイクロサービス統合、Entity解決、Gateway |
| パフォーマンス | リゾルバー計測、@defer、クエリプラン最適化 |
| テスト | 単体テスト、統合テスト、スキーマテスト |
| モニタリング | Prometheus メトリクス、OpenTelemetry トレーシング |
次に読むべきガイド
- REST vs GraphQL -- REST vs GraphQL
参考文献
- Apollo. "Production Readiness Checklist." apollographql.com, 2024.
- Facebook. "DataLoader." github.com/graphql/dataloader, 2024.
- Relay. "Relay Specification." relay.dev, 2024.
- Apollo. "Apollo Federation." apollographql.com/docs/federation, 2024.
- GraphQL Foundation. "GraphQL Specification." spec.graphql.org, 2024.
- Marc-Andre Giroux. "Production Ready GraphQL." book.productionreadygraphql.com, 2024.
- OpenTelemetry. "GraphQL Instrumentation." opentelemetry.io, 2024.
- Lee Byron et al. "GraphQL Subscriptions." github.com/graphql/graphql-spec, 2024.