GraphQL基礎
GraphQLはFacebookが開発したクエリ言語。スキーマ駆動開発、型システム、Query/Mutation/Subscription、リゾルバーの仕組みを理解し、REST APIとは異なるアプローチでのAPI設計を習得する。
GraphQL基礎
GraphQLはFacebookが開発したクエリ言語。スキーマ駆動開発、型システム、Query/Mutation/Subscription、リゾルバーの仕組みを理解し、REST APIとは異なるアプローチでのAPI設計を習得する。
この章で学ぶこと
- GraphQLの型システムとスキーマ定義を理解する
- Query・Mutation・Subscriptionの使い分けを把握する
- リゾルバーの実装パターンを学ぶ
- Apollo Serverの構築と運用手法を身につける
- N+1問題とDataLoaderによる最適化を理解する
- エラーハンドリングと認証・認可パターンを習得する
前提知識
- REST APIの基本概念 → 参照: REST Best Practices
- HTTPリクエスト/レスポンスの仕組み → 参照: HTTPの基礎
- JSONデータ構造の理解
- 型システムの基礎知識(TypeScriptやJavaの型概念があると望ましい)
1. GraphQLとは
1.1 概要と歴史
GraphQLは2012年にFacebook社内でモバイルアプリ向けのデータ取得基盤として開発された。2015年にオープンソースとして公開され、2019年にはLinux Foundation傘下のGraphQL Foundationに移管された。
GraphQL = Graph Query Language
→ API のためのクエリ言語 + 型システム + ランタイム
→ Facebook が 2012年に内部開発、2015年に公開、2019年にGraphQL Foundation設立
歴史年表:
2012 Facebook内部で開発開始(モバイルニュースフィード向け)
2015 React.jsカンファレンスで公開、仕様のオープンソース化
2016 GitHub API v4がGraphQLを採用(大規模事例の先駆け)
2017 Apollo, Relay Modern, Prismaなどエコシステムが拡大
2018 GraphQL仕様にSubscriptionが正式追加
2019 GraphQL Foundation設立(Linux Foundation傘下)
2021 @defer, @streamディレクティブの仕様策定開始
2023 GraphQL仕様 October 2021 Editionの安定リリース
2024 Composite Schema(Federation統一仕様)のRFC進行中
1.2 GraphQLの3つの柱
+-------------------------------------------------------------------+
| GraphQLの3つの柱 |
+-------------------------------------------------------------------+
| |
| [1] クエリ言語 [2] 型システム [3] ランタイム |
| (Query Language) (Type System) (Execution Engine) |
| |
| クライアントが スキーマで リクエストを |
| 欲しいデータを APIの形を 解析・検証し |
| 宣言的に記述 厳密に定義 結果を返す |
| |
| ・Query ・Scalar型 ・パース |
| ・Mutation ・Object型 ・バリデーション |
| ・Subscription ・Enum型 ・実行(リゾルバー) |
| ・Fragment ・Interface/Union ・シリアライズ |
| ・Variable ・Input型 ・エラーハンドリング |
| |
+-------------------------------------------------------------------+
1.3 RESTとの比較
REST vs GraphQL(イメージ):
REST:
GET /users/123 → { id, name, email, address, ... }
GET /users/123/orders → [{ id, total, items, ... }]
GET /orders/456/items → [{ id, product, price, ... }]
→ 3リクエスト、不要なデータも含む
GraphQL:
POST /graphql
query {
user(id: "123") {
name
orders(first: 5) {
total
items { productName, price }
}
}
}
→ 1リクエスト、必要なデータのみ
比較表1: REST vs GraphQL 機能比較
| 観点 | REST | GraphQL |
|---|---|---|
| エンドポイント | リソースごとに複数 (/users, /orders) |
単一 (/graphql) |
| データ取得量 | サーバーが決定(Over-fetching発生) | クライアントが必要なフィールドを指定 |
| 型システム | OpenAPI/Swaggerで別途定義 | スキーマに内蔵(SDL) |
| バージョニング | URLパス (/v1/, /v2/) が一般的 |
スキーマ進化(deprecated + 新フィールド追加) |
| キャッシュ | HTTPキャッシュヘッダーで容易 | 専用キャッシュ戦略が必要(Apollo Cacheなど) |
| リアルタイム | WebSocket / SSE を別途実装 | Subscriptionで標準サポート |
| 学習コスト | 広く知られており低い | SDL・リゾルバーなど独自概念の学習が必要 |
| ファイルアップロード | multipart/form-data で標準対応 | 仕様外(Apollo Upload等で拡張) |
| エラーハンドリング | HTTPステータスコード | 常に200、errorsフィールドで表現 |
| ドキュメント | Swagger UI等で生成 | GraphiQL/Playground等で自動生成+対話的実行 |
| ネストデータ | 複数リクエストまたはInclude指定 | 1リクエストで任意の深さまで取得可能 |
比較表2: ユースケース別適性
| ユースケース | REST推奨度 | GraphQL推奨度 | 理由 |
|---|---|---|---|
| CRUDが中心のシンプルAPI | ★★★★★ | ★★★☆☆ | RESTの方がシンプルで十分 |
| モバイルアプリ向けBFF | ★★☆☆☆ | ★★★★★ | 帯域節約・1リクエストが大きい利点 |
| マイクロサービス集約 | ★★★☆☆ | ★★★★★ | Federation/Stitchingで統合容易 |
| 外部公開API | ★★★★★ | ★★★☆☆ | REST+OpenAPIの方が汎用的 |
| ダッシュボード/管理画面 | ★★★☆☆ | ★★★★★ | 複雑なデータ要件に柔軟対応 |
| IoT/組み込み | ★★★★★ | ★★☆☆☆ | HTTP GETの方が軽量 |
| リアルタイム通知 | ★★★☆☆ | ★★★★☆ | Subscriptionで標準サポート |
| ファイル配信/ストリーム | ★★★★★ | ★☆☆☆☆ | GraphQLはJSONデータ向け |
1.4 GraphQLのリクエスト/レスポンスフロー
| ─────────────────────→ | GraphQL Server | |
|---|---|---|
| Client | { | |
| (Browser/ | query: "...", | 1. Parse (構文解析) |
| Mobile) | variables: {} | ↓ |
| } | 2. Validate (検証) | |
| - 型チェック | ||
| - フィールド存在確認 | ||
| ↓ | ||
| { | 3. Execute (実行) | |
| data: {...}, | - リゾルバー呼び出し | |
| ←───────────────────── | - データソースアクセス | |
| errors: [...] | ↓ | |
| } | 4. Serialize (直列化) |
↕| DataSources |
|---|
| - Database |
| - REST API |
| - gRPC |
| - Cache |
2. スキーマ定義(SDL)
2.1 スカラー型
GraphQLには5つの組み込みスカラー型がある。
# 組み込みスカラー型
# Int : 符号付き32ビット整数
# Float : 倍精度浮動小数点数
# String : UTF-8文字列
# Boolean : true / false
# ID : 一意識別子(内部的にはString)
# カスタムスカラー型の定義
scalar DateTime # ISO 8601形式の日時文字列
scalar Email # メールアドレス形式の文字列
scalar URL # URL形式の文字列
scalar JSON # 任意のJSONオブジェクト
scalar BigInt # 64ビット整数(Int範囲を超える場合)
scalar Void # 戻り値なし(副作用のみのMutationに使用)2.2 オブジェクト型と型修飾子
# 型修飾子の組み合わせと意味
#
# String → null許容文字列(値はnullまたはString)
# String! → 非null文字列(値は必ずString)
# [String] → null許容の配列(配列自体がnull、要素もnull可)
# [String]! → 非nullの配列(配列自体は非null、要素はnull可)
# [String!] → null許容の配列(配列自体はnull可、要素は非null)
# [String!]!→ 非nullの配列(配列自体も要素も非null)
# ┌──────────────────────────────────────────────────────┐
# │ 型修飾子の許容パターン一覧 │
# ├───────────────┬──────────────────────────────────────┤
# │ 宣言 │ 許容される値 │
# ├───────────────┼──────────────────────────────────────┤
# │ String │ null, "hello" │
# │ String! │ "hello" │
# │ [String] │ null, [], [null], ["a", null] │
# │ [String]! │ [], [null], ["a", null] │
# │ [String!] │ null, [], ["a", "b"] │
# │ [String!]! │ [], ["a", "b"] │
# └───────────────┴──────────────────────────────────────┘2.3 列挙型
# 列挙型
enum UserRole {
USER
ADMIN
EDITOR
MODERATOR
}
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
REFUNDED
}
# 列挙型はフィルタリング、バリデーション、ドキュメント化に有用
# 実行時にスキーマに存在しない値が渡されるとバリデーションエラーになる2.4 オブジェクト型の定義
# オブジェクト型
type User {
id: ID! # !は非null
name: String!
email: Email!
role: UserRole!
avatar: String # nullable
bio: String
createdAt: DateTime!
updatedAt: DateTime!
orders: [Order!]! # 非nullの配列(配列自体も非null)
orderCount: Int!
posts: [Post!]!
followers: [User!]!
following: [User!]!
}
type Order {
id: ID!
user: User!
status: OrderStatus!
total: Int! # 金額(円)
items: [OrderItem!]!
shippingAddress: Address
note: String
createdAt: DateTime!
updatedAt: DateTime!
}
type OrderItem {
id: ID!
order: Order!
product: Product!
quantity: Int!
unitPrice: Int!
subtotal: Int! # quantity * unitPrice(計算フィールド)
}
type Product {
id: ID!
name: String!
price: Int!
description: String
category: Category!
tags: [String!]!
imageUrl: URL
stock: Int!
isAvailable: Boolean!
}
type Category {
id: ID!
name: String!
slug: String!
parent: Category # 再帰的な型参照(親カテゴリ)
children: [Category!]!
products: [Product!]!
}
type Address {
postalCode: String!
prefecture: String!
city: String!
street: String!
building: String
}
type Post {
id: ID!
author: User!
title: String!
body: String!
tags: [String!]!
publishedAt: DateTime
createdAt: DateTime!
}2.5 入力型(Input Types)
# 入力型(Mutation の引数に使用)
# input型とtype型の違い:
# - input型はMutation/Queryの引数にのみ使用可能
# - input型のフィールドにはtype型を含められない(inputのみ)
# - input型にはリゾルバーを定義できない
input CreateUserInput {
name: String!
email: Email!
role: UserRole = USER # デフォルト値
bio: String
avatar: String
}
input UpdateUserInput {
name: String
email: Email
role: UserRole
bio: String
avatar: String
}
input CreateOrderInput {
items: [OrderItemInput!]!
shippingAddress: AddressInput!
note: String
}
input OrderItemInput {
productId: ID!
quantity: Int!
}
input AddressInput {
postalCode: String!
prefecture: String!
city: String!
street: String!
building: String
}2.6 Interface と Union
# Interface: 共通フィールドを持つ型の抽象化
interface Node {
id: ID!
}
interface Timestamped {
createdAt: DateTime!
updatedAt: DateTime!
}
type User implements Node & Timestamped {
id: ID!
name: String!
email: Email!
createdAt: DateTime!
updatedAt: DateTime!
}
type Post implements Node & Timestamped {
id: ID!
title: String!
body: String!
createdAt: DateTime!
updatedAt: DateTime!
}
# Union: 共通フィールドを持たない型の集合
union SearchResult = User | Post | Product
type Query {
search(query: String!): [SearchResult!]!
node(id: ID!): Node # Relay Global Object Identificationパターン
}
# Unionのクエリ例
# query {
# search(query: "GraphQL") {
# ... on User { name, email }
# ... on Post { title, body }
# ... on Product { name, price }
# }
# }2.7 ディレクティブ
# 組み込みディレクティブ
# @skip(if: Boolean!) - trueの場合そのフィールドを除外
# @include(if: Boolean!) - trueの場合そのフィールドを含める
# @deprecated(reason: String) - フィールドの非推奨化
type User {
id: ID!
name: String!
email: Email!
username: String @deprecated(reason: "Use 'name' instead.")
}
# クエリでのディレクティブ使用
# query GetUser($id: ID!, $includeOrders: Boolean!) {
# user(id: $id) {
# name
# email
# orders @include(if: $includeOrders) {
# id
# total
# }
# }
# }
# カスタムディレクティブの定義(サーバー側で実装が必要)
directive @auth(requires: UserRole!) on FIELD_DEFINITION
directive @cacheControl(maxAge: Int!) on FIELD_DEFINITION
directive @rateLimit(max: Int!, window: String!) on FIELD_DEFINITION
type Query {
users: [User!]! @auth(requires: ADMIN) @rateLimit(max: 100, window: "1m")
publicPosts: [Post!]! @cacheControl(maxAge: 300)
}2.8 ページネーション用型(Relay Connection仕様)
# Relay Connection仕様に基づくページネーション
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type UserEdge {
node: User!
cursor: String!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type OrderEdge {
node: Order!
cursor: String!
}
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
# Cursor vs Offset ページネーション比較:
#
# Offset方式: users(offset: 20, limit: 10)
# 利点: 実装が簡単、任意のページにジャンプ可能
# 欠点: データ挿入/削除時にずれが発生、大きなオフセットはDB負荷大
#
# Cursor方式: users(first: 10, after: "abc123")
# 利点: データ変更に強い、一貫した結果、インデックス利用で高速
# 欠点: 任意ページジャンプ不可、実装がやや複雑3. Query(データ取得)
3.1 Query型の定義
# スキーマ定義
type Query {
# 単一リソース
user(id: ID!): User
order(id: ID!): Order
product(id: ID!): Product
# コレクション(Cursor ページネーション)
users(
first: Int
after: String
last: Int
before: String
filter: UserFilter
sort: UserSort
): UserConnection!
orders(
first: Int
after: String
filter: OrderFilter
): OrderConnection!
# 検索
searchUsers(query: String!, limit: Int = 10): [User!]!
search(query: String!, types: [SearchType!]): [SearchResult!]!
# 集計
userStats: UserStats!
orderStats(period: StatPeriod!): OrderStats!
# ヘルスチェック
health: HealthStatus!
# 現在のログインユーザー
me: User
}
input UserFilter {
role: UserRole
createdAfter: DateTime
createdBefore: DateTime
nameContains: String
}
input OrderFilter {
status: OrderStatus
minTotal: Int
maxTotal: Int
userId: ID
}
enum UserSort {
CREATED_AT_ASC
CREATED_AT_DESC
NAME_ASC
NAME_DESC
}
enum SearchType {
USER
POST
PRODUCT
}
enum StatPeriod {
TODAY
THIS_WEEK
THIS_MONTH
THIS_YEAR
}
type UserStats {
totalUsers: Int!
activeUsers: Int!
newUsersToday: Int!
roleDistribution: [RoleCount!]!
}
type RoleCount {
role: UserRole!
count: Int!
}
type OrderStats {
totalOrders: Int!
totalRevenue: Int!
averageOrderValue: Float!
statusDistribution: [StatusCount!]!
}
type StatusCount {
status: OrderStatus!
count: Int!
}
type HealthStatus {
status: String!
uptime: Float!
version: String!
}3.2 クエリの書き方
# クライアントからのクエリ例
# 基本的なクエリ
query GetUser {
user(id: "123") {
id
name
email
role
}
}
# ネストされたクエリ
query GetUserWithOrders {
user(id: "123") {
name
orders(first: 5) {
edges {
node {
id
status
total
items {
product {
name
price
}
quantity
subtotal
}
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
}
# 変数を使ったクエリ
query GetUsers($first: Int!, $after: String, $role: UserRole) {
users(first: $first, after: $after, filter: { role: $role }) {
edges {
node {
id
name
email
role
createdAt
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
# 変数: { "first": 20, "after": null, "role": "ADMIN" }
# エイリアス(同じフィールドを異なる引数で取得)
query CompareUsers {
admin: user(id: "1") { name, role, orderCount }
editor: user(id: "2") { name, role, orderCount }
}
# フラグメント(共通フィールドの再利用)
fragment UserBasic on User {
id
name
email
role
}
fragment OrderSummary on Order {
id
status
total
createdAt
}
query GetMultipleUsers {
user1: user(id: "1") {
...UserBasic
orderCount
orders(first: 3) {
edges {
node { ...OrderSummary }
}
}
}
user2: user(id: "2") {
...UserBasic
orderCount
orders(first: 3) {
edges {
node { ...OrderSummary }
}
}
}
}3.3 インラインフラグメントとUnion型のクエリ
# Union型に対するインラインフラグメント
query SearchAll($q: String!) {
search(query: $q) {
... on User {
__typename
id
name
email
}
... on Post {
__typename
id
title
body
}
... on Product {
__typename
id
name
price
}
}
}
# __typename はオブジェクトの型名を返す特殊フィールド
# レスポンス例:
# {
# "data": {
# "search": [
# { "__typename": "User", "id": "1", "name": "Taro", "email": "..." },
# { "__typename": "Post", "id": "10", "title": "GraphQL入門", "body": "..." },
# { "__typename": "Product", "id": "100", "name": "GraphQL本", "price": 3000 }
# ]
# }
# }4. Mutation(データ変更)
4.1 Mutation型の定義
# スキーマ定義
type Mutation {
# ユーザー
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
# ユーザー認証
signUp(input: SignUpInput!): AuthPayload!
signIn(email: Email!, password: String!): AuthPayload!
refreshToken(token: String!): AuthPayload!
# 注文
createOrder(input: CreateOrderInput!): CreateOrderPayload!
updateOrderStatus(id: ID!, status: OrderStatus!): UpdateOrderPayload!
cancelOrder(id: ID!): CancelOrderPayload!
# 商品
createProduct(input: CreateProductInput!): CreateProductPayload!
updateProduct(id: ID!, input: UpdateProductInput!): UpdateProductPayload!
deleteProduct(id: ID!): DeleteProductPayload!
}
# Payload パターン(成功/エラーを表現)
type CreateUserPayload {
user: User
errors: [UserError!]!
}
type UpdateUserPayload {
user: User
errors: [UserError!]!
}
type DeleteUserPayload {
deletedId: ID
errors: [UserError!]!
}
type AuthPayload {
token: String
user: User
errors: [UserError!]!
}
type CreateOrderPayload {
order: Order
errors: [UserError!]!
}
type UpdateOrderPayload {
order: Order
errors: [UserError!]!
}
type CancelOrderPayload {
order: Order
errors: [UserError!]!
}
# エラー型
type UserError {
field: String # エラーが発生したフィールド名
message: String! # 人間が読めるメッセージ
code: ErrorCode! # 機械処理用のエラーコード
}
enum ErrorCode {
NOT_FOUND
VALIDATION_ERROR
ALREADY_EXISTS
UNAUTHORIZED
FORBIDDEN
INTERNAL_ERROR
RATE_LIMITED
INVALID_INPUT
}
input SignUpInput {
name: String!
email: Email!
password: String!
}
input CreateProductInput {
name: String!
price: Int!
description: String
categoryId: ID!
tags: [String!]
imageUrl: URL
stock: Int!
}
input UpdateProductInput {
name: String
price: Int
description: String
categoryId: ID
tags: [String!]
imageUrl: URL
stock: Int
}4.2 Mutationの実行例
# ユーザー作成
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
user {
id
name
email
role
createdAt
}
errors {
field
message
code
}
}
}
# 変数: { "input": { "name": "Taro", "email": "taro@example.com" } }
# 成功レスポンス:
# {
# "data": {
# "createUser": {
# "user": {
# "id": "456",
# "name": "Taro",
# "email": "taro@example.com",
# "role": "USER",
# "createdAt": "2024-01-15T10:30:00Z"
# },
# "errors": []
# }
# }
# }
# エラーレスポンス:
# {
# "data": {
# "createUser": {
# "user": null,
# "errors": [
# {
# "field": "email",
# "message": "Email already exists",
# "code": "ALREADY_EXISTS"
# }
# ]
# }
# }
# }# 認証(サインイン)
mutation SignIn($email: Email!, $password: String!) {
signIn(email: $email, password: $password) {
token
user {
id
name
role
}
errors {
message
code
}
}
}
# 注文作成
mutation CreateOrder($input: CreateOrderInput!) {
createOrder(input: $input) {
order {
id
status
total
items {
product { name }
quantity
unitPrice
subtotal
}
}
errors {
field
message
code
}
}
}
# 変数:
# {
# "input": {
# "items": [
# { "productId": "prod-1", "quantity": 2 },
# { "productId": "prod-2", "quantity": 1 }
# ],
# "shippingAddress": {
# "postalCode": "100-0001",
# "prefecture": "東京都",
# "city": "千代田区",
# "street": "丸の内1-1-1"
# }
# }
# }4.3 Mutationの設計原則
Mutation設計の5原則:
1. 入力はInput型にまとめる
✗ createUser(name: String!, email: String!, role: UserRole!)
○ createUser(input: CreateUserInput!)
→ 引数追加時にクエリを変更せずInput型の拡張だけで済む
2. 戻り値はPayload型で統一する
✗ createUser(input: ...): User! ← エラー情報なし
○ createUser(input: ...): CreateUserPayload!
→ 成功時のデータとエラー情報を同一レスポンスで返す
3. 冪等性を意識する
→ 同じMutationを複数回実行しても結果が同じ
→ クライアントIDやリクエストIDで重複排除
4. 命名は動詞 + 名詞
○ createUser, updateOrder, cancelSubscription
✗ userCreate, orderUpdate
5. 1つのMutationで1つの操作
✗ updateUserAndCreateOrder(...)
○ updateUser(...) + createOrder(...) を別々に
5. Subscription(リアルタイム通知)
5.1 Subscriptionの仕組み
| Subscription のフロー | |||
|---|---|---|---|
| Client Server PubSub | |||
| subscription { | |||
| orderUpdated(userId) | |||
| } | |||
| ───WebSocket接続───→ | |||
| subscribe(topic) → | |||
| ... 待機中 ... | |||
| ← publish(topic, | |||
| payload) | |||
| ← { data: { | |||
| orderUpdated: { | |||
| id, status | |||
| } | |||
| } | |||
| } | |||
| ← publish(...) | |||
| ← { data: {...} } | |||
| unsubscribe | |||
| ───WebSocket切断───→ | |||
5.2 Subscription型の定義
type Subscription {
# 注文ステータスの変更を購読
orderStatusChanged(userId: ID!): OrderStatusEvent!
# 新しいメッセージの購読(チャット機能)
messageSent(channelId: ID!): Message!
# 商品在庫の変更
stockUpdated(productId: ID!): StockEvent!
# 全体通知
notificationReceived(userId: ID!): Notification!
}
type OrderStatusEvent {
order: Order!
previousStatus: OrderStatus!
newStatus: OrderStatus!
changedAt: DateTime!
}
type Message {
id: ID!
sender: User!
content: String!
sentAt: DateTime!
}
type StockEvent {
product: Product!
previousStock: Int!
newStock: Int!
changedAt: DateTime!
}
type Notification {
id: ID!
type: NotificationType!
title: String!
message: String!
createdAt: DateTime!
}
enum NotificationType {
ORDER_UPDATE
PROMOTION
SYSTEM
MENTION
}5.3 Subscriptionリゾルバーの実装
// PubSubを使ったSubscriptionリゾルバー
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
// イベント名の定数
const EVENTS = {
ORDER_STATUS_CHANGED: 'ORDER_STATUS_CHANGED',
MESSAGE_SENT: 'MESSAGE_SENT',
STOCK_UPDATED: 'STOCK_UPDATED',
NOTIFICATION: 'NOTIFICATION',
};
const resolvers = {
Subscription: {
orderStatusChanged: {
// subscribe関数がAsyncIteratorを返す
subscribe: (_, { userId }) => {
return pubsub.asyncIterator(
`${EVENTS.ORDER_STATUS_CHANGED}.${userId}`
);
},
// resolve関数でペイロードを変換(オプション)
resolve: (payload) => payload,
},
messageSent: {
subscribe: (_, { channelId }, context) => {
// 認証チェック
if (!context.user) {
throw new Error('Authentication required');
}
return pubsub.asyncIterator(
`${EVENTS.MESSAGE_SENT}.${channelId}`
);
},
},
notificationReceived: {
subscribe: (_, { userId }, context) => {
if (context.user?.id !== userId) {
throw new Error('Cannot subscribe to other user notifications');
}
return pubsub.asyncIterator(
`${EVENTS.NOTIFICATION}.${userId}`
);
},
},
},
Mutation: {
updateOrderStatus: async (_, { id, status }, context) => {
const order = await context.dataSources.orderAPI.updateStatus(id, status);
// Subscriptionにイベントを発行
pubsub.publish(`${EVENTS.ORDER_STATUS_CHANGED}.${order.userId}`, {
orderStatusChanged: {
order,
previousStatus: order.previousStatus,
newStatus: status,
changedAt: new Date().toISOString(),
},
});
return { order, errors: [] };
},
},
};5.4 クライアントでのSubscription利用
// Apollo Client でのSubscription利用
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: 'http://localhost:4000/graphql',
});
// WebSocket接続(Subscription用)
const wsLink = new GraphQLWsLink(
createClient({
url: 'ws://localhost:4000/graphql',
connectionParams: {
authToken: localStorage.getItem('token'),
},
})
);
// オペレーションタイプに応じてリンクを切り替え
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink, // Subscriptionの場合
httpLink // Query/Mutationの場合
);
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
});
// Reactコンポーネントでの使用
import { useSubscription, gql } from '@apollo/client';
const ORDER_STATUS_SUBSCRIPTION = gql`
subscription OnOrderStatusChanged($userId: ID!) {
orderStatusChanged(userId: $userId) {
order {
id
status
total
}
previousStatus
newStatus
changedAt
}
}
`;
function OrderTracker({ userId }) {
const { data, loading, error } = useSubscription(
ORDER_STATUS_SUBSCRIPTION,
{ variables: { userId } }
);
if (loading) return <p>注文状態を監視中...</p>;
if (error) return <p>接続エラー: {error.message}</p>;
if (data) {
const { order, previousStatus, newStatus } = data.orderStatusChanged;
return (
<div>
<p>注文 #{order.id} のステータスが変更されました</p>
<p>{previousStatus} → {newStatus}</p>
</div>
);
}
return <p>更新待ち...</p>;
}6. リゾルバー実装
6.1 リゾルバーの基本構造
リゾルバーの4つの引数:
resolver(parent, args, context, info)
parent : 親フィールドのリゾルバーが返した値
(ルートリゾルバーではundefined)
args : クエリで渡された引数
context : リクエスト全体で共有されるオブジェクト
(認証情報、DataSource、DataLoaderなど)
info : クエリのAST情報
(フィールド名、パス、選択セットなど)| リゾルバーチェーン(実行順序) |
|---|
| query { |
| user(id: "1") { ← Query.user リゾルバー |
| name ← デフォルトリゾルバー |
| orders { ← User.orders リゾルバー |
| items { ← Order.items リゾルバー |
| product { ← OrderItem.product リゾルバー |
| name ← デフォルトリゾルバー |
| } |
| } |
| } |
| } |
| } |
| デフォルトリゾルバー: |
| parent[fieldName] を返す |
| → parentオブジェクトに同名プロパティがあれば自動解決 |
6.2 リゾルバーの実装
// Apollo Server でのリゾルバー実装
import { GraphQLScalarType, Kind } from 'graphql';
const resolvers = {
// === ルートリゾルバー ===
Query: {
// 単一ユーザー取得
user: async (parent, { id }, context) => {
// 認証チェック
if (!context.user) {
throw new AuthenticationError('ログインが必要です');
}
const user = await context.dataSources.userAPI.getUser(id);
if (!user) return null;
return user;
},
// ユーザー一覧(Cursorページネーション)
users: async (parent, { first = 20, after, filter, sort }, context) => {
const { nodes, totalCount, hasNextPage, hasPreviousPage } =
await context.dataSources.userAPI.listUsers({
first,
after,
filter,
sort,
});
const edges = nodes.map((node) => ({
node,
cursor: Buffer.from(`cursor:${node.id}`).toString('base64'),
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage,
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
totalCount,
};
},
// 検索
search: async (parent, { query, types }, context) => {
const results = [];
if (!types || types.includes('USER')) {
const users = await context.dataSources.userAPI.search(query);
results.push(...users);
}
if (!types || types.includes('POST')) {
const posts = await context.dataSources.postAPI.search(query);
results.push(...posts);
}
if (!types || types.includes('PRODUCT')) {
const products = await context.dataSources.productAPI.search(query);
results.push(...products);
}
return results;
},
// 現在のユーザー
me: (parent, args, context) => {
return context.user || null;
},
},
// === Mutationリゾルバー ===
Mutation: {
createUser: async (parent, { input }, context) => {
try {
// バリデーション
if (!input.name || input.name.trim().length === 0) {
return {
user: null,
errors: [{
field: 'name',
message: '名前は必須です',
code: 'VALIDATION_ERROR',
}],
};
}
const user = await context.dataSources.userAPI.createUser(input);
return { user, errors: [] };
} catch (error) {
if (error.code === 'DUPLICATE_EMAIL') {
return {
user: null,
errors: [{
field: 'email',
message: '既に登録済みのメールアドレスです',
code: 'ALREADY_EXISTS',
}],
};
}
return {
user: null,
errors: [{
field: null,
message: '予期しないエラーが発生しました',
code: 'INTERNAL_ERROR',
}],
};
}
},
updateUser: async (parent, { id, input }, context) => {
// 認可チェック(自分自身またはADMINのみ)
if (context.user.id !== id && context.user.role !== 'ADMIN') {
return {
user: null,
errors: [{
field: null,
message: '他のユーザーの情報を変更する権限がありません',
code: 'FORBIDDEN',
}],
};
}
try {
const user = await context.dataSources.userAPI.updateUser(id, input);
return { user, errors: [] };
} catch (error) {
return {
user: null,
errors: [{
field: error.field || null,
message: error.message,
code: error.code || 'INTERNAL_ERROR',
}],
};
}
},
deleteUser: async (parent, { id }, context) => {
if (context.user.role !== 'ADMIN') {
return {
deletedId: null,
errors: [{
field: null,
message: '管理者権限が必要です',
code: 'FORBIDDEN',
}],
};
}
await context.dataSources.userAPI.deleteUser(id);
return { deletedId: id, errors: [] };
},
},
// === フィールドレベルリゾルバー ===
User: {
// user.orders は別テーブルから取得
orders: async (user, { first = 10, after }, context) => {
return context.dataSources.orderAPI.getOrdersByUserId(
user.id, first, after
);
},
// 計算フィールド
orderCount: async (user, args, context) => {
return context.dataSources.orderAPI.countByUserId(user.id);
},
// フォロワー
followers: async (user, args, context) => {
return context.dataSources.userAPI.getFollowers(user.id);
},
},
Order: {
// 注文に紐づくユーザー
user: async (order, args, context) => {
return context.dataSources.userAPI.getUser(order.userId);
},
items: async (order, args, context) => {
return context.dataSources.orderAPI.getOrderItems(order.id);
},
},
OrderItem: {
product: async (item, args, context) => {
return context.dataSources.productAPI.getProduct(item.productId);
},
// 計算フィールド
subtotal: (item) => item.quantity * item.unitPrice,
},
// === Union型のリゾルバー ===
SearchResult: {
__resolveType(obj) {
// オブジェクトの型を判定
if (obj.email) return 'User';
if (obj.body) return 'Post';
if (obj.price !== undefined) return 'Product';
return null;
},
},
// === カスタムスカラー ===
DateTime: new GraphQLScalarType({
name: 'DateTime',
description: 'ISO 8601形式の日時文字列',
serialize(value) {
return value instanceof Date ? value.toISOString() : value;
},
parseValue(value) {
const date = new Date(value);
if (isNaN(date.getTime())) {
throw new Error('Invalid DateTime format');
}
return date;
},
parseLiteral(ast) {
if (ast.kind === Kind.STRING) {
const date = new Date(ast.value);
if (isNaN(date.getTime())) {
throw new Error('Invalid DateTime format');
}
return date;
}
return null;
},
}),
Email: new GraphQLScalarType({
name: 'Email',
description: 'メールアドレス形式の文字列',
serialize(value) {
return value;
},
parseValue(value) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
throw new Error('Invalid email format');
}
return value.toLowerCase();
},
parseLiteral(ast) {
if (ast.kind === Kind.STRING) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(ast.value)) {
throw new Error('Invalid email format');
}
return ast.value.toLowerCase();
}
return null;
},
}),
};7. Apollo Server セットアップ
7.1 基本セットアップ
// server.js - Apollo Server v4
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { expressMiddleware } from '@apollo/server/express4';
import { readFileSync } from 'fs';
import express from 'express';
import cors from 'cors';
import http from 'http';
// スキーマファイルの読み込み
const typeDefs = readFileSync('./schema.graphql', 'utf-8');
// サーバー作成
const server = new ApolloServer({
typeDefs,
resolvers,
// イントロスペクション(本番では無効推奨)
introspection: process.env.NODE_ENV !== 'production',
// プラグイン
plugins: [
// ランディングページ(開発環境でGraphQL Playgroundを表示)
process.env.NODE_ENV === 'production'
? ApolloServerPluginLandingPageDisabled()
: ApolloServerPluginLandingPageLocalDefault(),
// レスポンスキャッシュ
responseCachePlugin(),
// ログプラグイン
{
async requestDidStart(requestContext) {
const start = Date.now();
return {
async willSendResponse(ctx) {
const duration = Date.now() - start;
console.log(
`[GraphQL] ${ctx.operation?.operation} ` +
`${ctx.operation?.name?.value || 'anonymous'} ` +
`${duration}ms`
);
},
async didEncounterErrors(ctx) {
for (const err of ctx.errors) {
console.error('[GraphQL Error]', err.message, err.extensions);
}
},
};
},
},
],
// フォーマットエラー(本番ではスタックトレースを隠す)
formatError: (formattedError, error) => {
if (process.env.NODE_ENV === 'production') {
// 内部エラーの詳細を隠蔽
if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return {
message: 'Internal server error',
extensions: { code: 'INTERNAL_SERVER_ERROR' },
};
}
}
return formattedError;
},
});
// スタンドアロンモード(最もシンプル)
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => ({
// 認証
user: await authenticateUser(req.headers.authorization),
// データソース
dataSources: {
userAPI: new UserAPI(),
orderAPI: new OrderAPI(),
productAPI: new ProductAPI(),
},
}),
});
console.log(`GraphQL server ready at ${url}`);7.2 Express統合セットアップ
// express-server.js - Express + Apollo Server + WebSocket (Subscription対応)
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 { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import express from 'express';
import http from 'http';
import cors from 'cors';
import bodyParser from 'body-parser';
// スキーマ作成
const schema = makeExecutableSchema({ typeDefs, resolvers });
// Express + HTTPサーバー
const app = express();
const httpServer = http.createServer(app);
// WebSocketサーバー(Subscription用)
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
// WebSocketの終了処理を設定
const serverCleanup = useServer(
{
schema,
context: async (ctx) => {
// WebSocket接続時の認証
const token = ctx.connectionParams?.authToken;
const user = await authenticateToken(token);
return {
user,
dataSources: {
userAPI: new UserAPI(),
orderAPI: new OrderAPI(),
},
};
},
onConnect: async (ctx) => {
console.log('WebSocket client connected');
},
onDisconnect: (ctx) => {
console.log('WebSocket client disconnected');
},
},
wsServer
);
// Apollo Server
const server = new ApolloServer({
schema,
plugins: [
// HTTPサーバーの正常終了
ApolloServerPluginDrainHttpServer({ httpServer }),
// WebSocketサーバーの正常終了
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
},
};
},
},
],
});
await server.start();
// Expressミドルウェアとして設定
app.use(
'/graphql',
cors(),
bodyParser.json(),
expressMiddleware(server, {
context: async ({ req }) => ({
user: await authenticateUser(req.headers.authorization),
dataSources: {
userAPI: new UserAPI(),
orderAPI: new OrderAPI(),
},
}),
})
);
// ヘルスチェックエンドポイント(REST)
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
const PORT = process.env.PORT || 4000;
httpServer.listen(PORT, () => {
console.log(`Server ready at http://localhost:${PORT}/graphql`);
console.log(`Subscriptions ready at ws://localhost:${PORT}/graphql`);
});7.3 認証・認可パターン
// auth.js - 認証・認可ユーティリティ
import jwt from 'jsonwebtoken';
// JWTトークンからユーザーを取得
async function authenticateUser(authHeader) {
if (!authHeader) return null;
const token = authHeader.replace('Bearer ', '');
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await UserModel.findById(decoded.userId);
return user;
} catch (error) {
return null; // トークン無効でもnullを返す(エラーにしない)
}
}
// ディレクティブベースの認可
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver } from 'graphql';
function authDirectiveTransformer(schema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
if (authDirective) {
const { requires } = authDirective;
const originalResolver = fieldConfig.resolve || defaultFieldResolver;
fieldConfig.resolve = async (parent, args, context, info) => {
// 認証チェック
if (!context.user) {
throw new Error('認証が必要です');
}
// 認可チェック
if (requires && context.user.role !== requires) {
throw new Error(
`この操作には${requires}権限が必要です`
);
}
return originalResolver(parent, args, context, info);
};
}
return fieldConfig;
},
});
}
// 使用例(スキーマ変換)
let schema = makeExecutableSchema({ typeDefs, resolvers });
schema = authDirectiveTransformer(schema);7.4 DataSourceパターン
// data-sources/user-api.js
// RESTDataSourceを使ったデータソースの実装
import { RESTDataSource } from '@apollo/datasource-rest';
class UserAPI extends RESTDataSource {
constructor() {
super();
this.baseURL = 'http://internal-api:3000/';
}
// キャッシュTTLの設定
override cacheOptionsFor() {
return { ttl: 60 }; // 60秒キャッシュ
}
async getUser(id) {
return this.get(`users/${id}`);
}
async listUsers({ first, after, filter, sort }) {
const params = { limit: first };
if (after) params.cursor = after;
if (filter?.role) params.role = filter.role;
if (sort) params.sort = sort;
return this.get('users', { params });
}
async createUser(input) {
return this.post('users', { body: input });
}
async updateUser(id, input) {
return this.patch(`users/${id}`, { body: input });
}
async deleteUser(id) {
return this.delete(`users/${id}`);
}
async search(query) {
const results = await this.get('users/search', {
params: { q: query },
});
return results.map((user) => ({ ...user, __typename: 'User' }));
}
}
// data-sources/database-source.js
// SQLデータベースを直接利用するデータソース
class DatabaseSource {
constructor(pool) {
this.pool = pool; // データベース接続プール
}
async getUser(id) {
const { rows } = await this.pool.query(
'SELECT * FROM users WHERE id = $1',
[id]
);
return rows[0] || null;
}
async listUsers({ first, after, filter, sort }) {
let query = 'SELECT * FROM users WHERE 1=1';
const params = [];
let paramIndex = 1;
if (after) {
const decodedCursor = Buffer.from(after, 'base64')
.toString('utf-8')
.replace('cursor:', '');
query += ` AND id > $${paramIndex++}`;
params.push(decodedCursor);
}
if (filter?.role) {
query += ` AND role = $${paramIndex++}`;
params.push(filter.role);
}
if (filter?.nameContains) {
query += ` AND name ILIKE $${paramIndex++}`;
params.push(`%${filter.nameContains}%`);
}
// ソート
const sortMap = {
CREATED_AT_ASC: 'created_at ASC',
CREATED_AT_DESC: 'created_at DESC',
NAME_ASC: 'name ASC',
NAME_DESC: 'name DESC',
};
query += ` ORDER BY ${sortMap[sort] || 'created_at DESC'}`;
// ページネーション(+1で次ページの有無を判定)
query += ` LIMIT $${paramIndex++}`;
params.push(first + 1);
const { rows } = await this.pool.query(query, params);
const hasNextPage = rows.length > first;
const nodes = hasNextPage ? rows.slice(0, first) : rows;
return {
nodes,
totalCount: await this.countUsers(filter),
hasNextPage,
hasPreviousPage: !!after,
};
}
async countUsers(filter) {
let query = 'SELECT COUNT(*) FROM users WHERE 1=1';
const params = [];
let paramIndex = 1;
if (filter?.role) {
query += ` AND role = $${paramIndex++}`;
params.push(filter.role);
}
const { rows } = await this.pool.query(query, params);
return parseInt(rows[0].count, 10);
}
}8. N+1問題とDataLoader
8.1 N+1問題とは
N+1問題のイメージ:
query {
users(first: 10) { ← 1回のSQLクエリ(ユーザー10件取得)
edges {
node {
name
orders { ← ユーザーごとに1回のSQLクエリ(×10回)
id
total
}
}
}
}
}
実行されるSQL:
1. SELECT * FROM users LIMIT 10 -- 1回
2. SELECT * FROM orders WHERE user_id = 1 -- User 1の注文
3. SELECT * FROM orders WHERE user_id = 2 -- User 2の注文
4. SELECT * FROM orders WHERE user_id = 3 -- User 3の注文
...
11. SELECT * FROM orders WHERE user_id = 10 -- User 10の注文
→ 合計 11回のDBクエリ(1 + N = 1 + 10 = 11)
DataLoader で解決:
1. SELECT * FROM users LIMIT 10 -- 1回
2. SELECT * FROM orders WHERE user_id IN (1,2,3,...,10) -- 1回
→ 合計 2回のDBクエリ
8.2 DataLoaderの実装
// data-loaders.js
import DataLoader from 'dataloader';
// DataLoaderファクトリ(リクエストごとに新しいインスタンスを作成)
function createLoaders(db) {
return {
// ユーザーローダー
userLoader: new DataLoader(async (userIds) => {
// バッチ関数: IDの配列を受け取り、同じ順序で結果を返す
const users = await db.query(
'SELECT * FROM users WHERE id = ANY($1)',
[userIds]
);
// IDの順序を保持してマッピング
const userMap = new Map(users.rows.map((u) => [u.id, u]));
return userIds.map((id) => userMap.get(id) || null);
}),
// ユーザーの注文ローダー(1:Nの関係)
ordersByUserIdLoader: new DataLoader(async (userIds) => {
const orders = await db.query(
'SELECT * FROM orders WHERE user_id = ANY($1) ORDER BY created_at DESC',
[userIds]
);
// ユーザーIDごとにグループ化
const orderMap = new Map();
for (const order of orders.rows) {
if (!orderMap.has(order.user_id)) {
orderMap.set(order.user_id, []);
}
orderMap.get(order.user_id).push(order);
}
return userIds.map((id) => orderMap.get(id) || []);
}),
// 商品ローダー
productLoader: new DataLoader(async (productIds) => {
const products = await db.query(
'SELECT * FROM products WHERE id = ANY($1)',
[productIds]
);
const productMap = new Map(products.rows.map((p) => [p.id, p]));
return productIds.map((id) => productMap.get(id) || null);
}),
// 注文アイテムローダー(1:Nの関係)
orderItemsByOrderIdLoader: new DataLoader(async (orderIds) => {
const items = await db.query(
'SELECT * FROM order_items WHERE order_id = ANY($1)',
[orderIds]
);
const itemMap = new Map();
for (const item of items.rows) {
if (!itemMap.has(item.order_id)) {
itemMap.set(item.order_id, []);
}
itemMap.get(item.order_id).push(item);
}
return orderIds.map((id) => itemMap.get(id) || []);
}),
};
}
// コンテキストでDataLoaderを設定
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => ({
user: await authenticateUser(req.headers.authorization),
// リクエストごとに新しいLoaderを作成(キャッシュはリクエストスコープ)
loaders: createLoaders(db),
db,
}),
});
// リゾルバーでDataLoaderを使用
const resolversWithLoader = {
Query: {
user: (_, { id }, { loaders }) => loaders.userLoader.load(id),
},
User: {
orders: (user, _, { loaders }) =>
loaders.ordersByUserIdLoader.load(user.id),
},
Order: {
user: (order, _, { loaders }) => loaders.userLoader.load(order.userId),
items: (order, _, { loaders }) =>
loaders.orderItemsByOrderIdLoader.load(order.id),
},
OrderItem: {
product: (item, _, { loaders }) =>
loaders.productLoader.load(item.productId),
},
};8.3 DataLoaderの注意点
DataLoader使用時の注意:
1. リクエストスコープで作成する
✗ グローバルにDataLoaderを1つだけ作成
→ キャッシュが他のリクエストに漏れる(セキュリティリスク)
○ コンテキスト生成時にリクエストごとに新規作成
2. バッチ関数は入力と同じ順序で結果を返す
✗ [id=3, id=1, id=2] → [user1, user2, user3] (ID順)
○ [id=3, id=1, id=2] → [user3, user1, user2] (入力順)
3. 見つからないキーにはnullを返す(エラーではなく)
✗ throw new Error('User not found')
○ return null
4. キャッシュの無効化
→ Mutation後に loader.clear(id) または loader.clearAll()
→ 更新されたデータを再読み込みするために必要
5. バッチサイズの制限
→ maxBatchSize オプションで設定可能
→ DBの IN句制限に合わせる(PostgreSQLは約65000パラメータ)
9. クライアント実装
9.1 Apollo Client セットアップ
// apollo-client.js
import {
ApolloClient,
InMemoryCache,
ApolloLink,
from,
} from '@apollo/client';
import { onError } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry';
// エラーハンドリングリンク
const errorLink = onError(({ graphQLErrors, networkError, operation }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path, extensions }) => {
console.error(
`[GraphQL Error] Message: ${message}, ` +
`Location: ${JSON.stringify(locations)}, ` +
`Path: ${path}, Code: ${extensions?.code}`
);
// 認証エラー時はログアウト処理
if (extensions?.code === 'UNAUTHENTICATED') {
localStorage.removeItem('token');
window.location.href = '/login';
}
});
}
if (networkError) {
console.error(`[Network Error] ${networkError}`);
}
});
// リトライリンク
const retryLink = new RetryLink({
delay: {
initial: 300,
max: 3000,
jitter: true,
},
attempts: {
max: 3,
retryIf: (error) => !!error,
},
});
// 認証リンク(リクエストヘッダーにトークンを付与)
const authLink = new ApolloLink((operation, forward) => {
const token = localStorage.getItem('token');
operation.setContext({
headers: {
Authorization: token ? `Bearer ${token}` : '',
},
});
return forward(operation);
});
// キャッシュ設定
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
// ページネーションのマージポリシー
users: {
keyArgs: ['filter', 'sort'],
merge(existing, incoming, { args }) {
if (!args?.after) return incoming;
return {
...incoming,
edges: [...(existing?.edges || []), ...incoming.edges],
};
},
},
},
},
User: {
// ユーザーのキャッシュキー
keyFields: ['id'],
},
Order: {
keyFields: ['id'],
},
},
});
// クライアント作成
const client = new ApolloClient({
link: from([errorLink, retryLink, authLink, httpLink]),
cache,
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network', // キャッシュ優先で最新も取得
errorPolicy: 'all',
},
query: {
fetchPolicy: 'network-only',
errorPolicy: 'all',
},
mutate: {
errorPolicy: 'all',
},
},
});9.2 Reactコンポーネントでの使用
// components/UserList.jsx
import { useQuery, useMutation, gql } from '@apollo/client';
// クエリ定義
const GET_USERS = gql`
query GetUsers($first: Int!, $after: String, $filter: UserFilter) {
users(first: $first, after: $after, filter: $filter) {
edges {
node {
id
name
email
role
createdAt
orderCount
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
`;
const DELETE_USER = gql`
mutation DeleteUser($id: ID!) {
deleteUser(id: $id) {
deletedId
errors {
message
code
}
}
}
`;
function UserList() {
const { loading, error, data, fetchMore } = useQuery(GET_USERS, {
variables: { first: 20 },
});
const [deleteUser] = useMutation(DELETE_USER, {
// Mutation後のキャッシュ更新
update(cache, { data: { deleteUser: result } }) {
if (result.deletedId) {
cache.modify({
fields: {
users(existingConnection, { readField }) {
return {
...existingConnection,
edges: existingConnection.edges.filter(
(edge) => readField('id', edge.node) !== result.deletedId
),
totalCount: existingConnection.totalCount - 1,
};
},
},
});
}
},
});
if (loading && !data) return <p>読み込み中...</p>;
if (error) return <p>エラー: {error.message}</p>;
const { edges, pageInfo, totalCount } = data.users;
return (
<div>
<h1>ユーザー一覧 ({totalCount}件)</h1>
<ul>
{edges.map(({ node }) => (
<li key={node.id}>
{node.name} ({node.email}) - {node.role}
<span>注文数: {node.orderCount}</span>
<button onClick={() => deleteUser({ variables: { id: node.id } })}>
削除
</button>
</li>
))}
</ul>
{pageInfo.hasNextPage && (
<button
onClick={() =>
fetchMore({
variables: { after: pageInfo.endCursor },
})
}
>
もっと読み込む
</button>
)}
</div>
);
}
// components/UserProfile.jsx
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
role
bio
createdAt
orders(first: 5) {
edges {
node {
id
status
total
createdAt
items {
product { name, price }
quantity
subtotal
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`;
function UserProfile({ userId }) {
const { loading, error, data } = useQuery(GET_USER, {
variables: { id: userId },
// ポーリング(10秒ごとに自動更新)
// pollInterval: 10000,
});
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
if (!data.user) return <p>ユーザーが見つかりません</p>;
const { user } = data;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<p>役割: {user.role}</p>
{user.bio && <p>{user.bio}</p>}
<h2>注文履歴</h2>
{user.orders.edges.map(({ node }) => (
<div key={node.id}>
<h3>注文 #{node.id}</h3>
<p>ステータス: {node.status}</p>
<p>合計: {node.total.toLocaleString()}円</p>
<ul>
{node.items.map((item, i) => (
<li key={i}>
{item.product.name} x {item.quantity} = {item.subtotal.toLocaleString()}円
</li>
))}
</ul>
</div>
))}
</div>
);
}10. エラーハンドリング
10.1 GraphQLのエラーモデル
GraphQLのエラー分類:| GraphQL エラーの3層構造 |
|---|
| Layer 1: ネットワークエラー |
| → HTTPレベルのエラー(接続タイムアウト、DNS解決失敗等) |
| → レスポンスのHTTPステータスが4xx/5xx |
| → GraphQLサーバーに到達できていない状態 |
| Layer 2: GraphQL実行エラー(errors配列) |
| → パース失敗、バリデーション失敗、リゾルバー内例外 |
| → HTTPステータスは200だがerrorsフィールドにエラー情報あり |
| → data は partial(一部null)になることがある |
| Layer 3: ビジネスロジックエラー(Payloadのerrorsフィールド) |
| → アプリケーション固有のエラー(バリデーション、認可等) |
| → GraphQLとしては成功(errorsなし) |
| → Payloadオブジェクト内のerrorsで表現 |
10.2 エラーレスポンスの形式
// Layer 2: GraphQL実行エラーの例
// リゾルバー内でthrowされたエラー
{
"data": {
"user": null
},
"errors": [
{
"message": "認証が必要です",
"locations": [{ "line": 2, "column": 3 }],
"path": ["user"],
"extensions": {
"code": "UNAUTHENTICATED",
"http": { "status": 401 }
}
}
]
}
// Layer 3: ビジネスロジックエラーの例
// Payloadパターンによるエラー
{
"data": {
"createUser": {
"user": null,
"errors": [
{
"field": "email",
"message": "既に登録済みのメールアドレスです",
"code": "ALREADY_EXISTS"
},
{
"field": "name",
"message": "名前は2文字以上で入力してください",
"code": "VALIDATION_ERROR"
}
]
}
}
}
// Partial Data(部分的成功)の例
// 一部のフィールドが失敗しても他のフィールドは返す
{
"data": {
"user": {
"name": "Taro",
"email": "taro@example.com",
"orders": null // ← このフィールドだけエラー
}
},
"errors": [
{
"message": "注文サービスに接続できません",
"path": ["user", "orders"],
"extensions": { "code": "SERVICE_UNAVAILABLE" }
}
]
}10.3 エラー設計のベストプラクティス
エラー設計の指針:
1. 予期されるエラー → Payloadパターン(Layer 3)
- バリデーションエラー
- 重複登録
- 権限不足
→ クライアントが型安全にハンドリング可能
2. 予期しないエラー → GraphQL errors(Layer 2)
- 認証期限切れ
- サーバー内部エラー
- リソース上限超過
→ extensions.code で分類
3. エラーコードは必ず定義する
→ 人間向けメッセージは変わりうるが、コードは安定
→ クライアントのi18n対応にも有用
4. エラーにはfieldパスを含める
→ フォームのどの項目でエラーが出たか特定できる
→ UXの向上に直結
11. テスト戦略
11.1 リゾルバーの単体テスト
// __tests__/resolvers/user.test.js
import { resolvers } from '../../resolvers';
describe('Query.user', () => {
const mockContext = {
user: { id: '1', role: 'ADMIN' },
dataSources: {
userAPI: {
getUser: jest.fn(),
},
},
};
afterEach(() => {
jest.clearAllMocks();
});
it('IDでユーザーを取得できること', async () => {
const mockUser = {
id: '123',
name: 'Taro',
email: 'taro@example.com',
role: 'USER',
};
mockContext.dataSources.userAPI.getUser.mockResolvedValue(mockUser);
const result = await resolvers.Query.user(
null,
{ id: '123' },
mockContext
);
expect(result).toEqual(mockUser);
expect(mockContext.dataSources.userAPI.getUser).toHaveBeenCalledWith('123');
});
it('存在しないIDの場合nullを返すこと', async () => {
mockContext.dataSources.userAPI.getUser.mockResolvedValue(null);
const result = await resolvers.Query.user(
null,
{ id: 'nonexistent' },
mockContext
);
expect(result).toBeNull();
});
});
describe('Mutation.createUser', () => {
const mockContext = {
user: { id: '1', role: 'ADMIN' },
dataSources: {
userAPI: {
createUser: jest.fn(),
},
},
};
it('正常にユーザーを作成できること', async () => {
const input = { name: 'Taro', email: 'taro@example.com' };
const createdUser = { id: '456', ...input, role: 'USER' };
mockContext.dataSources.userAPI.createUser.mockResolvedValue(createdUser);
const result = await resolvers.Mutation.createUser(
null,
{ input },
mockContext
);
expect(result.user).toEqual(createdUser);
expect(result.errors).toEqual([]);
});
it('重複メール時にエラーを返すこと', async () => {
const input = { name: 'Taro', email: 'existing@example.com' };
mockContext.dataSources.userAPI.createUser.mockRejectedValue({
code: 'DUPLICATE_EMAIL',
field: 'email',
message: '既に登録済みのメールアドレスです',
});
const result = await resolvers.Mutation.createUser(
null,
{ input },
mockContext
);
expect(result.user).toBeNull();
expect(result.errors[0].code).toBe('ALREADY_EXISTS');
});
});11.2 統合テスト
// __tests__/integration/server.test.js
import { ApolloServer } from '@apollo/server';
import { readFileSync } from 'fs';
import assert from 'assert';
const typeDefs = readFileSync('./schema.graphql', 'utf-8');
describe('GraphQL Server統合テスト', () => {
let server;
beforeAll(() => {
server = new ApolloServer({ typeDefs, resolvers });
});
it('ユーザー取得クエリが正しく動作すること', async () => {
const response = await server.executeOperation(
{
query: `
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`,
variables: { id: '123' },
},
{
contextValue: {
user: { id: '1', role: 'ADMIN' },
dataSources: {
userAPI: {
getUser: async (id) => ({
id,
name: 'Test User',
email: 'test@example.com',
}),
},
},
},
}
);
assert.strictEqual(response.body.kind, 'single');
const { data, errors } = response.body.singleResult;
assert.strictEqual(errors, undefined);
assert.strictEqual(data.user.name, 'Test User');
});
it('認証なしのリクエストがエラーになること', async () => {
const response = await server.executeOperation(
{
query: `query { users(first: 10) { edges { node { id } } } }`,
},
{
contextValue: {
user: null, // 未認証
dataSources: {
userAPI: { listUsers: jest.fn() },
},
},
}
);
assert.strictEqual(response.body.kind, 'single');
const { errors } = response.body.singleResult;
assert(errors && errors.length > 0);
});
});12. セキュリティ
12.1 クエリ深度制限
// セキュリティ: クエリの深度制限
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(10), // 最大深度10
],
});
// 深度10を超えるクエリはバリデーションで拒否される
// 悪意あるクエリ例:
// query {
// user(id: "1") { // 深度 1
// orders { // 深度 2
// items { // 深度 3
// product { // 深度 4
// category { // 深度 5
// parent { // 深度 6 (再帰的)
// parent { // 深度 7
// ... // 無限再帰の可能性
// }
// }
// }
// }
// }
// }
// }
// }12.2 クエリ複雑度制限
// クエリの複雑度(コスト)制限
import { createComplexityRule, simpleEstimator } from 'graphql-query-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
createComplexityRule({
maximumComplexity: 1000,
estimators: [
simpleEstimator({ defaultComplexity: 1 }),
],
onComplete: (complexity) => {
console.log(`Query complexity: ${complexity}`);
},
}),
],
});
// スキーマレベルでフィールドごとのコストを指定
// type Query {
// users(first: Int): UserConnection! @complexity(value: 10, multipliers: ["first"])
// user(id: ID!): User @complexity(value: 1)
// }
//
// users(first: 100) のコスト = 10 * 100 = 1000 → 上限に達する12.3 レート制限とAPQ
// Automatic Persisted Queries (APQ)
// クエリ文字列のハッシュを送信し、サーバーにキャッシュされたクエリを実行
// → クエリ文字列の転送量を削減し、任意クエリの実行を防止
import {
ApolloClient,
InMemoryCache,
createHttpLink,
} from '@apollo/client';
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
const httpLink = createHttpLink({ uri: '/graphql' });
const persistedQueriesLink = createPersistedQueryLink({
sha256,
useGETForHashedQueries: true, // GETリクエストでCDNキャッシュ活用
});
const client = new ApolloClient({
link: persistedQueriesLink.concat(httpLink),
cache: new InMemoryCache(),
});
// サーバー側: allowedOperationsのホワイトリスト(本番向け)
// → 登録されたクエリのみ実行を許可
// → GraphiQL等からの任意クエリ実行を防止13. アンチパターン
13.1 アンチパターン1: 巨大な単一リゾルバー
// ===== アンチパターン: 巨大な単一リゾルバー =====
// すべてのロジックを1つのリゾルバーに詰め込む
const badResolvers = {
Query: {
user: async (_, { id }, context) => {
// DBクエリ
const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
// 注文を取得(N+1問題を引き起こす)
const orders = await db.query(
'SELECT * FROM orders WHERE user_id = $1', [id]
);
// 各注文のアイテムを取得(さらにN+1)
for (const order of orders.rows) {
order.items = await db.query(
'SELECT * FROM order_items WHERE order_id = $1', [order.id]
);
// 各アイテムの商品を取得(さらにN+1)
for (const item of order.items.rows) {
item.product = await db.query(
'SELECT * FROM products WHERE id = $1', [item.product_id]
);
}
}
// バリデーション、変換、キャッシュすべてここに...
user.rows[0].orders = orders.rows;
return user.rows[0];
},
},
};
// 問題点:
// 1. N+1問題(注文ごと、アイテムごとに個別クエリ)
// 2. クライアントがordersを要求していなくても全データを取得
// 3. テストが困難(モック対象が多い)
// 4. 関心の分離ができていない
// ===== 改善: フィールドリゾルバー + DataLoader =====
const goodResolvers = {
Query: {
user: (_, { id }, { loaders }) => loaders.userLoader.load(id),
},
User: {
orders: (user, _, { loaders }) =>
loaders.ordersByUserIdLoader.load(user.id),
},
Order: {
items: (order, _, { loaders }) =>
loaders.orderItemsByOrderIdLoader.load(order.id),
},
OrderItem: {
product: (item, _, { loaders }) =>
loaders.productLoader.load(item.productId),
},
};
// → 各フィールドが独立、DataLoaderでバッチ化、必要なフィールドのみ解決13.2 アンチパターン2: スキーマとビジネスロジックの密結合
// ===== アンチパターン: リゾルバーにビジネスロジックを直接記述 =====
const badMutationResolver = {
Mutation: {
createOrder: async (_, { input }, context) => {
// 在庫チェック(ビジネスロジック)
for (const item of input.items) {
const product = await db.query(
'SELECT stock FROM products WHERE id = $1', [item.productId]
);
if (product.rows[0].stock < item.quantity) {
return {
order: null,
errors: [{
field: 'items',
message: `${product.rows[0].name}の在庫が不足しています`,
code: 'VALIDATION_ERROR',
}],
};
}
}
// 金額計算(ビジネスロジック)
let total = 0;
for (const item of input.items) {
const product = await db.query(
'SELECT price FROM products WHERE id = $1', [item.productId]
);
total += product.rows[0].price * item.quantity;
}
// 割引適用(ビジネスロジック)
if (total > 10000) {
total = Math.floor(total * 0.9);
}
// DB書き込み、メール送信など全部ここに...
// → テスト困難、再利用不可、変更リスク高
},
},
};
// ===== 改善: サービス層に分離 =====
// services/order-service.js
class OrderService {
constructor(db, productService, notificationService) {
this.db = db;
this.productService = productService;
this.notificationService = notificationService;
}
async createOrder(input, userId) {
// バリデーション
const validationErrors = await this.validateOrderInput(input);
if (validationErrors.length > 0) {
return { order: null, errors: validationErrors };
}
// 金額計算
const total = await this.calculateTotal(input.items);
// 注文作成
const order = await this.db.createOrder({
userId,
items: input.items,
total,
status: 'PENDING',
});
// 通知
await this.notificationService.sendOrderConfirmation(order);
return { order, errors: [] };
}
async validateOrderInput(input) { /* ... */ }
async calculateTotal(items) { /* ... */ }
}
// リゾルバーは薄いレイヤーとして機能
const goodMutationResolver = {
Mutation: {
createOrder: async (_, { input }, context) => {
return context.services.orderService.createOrder(input, context.user.id);
},
},
};
// → テスト容易、ロジック再利用可能、関心の分離13.3 アンチパターン3: 過度なネスト許可
アンチパターン: 循環参照の放置
type User {
orders: [Order!]!
}
type Order {
user: User! ← User → Order → User → Order → ... 無限ループ可能
items: [OrderItem!]!
}
type OrderItem {
order: Order! ← OrderItem → Order → OrderItem → ... 循環
}
悪意あるクエリ:
query DeepNested {
user(id: "1") {
orders {
user {
orders {
user {
orders {
# ... 無限に続けられる
}
}
}
}
}
}
}
対策:
1. depthLimit による深度制限(セクション12.1参照)
2. 複雑度コスト制限(セクション12.2参照)
3. クエリタイムアウトの設定
4. 逆参照を慎重に設計(本当に必要な場合のみ追加)
14. エッジケース分析
14.1 エッジケース1: Nullableフィールドのチェーン
# 問題: ネストされたnullableフィールドのアクセス
type User {
id: ID!
name: String!
profile: UserProfile # nullable
}
type UserProfile {
avatar: String # nullable
address: Address # nullable
}
type Address {
prefecture: String!
city: String!
}
# クエリ
query GetUserAddress {
user(id: "1") {
name
profile { # nullの可能性あり
address { # nullの可能性あり
prefecture
city
}
}
}
}
# レスポンスパターン1: profileがnull
# {
# "data": {
# "user": {
# "name": "Taro",
# "profile": null
# }
# }
# }
# レスポンスパターン2: profileはあるがaddressがnull
# {
# "data": {
# "user": {
# "name": "Taro",
# "profile": {
# "address": null
# }
# }
# }
# }
# クライアント側の安全なアクセス:
# const city = data?.user?.profile?.address?.city ?? 'N/A';エッジケースの図解:
Non-null伝播ルール:| type Query { |
| user(id: ID!): User # nullable |
| } |
| type User { |
| name: String! # non-null |
| orders: [Order!]! # non-null (配列と要素) |
| } |
| もし User.name のリゾルバーがnullを返したら: |
| → nameはnon-nullなのでUser全体がnullになる |
| → user フィールドがnullableなら user: null になる |
| → user フィールドがnon-null(User!)なら |
| さらに親に伝播し、最終的にdata全体がnullになる |
| 教訓: |
| non-null(!)は「このフィールドは必ず値がある」という |
| 保証だが、リゾルバーがnullを返すとエラー伝播する |
| 外部サービス依存のフィールドはnullableにすることで |
| 部分的な成功(Partial Data)を可能にする |
14.2 エッジケース2: 大量データの一括リクエスト
# 問題: クライアントが大量データを一度に要求
# 危険なクエリ例
query GetAllUsers {
users(first: 10000) { # 1万件要求
edges {
node {
id
name
orders(first: 100) { # 各ユーザーの注文100件
edges {
node {
items { # 各注文の全アイテム
product {
name
category {
products { # カテゴリの全商品
name
}
}
}
}
}
}
}
}
}
}
}
# → 10000 * 100 * N * M = 数百万レコードのDB負荷が発生しうる対策の多層防御:
Layer 1: 引数の上限値
→ first/last の最大値を制限(例: max 100)
→ リゾルバー内で Math.min(args.first, MAX_PAGE_SIZE) を適用
Layer 2: クエリ深度制限
→ depthLimit(7) で過度なネストを防止
Layer 3: クエリ複雑度制限
→ 1リクエストあたりのコスト上限を設定
Layer 4: タイムアウト
→ リゾルバー/DBクエリにタイムアウトを設定
→ 一定時間で強制打ち切り
Layer 5: レート制限
→ IPベース / ユーザーベースでリクエスト数を制限
→ 時間窓内の最大リクエスト数を管理
// 実装例: 引数の上限値チェック
const MAX_PAGE_SIZE = 100;
const resolvers = {
Query: {
users: async (_, args, context) => {
const first = Math.min(args.first || 20, MAX_PAGE_SIZE);
if (args.first > MAX_PAGE_SIZE) {
console.warn(
`Requested page size ${args.first} exceeds max ${MAX_PAGE_SIZE}`
);
}
return context.dataSources.userAPI.listUsers({
...args,
first,
});
},
},
};14.3 エッジケース3: 並行Mutationの競合
並行Mutation時のデータ競合:
Client A Client B
| |
| updateUser(id:"1", | updateUser(id:"1",
| input: {name:"太郎"}) | input: {email:"new@x.com"})
| |
| --- (1) READ user ---> |
| <-- name:"Taro", email:"old" |
| | --- (2) READ user --->
| | <-- name:"Taro", email:"old"
| --- (3) WRITE name:"太郎" --> |
| | --- (4) WRITE email:"new" -->
| |
| 結果: name="太郎", email="new@x.com"
| → この場合は問題なし(異なるフィールド)
|
| 問題のあるケース: 同一フィールドの更新
| Client A: updateUser(input: {name:"太郎"})
| Client B: updateUser(input: {name:"花子"})
| → 最後の書き込みが勝つ(Last Write Wins)
対策:
1. 楽観的ロック: updatedAtをチェック
input UpdateUserInput {
name: String
expectedVersion: Int! # 更新前のバージョン番号
}
2. フィールドレベルロック:
→ 変更するフィールドのみを対象にUPDATE
→ PATCH的な部分更新
3. イベントソーシング:
→ 変更をイベントとして記録
→ 競合検知と解決が容易
15. パフォーマンス最適化
15.1 クエリプランニング
パフォーマンス最適化の観点:| GraphQL パフォーマンス最適化ピラミッド |
|---|
| ┌───┐ |
| / CDN \ |
| / Cache \ |
| ┌─────────┐ |
| / Response \ |
| / Cache \ |
| ┌───────────────┐ |
| / DataLoader \ |
| / (Request Cache) \ |
| ┌─────────────────────┐ |
| / DB Query \ |
| / Optimization \ |
| ┌───────────────────────────┐ |
| / Schema Design \ |
| / (Foundation) \ |
| └─────────────────────────────────┘ |
| 下層から順に最適化するのが効果的 |
15.2 応答キャッシュ
// Apollo Server のレスポンスキャッシュ
import responseCachePlugin from '@apollo/server-plugin-response-cache';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
responseCachePlugin({
// ユーザーごとにキャッシュを分離
sessionId: (requestContext) =>
requestContext.contextValue.user?.id || 'anonymous',
}),
],
});
// スキーマでキャッシュヒントを設定
// type Query {
// publicPosts: [Post!]! @cacheControl(maxAge: 300) # 5分キャッシュ
// me: User @cacheControl(maxAge: 0, scope: PRIVATE) # キャッシュなし
// }
//
// type Post @cacheControl(maxAge: 60) {
// id: ID!
// title: String!
// author: User! @cacheControl(maxAge: 30)
// }16. 演習問題
演習1: 基礎(スキーマ定義)
以下の要件を満たすGraphQLスキーマをSDLで定義せよ。
要件: ブログシステムのスキーマ
エンティティ:
- Author: id, name, email, bio, createdAt
- Article: id, title, body, author, tags, status(DRAFT/PUBLISHED/ARCHIVED),
publishedAt, createdAt, updatedAt
- Comment: id, article, author, body, createdAt
- Tag: id, name, slug
機能:
- 記事一覧(ページネーション、ステータスフィルタ、タグフィルタ)
- 記事詳細(コメント付き)
- 著者の記事一覧
- 記事作成/更新/削除(Mutation)
- コメント追加/削除(Mutation)
条件:
- Relay Connection仕様のページネーション
- Payloadパターンのエラーハンドリング
- 適切なnull/non-null設定
# 解答例(一部)
scalar DateTime
enum ArticleStatus {
DRAFT
PUBLISHED
ARCHIVED
}
type Author {
id: ID!
name: String!
email: String!
bio: String
createdAt: DateTime!
articles(
first: Int
after: String
status: ArticleStatus
): ArticleConnection!
articleCount: Int!
}
type Article {
id: ID!
title: String!
body: String!
author: Author!
tags: [Tag!]!
status: ArticleStatus!
publishedAt: DateTime # DRAFTの場合null
createdAt: DateTime!
updatedAt: DateTime!
comments(first: Int, after: String): CommentConnection!
commentCount: Int!
}
type Comment {
id: ID!
article: Article!
author: Author!
body: String!
createdAt: DateTime!
}
type Tag {
id: ID!
name: String!
slug: String!
articles(first: Int, after: String): ArticleConnection!
}
# Connection types...
type ArticleEdge { node: Article!, cursor: String! }
type ArticleConnection {
edges: [ArticleEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type CommentEdge { node: Comment!, cursor: String! }
type CommentConnection {
edges: [CommentEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# Query
type Query {
article(id: ID!): Article
articles(
first: Int
after: String
status: ArticleStatus
tagSlug: String
authorId: ID
): ArticleConnection!
author(id: ID!): Author
tag(slug: String!): Tag
tags: [Tag!]!
me: Author
}
# Mutation
input CreateArticleInput {
title: String!
body: String!
tagIds: [ID!]
status: ArticleStatus = DRAFT
}
input UpdateArticleInput {
title: String
body: String
tagIds: [ID!]
status: ArticleStatus
}
input AddCommentInput {
articleId: ID!
body: String!
}
type ArticlePayload {
article: Article
errors: [UserError!]!
}
type CommentPayload {
comment: Comment
errors: [UserError!]!
}
type DeletePayload {
deletedId: ID
errors: [UserError!]!
}
type UserError {
field: String
message: String!
code: String!
}
type Mutation {
createArticle(input: CreateArticleInput!): ArticlePayload!
updateArticle(id: ID!, input: UpdateArticleInput!): ArticlePayload!
deleteArticle(id: ID!): DeletePayload!
addComment(input: AddCommentInput!): CommentPayload!
deleteComment(id: ID!): DeletePayload!
}演習2: 中級(リゾルバーとDataLoader)
上記のブログスキーマに対して、以下のリゾルバーを実装せよ。
要件:
1. Query.articles リゾルバー(カーソルページネーション付き)
2. Article.commentCount フィールドリゾルバー(DataLoader使用)
3. Mutation.createArticle リゾルバー(バリデーション付き)
4. 全てのリゾルバーで認証チェックを行うこと
// 解答例
import DataLoader from 'dataloader';
// DataLoaderの作成
function createBlogLoaders(db) {
return {
commentCountLoader: new DataLoader(async (articleIds) => {
const result = await db.query(
`SELECT article_id, COUNT(*) as count
FROM comments
WHERE article_id = ANY($1)
GROUP BY article_id`,
[articleIds]
);
const countMap = new Map(
result.rows.map((r) => [r.article_id, parseInt(r.count, 10)])
);
return articleIds.map((id) => countMap.get(id) || 0);
}),
authorLoader: new DataLoader(async (authorIds) => {
const result = await db.query(
'SELECT * FROM authors WHERE id = ANY($1)',
[authorIds]
);
const map = new Map(result.rows.map((a) => [a.id, a]));
return authorIds.map((id) => map.get(id) || null);
}),
};
}
const blogResolvers = {
Query: {
articles: async (_, args, context) => {
if (!context.user) throw new Error('認証が必要です');
const { first = 20, after, status, tagSlug, authorId } = args;
const safeFirst = Math.min(first, 100);
let query = 'SELECT * FROM articles WHERE 1=1';
const params = [];
let idx = 1;
if (status) {
query += ` AND status = $${idx++}`;
params.push(status);
}
if (authorId) {
query += ` AND author_id = $${idx++}`;
params.push(authorId);
}
if (after) {
const cursor = Buffer.from(after, 'base64').toString().replace('cursor:', '');
query += ` AND id > $${idx++}`;
params.push(cursor);
}
query += ` ORDER BY created_at DESC LIMIT $${idx++}`;
params.push(safeFirst + 1);
const { rows } = await context.db.query(query, params);
const hasNextPage = rows.length > safeFirst;
const nodes = hasNextPage ? rows.slice(0, safeFirst) : rows;
const edges = nodes.map((node) => ({
node,
cursor: Buffer.from(`cursor:${node.id}`).toString('base64'),
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
totalCount: await countArticles(context.db, { status, authorId }),
};
},
},
Article: {
commentCount: (article, _, { loaders }) =>
loaders.commentCountLoader.load(article.id),
author: (article, _, { loaders }) =>
loaders.authorLoader.load(article.author_id),
},
Mutation: {
createArticle: async (_, { input }, context) => {
if (!context.user) {
return {
article: null,
errors: [{ field: null, message: '認証が必要です', code: 'UNAUTHENTICATED' }],
};
}
// バリデーション
const errors = [];
if (!input.title || input.title.trim().length < 3) {
errors.push({
field: 'title',
message: 'タイトルは3文字以上で入力してください',
code: 'VALIDATION_ERROR',
});
}
if (!input.body || input.body.trim().length < 10) {
errors.push({
field: 'body',
message: '本文は10文字以上で入力してください',
code: 'VALIDATION_ERROR',
});
}
if (errors.length > 0) {
return { article: null, errors };
}
const article = await context.db.query(
`INSERT INTO articles (title, body, author_id, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW()) RETURNING *`,
[input.title, input.body, context.user.id, input.status || 'DRAFT']
);
return { article: article.rows[0], errors: [] };
},
},
};演習3: 応用(Subscription + 統合テスト)
以下の要件でリアルタイムコメント通知機能を実装せよ。
要件:
1. 記事にコメントが追加されたらSubscriptionで通知
2. 通知には記事ID、コメント内容、投稿者名を含める
3. 購読者は記事の著者のみ(認可チェック)
4. 統合テストも作成する
// 解答例
// スキーマ追加
// type Subscription {
// commentAdded(articleId: ID!): CommentAddedEvent!
// }
//
// type CommentAddedEvent {
// articleId: ID!
// comment: Comment!
// }
import { PubSub, withFilter } from 'graphql-subscriptions';
const pubsub = new PubSub();
const COMMENT_ADDED = 'COMMENT_ADDED';
const subscriptionResolvers = {
Subscription: {
commentAdded: {
subscribe: withFilter(
() => pubsub.asyncIterator(COMMENT_ADDED),
async (payload, variables, context) => {
// 記事IDのフィルタリング
if (payload.commentAdded.articleId !== variables.articleId) {
return false;
}
// 認可チェック: 記事の著者のみ購読可能
const article = await context.dataSources.articleAPI
.getArticle(variables.articleId);
return article?.authorId === context.user?.id;
}
),
},
},
Mutation: {
addComment: async (_, { input }, context) => {
// ... コメント作成処理 ...
const comment = await context.db.query(
`INSERT INTO comments (article_id, author_id, body, created_at)
VALUES ($1, $2, $3, NOW()) RETURNING *`,
[input.articleId, context.user.id, input.body]
);
// Subscriptionにイベント発行
pubsub.publish(COMMENT_ADDED, {
commentAdded: {
articleId: input.articleId,
comment: comment.rows[0],
},
});
return { comment: comment.rows[0], errors: [] };
},
},
};
// 統合テスト
// __tests__/subscription.test.js
describe('Subscription: commentAdded', () => {
it('記事著者にコメント通知が届くこと', async () => {
// 1. Subscriptionを開始
const subscription = client.subscribe({
query: gql`
subscription OnCommentAdded($articleId: ID!) {
commentAdded(articleId: $articleId) {
articleId
comment {
body
author { name }
}
}
}
`,
variables: { articleId: 'article-1' },
});
// 2. 結果を収集するPromise
const resultPromise = new Promise((resolve) => {
subscription.subscribe({ next: resolve });
});
// 3. コメントを追加
await client.mutate({
mutation: gql`
mutation AddComment($input: AddCommentInput!) {
addComment(input: $input) {
comment { id }
errors { message }
}
}
`,
variables: {
input: { articleId: 'article-1', body: 'Great article!' },
},
});
// 4. Subscription結果の検証
const result = await resultPromise;
expect(result.data.commentAdded.articleId).toBe('article-1');
expect(result.data.commentAdded.comment.body).toBe('Great article!');
});
});FAQ
Q1: GraphQLとREST APIはどのような場面で使い分けるべきか?
GraphQLとREST APIは異なる設計思想とトレードオフを持つため、プロジェクトの特性に応じた使い分けが重要である。
GraphQLが適する場面:
- 複雑なデータ要件を持つUI: モバイルアプリやダッシュボードなど、画面ごとに異なるデータセットが必要な場合
- マイクロサービス統合: 複数のバックエンドサービスを単一のAPIとして集約したい場合(BFF: Backend for Frontendパターン)
- フロントエンド主導開発: フロントエンドチームがバックエンドへの依存を減らし、自律的にデータ要件を定義したい場合
- リアルタイム機能: Subscriptionを利用したチャット、通知、リアルタイムダッシュボード
REST APIが適する場面:
- シンプルなCRUD操作: ユーザー登録、ログイン、基本的なリソース管理など、定型的な操作が中心の場合
- ファイル処理: 大容量ファイルのアップロード/ダウンロード、ストリーミング配信
- CDNキャッシュ活用: URLベースのキャッシュ戦略が明確で、HTTPキャッシュを最大限活用したい場合
- 外部公開API: 広範な互換性が必要で、OpenAPI(Swagger)によるドキュメント生成やHTTPステータスコードの標準活用が重要な場合
併用パターン(推奨): 実運用では、外部向けはREST API(安定性・互換性重視)、内部向けBFFとしてGraphQL(開発効率重視)という組み合わせがよく採用される。例えば、モバイルアプリは内部GraphQL APIを使用し、サードパーティ連携は公開REST APIを提供する構成である。
Q2: GraphQLのN+1問題とは何か、どう解決するか?
N+1問題は、GraphQLのリゾルバーが階層的に実行される性質により発生する、最も頻繁に遭遇するパフォーマンス課題である。
問題の発生メカニズム:
query {
articles { # 1回のクエリで10件取得
id
title
author { # 各記事ごとに1回、合計10回のクエリ
name
}
}
}上記のクエリは、articlesの取得で1回、各記事のauthor取得でN回(記事数分)、合計N+1回のデータベースクエリを実行してしまう。記事が1000件あれば1001回のクエリが発生し、深刻なパフォーマンス劣化を引き起こす。
解決手段:
-
DataLoader(最も推奨): Facebook開発のバッチ処理ライブラリ。リクエストスコープ内でIDをバッチ化し、一括取得+キャッシュを行う(本章セクション8参照)。
- メリット: リゾルバーのロジックを変更せず導入可能、公式推奨パターン
- 実装:
new DataLoader(ids => batchGetAuthors(ids))でバッチ関数を定義し、contextに格納
-
JOINベースのリゾルバー:
infoパラメータから必要なフィールドを解析し、事前にJOINクエリを構築する方法。- メリット: 最適なSQLクエリを1回で実行可能
- デメリット: リゾルバーの複雑度が増加、
graphql-fieldsやgraphql-parse-resolve-infoライブラリの知識が必要
-
Lookahead/Projection: 次に解決されるフィールドを先読みし、必要なデータを事前取得する手法。
- メリット: データソースの特性に応じた最適化が可能
- デメリット: 実装が複雑、フレームワーク依存
推奨アプローチ: まずDataLoaderを導入し、それでも解決しない特殊ケース(集約関数、複雑なJOIN)でJOINベースリゾルバーを検討する段階的な戦略が実運用では有効である。
Q3: GraphQLを導入する際の学習コストと組織的な準備は?
GraphQLの導入には技術的な学習だけでなく、組織的な準備が必要となる。
技術的な学習コスト:
- 基礎習得: 型システム、スキーマ定義(SDL)、Query/Mutation/Subscriptionの理解に1-2週間
- 実装スキル: リゾルバー実装、DataLoader、エラーハンドリング、認証・認可パターンの習得に2-4週間
- 運用知識: Apollo Server構築、パフォーマンス最適化、セキュリティ対策、監視・ロギングに1-2ヶ月
- 高度なトピック: Federation、Schema Stitching、Relay仕様、キャッシュ戦略に2-3ヶ月
組織的な準備:
-
スキーマガバナンス体制: スキーマの変更管理、レビュープロセス、Breaking Changeの管理ルール策定
-
ツールチェーン整備: GraphiQL/Playground、Apollo Studio、スキーマバリデーション(CI統合)、コード生成ツール
-
ドキュメント文化: SDL自体がドキュメントになるが、ビジネスロジックやユースケースの補足ドキュメントが必要
-
段階的な導入計画:
- Phase 1: 小規模な内部ツールで試験導入(2-4週間)
- Phase 2: 単一マイクロサービスのBFF構築(1-2ヶ月)
- Phase 3: 複数サービス統合、Federationの検討(2-3ヶ月)
- Phase 4: 外部公開APIとしての展開(セキュリティ・スケーラビリティ確立後)
-
チーム体制: フロントエンド・バックエンド間のコミュニケーションコスト削減がGraphQLの利点だが、初期はスキーマ設計を主導する「GraphQL Champion」役の配置が推奨される。
失敗を避けるポイント:
- 既存REST APIの全置き換えを最初から目指さない(段階的移行)
- N+1問題を後回しにせず、DataLoaderを最初から導入する
- スキーマのバージョニング戦略(Schema Evolution)を初期に確立する
- 監視・ロギング基盤(Apollo Studio、Sentryなど)を早期に整備する
まとめ
| 概念 | ポイント |
|---|---|
| SDL | 型システムでスキーマを定義、APIの仕様書として機能 |
| Query | クライアントが必要なデータを正確に指定、フラグメントで再利用 |
| Mutation | Payloadパターンでエラーハンドリング、Input型で引数を構造化 |
| Subscription | WebSocketベースのリアルタイム通知、PubSubパターンで実装 |
| リゾルバー | 親→子の階層的なデータ取得、4引数(parent, args, context, info) |
| DataLoader | N+1問題の解決、バッチ処理+リクエストスコープキャッシュ |
| Apollo Server | Express統合、プラグイン機構、ディレクティブベースの認可 |
| セキュリティ | 深度制限、複雑度制限、レート制限、APQの多層防御 |
| エラー設計 | ネットワーク/GraphQL/ビジネスの3層モデル |
| テスト | 単体(リゾルバー)+ 統合(executeOperation)+ E2E |
次に読むべきガイド
→ GraphQL応用 -- Federation, Schema Stitching, Caching戦略, CI/CD統合
参考文献
- GraphQL Foundation. "GraphQL Specification (October 2021 Edition)." graphql.org, 2023.
- Apollo GraphQL. "Apollo Server v4 Documentation." apollographql.com/docs/apollo-server, 2024.
- Lee, B. "GraphQL in Action." Manning Publications, 2021.
- Banks, A. and Porcello, E. "Learning GraphQL: Declarative Data Fetching for Modern Web Apps." O'Reilly Media, 2018.
- Facebook Open Source. "DataLoader - Batching and Caching for GraphQL." github.com/graphql/dataloader, 2024.
- Relay Team. "Relay Connection Specification." relay.dev/graphql/connections.htm, 2024.