GraphQL API - 解説
本解説では、gqlgenの内部動作、設計のベストプラクティス、パフォーマンス最適化、そして発展的学習への道筋を詳しく説明します。
---
1. gqlgenの内部動作とコード生成の仕組み
gqlgenとは
gqlgenは、GraphQLスキーマからGoのコードを自動生成する「スキーマファースト」型のGraphQLライブラリです。他のアプローチ(コードファーストなど)と比較して、以下の利点があります:
- 型安全性: スキーマとコードの不整合が起こらない
- ドキュメント: スキーマ自体が仕様書として機能
- 開発速度: ボイラープレートコードの自動生成
コード生成のプロセス
ステップ1: スキーマ解析
gqlgenは、.graphqlファイルを解析し、抽象構文木(AST)を構築します。
type User {
id: ID!
name: String!
}
type Query {
user(id: ID!): User
}
この定義から、gqlgenは以下の情報を抽出します:
ステップ2: Goコードの生成
gqlgenは、gqlgen.ymlの設定に基づいて、以下のファイルを生成します:
1. generated.go(実行エンジン)
// 自動生成された実行エンジン
type ExecutableSchema struct {
resolvers ResolverRoot
directives DirectiveRoot
complexity ComplexityRoot
}
func (e *ExecutableSchema) Schema() *ast.Schema {
return parsedSchema
}
func (e *ExecutableSchema) Complexity(query string) int {
// クエリの複雑度を計算
}
このファイルには、GraphQLクエリを解析・実行するためのエンジンコードが含まれます。
2. models_gen.go(型定義)
// スキーマから生成された型
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
スキーマ内の各型に対応するGo構造体が生成されます。
3. resolver_interface.go(リゾルバーインターフェース)
type QueryResolver interface {
User(ctx context.Context, id string) (*User, error)
}
type UserResolver interface {
// フィールドリゾルバー(必要な場合のみ)
}
開発者が実装すべきインターフェースが定義されます。
ステップ3: リゾルバーのスタブ生成
初回実行時、gqlgenはリゾルバーのスタブファイルも生成します:
// schema.resolvers.go
func (r *queryResolver) User(ctx context.Context, id string) (*User, error) {
panic(fmt.Errorf("not implemented"))
}
開発者は、このスタブを実際のロジックに置き換えます。
gqlgen.ymlの詳細設定
モデルのカスタマイズ
既存のデータベースモデルを使用する場合:
models:
User:
model:
- github.com/yourapp/db.User # 既存のモデルを指定
fields:
id:
resolver: true # IDフィールドにカスタムリゾルバーを使用
これにより、GraphQLのUser型が、既存のdb.User構造体にマッピングされます。
スカラー型のカスタマイズ
カスタムスカラー型の実装:
models:
DateTime:
model:
- time.Time
JSON:
model:
- github.com/yourapp/types.JSON
カスタムスカラー型には、Marshal/Unmarshalロジックを実装する必要があります:
package types
import (
"encoding/json"
"fmt"
"io"
"github.com/99designs/gqlgen/graphql"
)
type JSON map[string]interface{}
func MarshalJSON(val JSON) graphql.Marshaler {
return graphql.WriterFunc(func(w io.Writer) {
data, _ := json.Marshal(val)
w.Write(data)
})
}
func UnmarshalJSON(v interface{}) (JSON, error) {
switch v := v.(type) {
case map[string]interface{}:
return JSON(v), nil
default:
return nil, fmt.Errorf("unexpected type %T", v)
}
}
リゾルバーの実行フロー
クエリが実行される際の内部フロー:
1. クライアントからクエリ受信
↓
2. クエリの構文解析(Parsing)
↓
3. バリデーション
- スキーマとの整合性チェック
- フィールドの存在確認
- 型の整合性確認
↓
4. 実行計画の構築
↓
5. リゾルバーの並列実行
- フィールドごとにgoroutineで実行
↓
6. 結果のマージとレスポンス生成
並列実行の仕組み
gqlgenは、依存関係のないフィールドを並列に実行します:
query {
user(id: "1") {
name # 並列実行
email # 並列実行
posts { # 並列実行
title
}
}
}
上記のクエリでは、name、email、postsの各リゾルバーがgoroutineで並列実行されます。
---
2. Resolver設計のベストプラクティス
リゾルバーの責務分離
アンチパターン:リゾルバーにビジネスロジックを詰め込む
// ❌ 悪い例
func (r *mutationResolver) CreatePost(ctx context.Context, input model.NewPost) (*Post, error) {
// リゾルバー内でバリデーション、DB操作、通知など全てを実装
if len(input.Title) < 5 {
return nil, errors.New("title too short")
}
tx, _ := r.db.Begin()
post := &Post{...}
tx.Insert(post)
tx.Commit()
r.emailService.NotifyFollowers(post)
r.searchEngine.Index(post)
return post, nil
}
ベストプラクティス:レイヤーの分離
// ✅ 良い例
func (r *mutationResolver) CreatePost(ctx context.Context, input model.NewPost) (*Post, error) {
// リゾルバーは薄く保ち、サービス層に委譲
userID := ctx.Value("userID").(string)
post, err := r.postService.Create(ctx, userID, input)
if err != nil {
return nil, err
}
return post, nil
}
// サービス層(別ファイル)
func (s *PostService) Create(ctx context.Context, userID string, input model.NewPost) (*Post, error) {
// バリデーション
if err := s.validator.ValidatePost(input); err != nil {
return nil, err
}
// トランザクション開始
tx, err := s.db.BeginTx(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback()
// ビジネスロジック
post := &Post{
Title: input.Title,
Content: input.Content,
AuthorID: userID,
}
if err := tx.Insert(post); err != nil {
return nil, err
}
// 関連処理
go s.notificationService.NotifyFollowers(post)
go s.searchService.Index(post)
tx.Commit()
return post, nil
}
エラーハンドリングのベストプラクティス
カスタムエラー型の定義
// errors.go
package apperrors
import "github.com/vektah/gqlparser/v2/gqlerror"
var (
ErrNotFound = &gqlerror.Error{
Message: "Resource not found",
Extensions: map[string]interface{}{
"code": "NOT_FOUND",
},
}
ErrUnauthorized = &gqlerror.Error{
Message: "Unauthorized",
Extensions: map[string]interface{}{
"code": "UNAUTHORIZED",
},
}
ErrValidation = func(field string, message string) *gqlerror.Error {
return &gqlerror.Error{
Message: "Validation error",
Extensions: map[string]interface{}{
"code": "VALIDATION_ERROR",
"field": field,
"message": message,
},
}
}
)
リゾルバーでの使用
func (r *queryResolver) Post(ctx context.Context, id string) (*Post, error) {
post, err := r.db.GetPostByID(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, apperrors.ErrNotFound
}
return nil, err
}
return post, nil
}
クライアント側では、以下のような構造化されたエラーを受け取ります:
{
"data": null,
"errors": [
{
"message": "Resource not found",
"path": ["post"],
"extensions": {
"code": "NOT_FOUND"
}
}
]
}
コンテキストの活用
リクエストスコープデータの管理
type contextKey string
const (
userIDKey contextKey = "userID"
dataloaderKey contextKey = "dataloader"
tracerKey contextKey = "tracer"
)
// コンテキストへの設定
func WithUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, userIDKey, userID)
}
// コンテキストからの取得
func GetUserID(ctx context.Context) (string, bool) {
userID, ok := ctx.Value(userIDKey).(string)
return userID, ok
}
// リゾルバーでの使用
func (r *mutationResolver) CreatePost(ctx context.Context, input model.NewPost) (*Post, error) {
userID, ok := GetUserID(ctx)
if !ok {
return nil, apperrors.ErrUnauthorized
}
// ...
}
タイムアウトとキャンセル処理
func (r *queryResolver) ExpensiveQuery(ctx context.Context) (*Result, error) {
// タイムアウト設定
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
resultCh := make(chan *Result)
errCh := make(chan error)
go func() {
result, err := r.service.ComputeExpensiveResult(ctx)
if err != nil {
errCh <- err
return
}
resultCh <- result
}()
select {
case result := <-resultCh:
return result, nil
case err := <-errCh:
return nil, err
case <-ctx.Done():
return nil, errors.New("query timeout")
}
}
---
3. パフォーマンス最適化
N+1問題の徹底解決
問題の可視化
まず、どこでN+1問題が発生しているかを特定します:
// ミドルウェアでDBクエリ数をカウント
func QueryCounterMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
counter := &QueryCounter{count: 0}
ctx := context.WithValue(r.Context(), "queryCounter", counter)
next.ServeHTTP(w, r.WithContext(ctx))
log.Printf("Request: %s, Queries: %d", r.URL.Path, counter.count)
})
}
Dataloaderの高度な使用
バッチサイズの調整
userLoader := dataloadgen.NewLoader(
batchFunc,
dataloadgen.WithBatchCapacity(100), // 一度に最大100件
dataloadgen.WithWait(1*time.Millisecond), // 1ms待機してバッチ化
dataloadgen.WithCache(cache), // カスタムキャッシュ実装
)
キャッシュのクリア
func (r *mutationResolver) UpdateUser(ctx context.Context, input model.UpdateUser) (*User, error) {
user, err := r.service.UpdateUser(ctx, input)
if err != nil {
return nil, err
}
// Dataloaderのキャッシュをクリア
loaders := GetLoaders(ctx)
loaders.UserLoader.Clear(ctx, user.ID)
return user, nil
}
クエリの複雑性制限
動的複雑度計算
// gqlgen.yml
complexity:
Query:
posts: 10
Post:
comments: 5
author: 1
User:
posts: 10
// カスタム複雑度関数
func PostsComplexity(childComplexity int, first *int, after *string) int {
limit := 10
if first != nil {
limit = *first
}
// 取得件数 × 子フィールドの複雑度
return limit * childComplexity
}
実装例
srv := handler.NewDefaultServer(generated.NewExecutableSchema(cfg))
// 複雑度制限
srv.Use(extension.FixedComplexityLimit(1000))
// カスタム複雑度計算
srv.AroundFields(func(ctx context.Context, next graphql.Resolver) (interface{}, error) {
fc := graphql.GetFieldContext(ctx)
// フィールドごとの複雑度を記録
complexity := calculateComplexity(fc)
log.Printf("Field: %s, Complexity: %d", fc.Field.Name, complexity)
return next(ctx)
})
キャッシュ戦略
フィールドレベルキャッシュ
func (r *queryResolver) PopularPosts(ctx context.Context) ([]*Post, error) {
cacheKey := "popular_posts"
// Redisキャッシュをチェック
if cached, err := r.cache.Get(ctx, cacheKey); err == nil {
var posts []*Post
if err := json.Unmarshal(cached, &posts); err == nil {
return posts, nil
}
}
// キャッシュミス:DBから取得
posts, err := r.db.GetPopularPosts(ctx)
if err != nil {
return nil, err
}
// キャッシュに保存(5分間)
if data, err := json.Marshal(posts); err == nil {
r.cache.Set(ctx, cacheKey, data, 5*time.Minute)
}
return posts, nil
}
Automatic Persisted Queries (APQ)
APQは、クエリ文字列をハッシュ化してネットワーク転送量を削減する手法です:
import "github.com/99designs/gqlgen/graphql/handler/extension"
srv.Use(extension.AutomaticPersistedQuery{
Cache: lru.New(100), // LRUキャッシュで100件保持
})
動作の流れ:
メリット:
- ネットワーク転送量が平均70%削減
- CDNでのキャッシュが容易
データベースクエリ最適化
Select N+1の回避
// ❌ 悪い例:各投稿ごとにコメント数をカウント
func (r *postResolver) CommentCount(ctx context.Context, obj *Post) (int, error) {
return r.db.CountComments(ctx, obj.ID) // N+1問題
}
// ✅ 良い例:JOINで一括取得
func (r *queryResolver) Posts(ctx context.Context) ([]*Post, error) {
query := `
SELECT
p.id, p.title, p.content,
COUNT(c.id) as comment_count
FROM posts p
LEFT JOIN comments c ON p.id = c.post_id
GROUP BY p.id
`
return r.db.Query(ctx, query)
}
インデックスの活用
-- 頻繁に検索されるフィールドにインデックス
CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_posts_published_at ON posts(published_at) WHERE published = true;
CREATE INDEX idx_comments_post_id ON comments(post_id);
-- 複合インデックス
CREATE INDEX idx_posts_author_published ON posts(author_id, published_at)
WHERE published = true;
---
4. エラーハンドリングとカスタムスカラー
エラーハンドリングのパターン
Partial Errors(部分的エラー)
GraphQLでは、一部のフィールドでエラーが発生しても、他のフィールドのデータは返すことができます:
func (r *queryResolver) Dashboard(ctx context.Context) (*Dashboard, error) {
dashboard := &Dashboard{}
// ユーザー情報取得
user, err := r.db.GetCurrentUser(ctx)
if err != nil {
// エラーでも続行
log.Printf("failed to get user: %v", err)
dashboard.User = nil
} else {
dashboard.User = user
}
// 投稿一覧取得
posts, err := r.db.GetRecentPosts(ctx)
if err != nil {
log.Printf("failed to get posts: %v", err)
dashboard.Posts = []*Post{}
} else {
dashboard.Posts = posts
}
return dashboard, nil
}
レスポンス例:
{
"data": {
"dashboard": {
"user": null,
"posts": [
{"title": "Post 1"},
{"title": "Post 2"}
]
}
},
"errors": [
{
"message": "failed to get user",
"path": ["dashboard", "user"]
}
]
}
エラーの拡張情報
import "github.com/vektah/gqlparser/v2/gqlerror"
func (r *mutationResolver) CreatePost(ctx context.Context, input model.NewPost) (*Post, error) {
if err := validatePost(input); err != nil {
return nil, &gqlerror.Error{
Message: "Validation failed",
Extensions: map[string]interface{}{
"code": "VALIDATION_ERROR",
"validationErrors": []map[string]string{
{"field": "title", "message": "Title is required"},
{"field": "content", "message": "Content must be at least 10 characters"},
},
},
}
}
// ...
}
カスタムスカラーの実装
DateTime スカラー
package scalars
import (
"fmt"
"io"
"strconv"
"time"
"github.com/99designs/gqlgen/graphql"
)
func MarshalDateTime(t time.Time) graphql.Marshaler {
return graphql.WriterFunc(func(w io.Writer) {
// RFC3339形式でシリアライズ
io.WriteString(w, strconv.Quote(t.Format(time.RFC3339)))
})
}
func UnmarshalDateTime(v interface{}) (time.Time, error) {
switch v := v.(type) {
case string:
return time.Parse(time.RFC3339, v)
case int64:
return time.Unix(v, 0), nil
default:
return time.Time{}, fmt.Errorf("%T is not a valid DateTime", v)
}
}
Email スカラー(バリデーション付き)
package scalars
import (
"fmt"
"io"
"regexp"
"github.com/99designs/gqlgen/graphql"
)
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}GraphQL API - 解説
本解説では、gqlgenの内部動作、設計のベストプラクティス、パフォーマンス最適化、そして発展的学習への道筋を詳しく説明します。
---
1. gqlgenの内部動作とコード生成の仕組み
gqlgenとは
gqlgenは、GraphQLスキーマからGoのコードを自動生成する「スキーマファースト」型のGraphQLライブラリです。他のアプローチ(コードファーストなど)と比較して、以下の利点があります:
- 型安全性: スキーマとコードの不整合が起こらない
- ドキュメント: スキーマ自体が仕様書として機能
- 開発速度: ボイラープレートコードの自動生成
コード生成のプロセス
ステップ1: スキーマ解析
gqlgenは、.graphqlファイルを解析し、抽象構文木(AST)を構築します。
type User {
id: ID!
name: String!
}
type Query {
user(id: ID!): User
}
この定義から、gqlgenは以下の情報を抽出します:
型定義(User)
フィールド(id, name)
フィールドの型(ID!, String!)
クエリ定義(user)
引数(id: ID!) ステップ2: Goコードの生成
gqlgenは、gqlgen.ymlの設定に基づいて、以下のファイルを生成します:
1. generated.go(実行エンジン)
// 自動生成された実行エンジン
type ExecutableSchema struct {
resolvers ResolverRoot
directives DirectiveRoot
complexity ComplexityRoot
}
func (e *ExecutableSchema) Schema() *ast.Schema {
return parsedSchema
}
func (e *ExecutableSchema) Complexity(query string) int {
// クエリの複雑度を計算
}
このファイルには、GraphQLクエリを解析・実行するためのエンジンコードが含まれます。
2. models_gen.go(型定義)
// スキーマから生成された型
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
スキーマ内の各型に対応するGo構造体が生成されます。
3. resolver_interface.go(リゾルバーインターフェース)
type QueryResolver interface {
User(ctx context.Context, id string) (*User, error)
}
type UserResolver interface {
// フィールドリゾルバー(必要な場合のみ)
}
開発者が実装すべきインターフェースが定義されます。
ステップ3: リゾルバーのスタブ生成
初回実行時、gqlgenはリゾルバーのスタブファイルも生成します:
// schema.resolvers.go
func (r *queryResolver) User(ctx context.Context, id string) (*User, error) {
panic(fmt.Errorf("not implemented"))
}
開発者は、このスタブを実際のロジックに置き換えます。
gqlgen.ymlの詳細設定
モデルのカスタマイズ
既存のデータベースモデルを使用する場合:
models:
User:
model:
- github.com/yourapp/db.User # 既存のモデルを指定
fields:
id:
resolver: true # IDフィールドにカスタムリゾルバーを使用
これにより、GraphQLのUser型が、既存のdb.User構造体にマッピングされます。
スカラー型のカスタマイズ
カスタムスカラー型の実装:
models:
DateTime:
model:
- time.Time
JSON:
model:
- github.com/yourapp/types.JSON
カスタムスカラー型には、Marshal/Unmarshalロジックを実装する必要があります:
package types
import (
"encoding/json"
"fmt"
"io"
"github.com/99designs/gqlgen/graphql"
)
type JSON map[string]interface{}
func MarshalJSON(val JSON) graphql.Marshaler {
return graphql.WriterFunc(func(w io.Writer) {
data, _ := json.Marshal(val)
w.Write(data)
})
}
func UnmarshalJSON(v interface{}) (JSON, error) {
switch v := v.(type) {
case map[string]interface{}:
return JSON(v), nil
default:
return nil, fmt.Errorf("unexpected type %T", v)
}
}
リゾルバーの実行フロー
クエリが実行される際の内部フロー:
1. クライアントからクエリ受信
↓
2. クエリの構文解析(Parsing)
↓
3. バリデーション
- スキーマとの整合性チェック
- フィールドの存在確認
- 型の整合性確認
↓
4. 実行計画の構築
↓
5. リゾルバーの並列実行
- フィールドごとにgoroutineで実行
↓
6. 結果のマージとレスポンス生成
並列実行の仕組み
gqlgenは、依存関係のないフィールドを並列に実行します:
query {
user(id: "1") {
name # 並列実行
email # 並列実行
posts { # 並列実行
title
}
}
}
上記のクエリでは、name、email、postsの各リゾルバーがgoroutineで並列実行されます。
---
2. Resolver設計のベストプラクティス
リゾルバーの責務分離
アンチパターン:リゾルバーにビジネスロジックを詰め込む
// ❌ 悪い例
func (r *mutationResolver) CreatePost(ctx context.Context, input model.NewPost) (*Post, error) {
// リゾルバー内でバリデーション、DB操作、通知など全てを実装
if len(input.Title) < 5 {
return nil, errors.New("title too short")
}
tx, _ := r.db.Begin()
post := &Post{...}
tx.Insert(post)
tx.Commit()
r.emailService.NotifyFollowers(post)
r.searchEngine.Index(post)
return post, nil
}
ベストプラクティス:レイヤーの分離
// ✅ 良い例
func (r *mutationResolver) CreatePost(ctx context.Context, input model.NewPost) (*Post, error) {
// リゾルバーは薄く保ち、サービス層に委譲
userID := ctx.Value("userID").(string)
post, err := r.postService.Create(ctx, userID, input)
if err != nil {
return nil, err
}
return post, nil
}
// サービス層(別ファイル)
func (s *PostService) Create(ctx context.Context, userID string, input model.NewPost) (*Post, error) {
// バリデーション
if err := s.validator.ValidatePost(input); err != nil {
return nil, err
}
// トランザクション開始
tx, err := s.db.BeginTx(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback()
// ビジネスロジック
post := &Post{
Title: input.Title,
Content: input.Content,
AuthorID: userID,
}
if err := tx.Insert(post); err != nil {
return nil, err
}
// 関連処理
go s.notificationService.NotifyFollowers(post)
go s.searchService.Index(post)
tx.Commit()
return post, nil
}
エラーハンドリングのベストプラクティス
カスタムエラー型の定義
// errors.go
package apperrors
import "github.com/vektah/gqlparser/v2/gqlerror"
var (
ErrNotFound = &gqlerror.Error{
Message: "Resource not found",
Extensions: map[string]interface{}{
"code": "NOT_FOUND",
},
}
ErrUnauthorized = &gqlerror.Error{
Message: "Unauthorized",
Extensions: map[string]interface{}{
"code": "UNAUTHORIZED",
},
}
ErrValidation = func(field string, message string) *gqlerror.Error {
return &gqlerror.Error{
Message: "Validation error",
Extensions: map[string]interface{}{
"code": "VALIDATION_ERROR",
"field": field,
"message": message,
},
}
}
)
リゾルバーでの使用
func (r *queryResolver) Post(ctx context.Context, id string) (*Post, error) {
post, err := r.db.GetPostByID(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, apperrors.ErrNotFound
}
return nil, err
}
return post, nil
}
クライアント側では、以下のような構造化されたエラーを受け取ります:
{
"data": null,
"errors": [
{
"message": "Resource not found",
"path": ["post"],
"extensions": {
"code": "NOT_FOUND"
}
}
]
}
コンテキストの活用
リクエストスコープデータの管理
type contextKey string
const (
userIDKey contextKey = "userID"
dataloaderKey contextKey = "dataloader"
tracerKey contextKey = "tracer"
)
// コンテキストへの設定
func WithUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, userIDKey, userID)
}
// コンテキストからの取得
func GetUserID(ctx context.Context) (string, bool) {
userID, ok := ctx.Value(userIDKey).(string)
return userID, ok
}
// リゾルバーでの使用
func (r *mutationResolver) CreatePost(ctx context.Context, input model.NewPost) (*Post, error) {
userID, ok := GetUserID(ctx)
if !ok {
return nil, apperrors.ErrUnauthorized
}
// ...
}
タイムアウトとキャンセル処理
func (r *queryResolver) ExpensiveQuery(ctx context.Context) (*Result, error) {
// タイムアウト設定
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
resultCh := make(chan *Result)
errCh := make(chan error)
go func() {
result, err := r.service.ComputeExpensiveResult(ctx)
if err != nil {
errCh <- err
return
}
resultCh <- result
}()
select {
case result := <-resultCh:
return result, nil
case err := <-errCh:
return nil, err
case <-ctx.Done():
return nil, errors.New("query timeout")
}
}
---
3. パフォーマンス最適化
N+1問題の徹底解決
問題の可視化
まず、どこでN+1問題が発生しているかを特定します:
// ミドルウェアでDBクエリ数をカウント
func QueryCounterMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
counter := &QueryCounter{count: 0}
ctx := context.WithValue(r.Context(), "queryCounter", counter)
next.ServeHTTP(w, r.WithContext(ctx))
log.Printf("Request: %s, Queries: %d", r.URL.Path, counter.count)
})
}
Dataloaderの高度な使用
バッチサイズの調整
userLoader := dataloadgen.NewLoader(
batchFunc,
dataloadgen.WithBatchCapacity(100), // 一度に最大100件
dataloadgen.WithWait(1*time.Millisecond), // 1ms待機してバッチ化
dataloadgen.WithCache(cache), // カスタムキャッシュ実装
)
キャッシュのクリア
func (r *mutationResolver) UpdateUser(ctx context.Context, input model.UpdateUser) (*User, error) {
user, err := r.service.UpdateUser(ctx, input)
if err != nil {
return nil, err
}
// Dataloaderのキャッシュをクリア
loaders := GetLoaders(ctx)
loaders.UserLoader.Clear(ctx, user.ID)
return user, nil
}
クエリの複雑性制限
動的複雑度計算
// gqlgen.yml
complexity:
Query:
posts: 10
Post:
comments: 5
author: 1
User:
posts: 10
// カスタム複雑度関数
func PostsComplexity(childComplexity int, first *int, after *string) int {
limit := 10
if first != nil {
limit = *first
}
// 取得件数 × 子フィールドの複雑度
return limit * childComplexity
}
実装例
srv := handler.NewDefaultServer(generated.NewExecutableSchema(cfg))
// 複雑度制限
srv.Use(extension.FixedComplexityLimit(1000))
// カスタム複雑度計算
srv.AroundFields(func(ctx context.Context, next graphql.Resolver) (interface{}, error) {
fc := graphql.GetFieldContext(ctx)
// フィールドごとの複雑度を記録
complexity := calculateComplexity(fc)
log.Printf("Field: %s, Complexity: %d", fc.Field.Name, complexity)
return next(ctx)
})
キャッシュ戦略
フィールドレベルキャッシュ
func (r *queryResolver) PopularPosts(ctx context.Context) ([]*Post, error) {
cacheKey := "popular_posts"
// Redisキャッシュをチェック
if cached, err := r.cache.Get(ctx, cacheKey); err == nil {
var posts []*Post
if err := json.Unmarshal(cached, &posts); err == nil {
return posts, nil
}
}
// キャッシュミス:DBから取得
posts, err := r.db.GetPopularPosts(ctx)
if err != nil {
return nil, err
}
// キャッシュに保存(5分間)
if data, err := json.Marshal(posts); err == nil {
r.cache.Set(ctx, cacheKey, data, 5*time.Minute)
}
return posts, nil
}
Automatic Persisted Queries (APQ)
APQは、クエリ文字列をハッシュ化してネットワーク転送量を削減する手法です:
import "github.com/99designs/gqlgen/graphql/handler/extension"
srv.Use(extension.AutomaticPersistedQuery{
Cache: lru.New(100), // LRUキャッシュで100件保持
})
動作の流れ:
クライアント: クエリのSHA256ハッシュを送信
サーバー: ハッシュに対応するクエリをキャッシュから検索
ヒット: キャッシュされたクエリを実行
ミス: クライアントに完全なクエリ送信を要求 メリット:
- ネットワーク転送量が平均70%削減
- CDNでのキャッシュが容易
データベースクエリ最適化
Select N+1の回避
// ❌ 悪い例:各投稿ごとにコメント数をカウント
func (r *postResolver) CommentCount(ctx context.Context, obj *Post) (int, error) {
return r.db.CountComments(ctx, obj.ID) // N+1問題
}
// ✅ 良い例:JOINで一括取得
func (r *queryResolver) Posts(ctx context.Context) ([]*Post, error) {
query := `
SELECT
p.id, p.title, p.content,
COUNT(c.id) as comment_count
FROM posts p
LEFT JOIN comments c ON p.id = c.post_id
GROUP BY p.id
`
return r.db.Query(ctx, query)
}
インデックスの活用
-- 頻繁に検索されるフィールドにインデックス
CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_posts_published_at ON posts(published_at) WHERE published = true;
CREATE INDEX idx_comments_post_id ON comments(post_id);
-- 複合インデックス
CREATE INDEX idx_posts_author_published ON posts(author_id, published_at)
WHERE published = true;
---
4. エラーハンドリングとカスタムスカラー
エラーハンドリングのパターン
Partial Errors(部分的エラー)
GraphQLでは、一部のフィールドでエラーが発生しても、他のフィールドのデータは返すことができます:
func (r *queryResolver) Dashboard(ctx context.Context) (*Dashboard, error) {
dashboard := &Dashboard{}
// ユーザー情報取得
user, err := r.db.GetCurrentUser(ctx)
if err != nil {
// エラーでも続行
log.Printf("failed to get user: %v", err)
dashboard.User = nil
} else {
dashboard.User = user
}
// 投稿一覧取得
posts, err := r.db.GetRecentPosts(ctx)
if err != nil {
log.Printf("failed to get posts: %v", err)
dashboard.Posts = []*Post{}
} else {
dashboard.Posts = posts
}
return dashboard, nil
}
レスポンス例:
{
"data": {
"dashboard": {
"user": null,
"posts": [
{"title": "Post 1"},
{"title": "Post 2"}
]
}
},
"errors": [
{
"message": "failed to get user",
"path": ["dashboard", "user"]
}
]
}
エラーの拡張情報
import "github.com/vektah/gqlparser/v2/gqlerror"
func (r *mutationResolver) CreatePost(ctx context.Context, input model.NewPost) (*Post, error) {
if err := validatePost(input); err != nil {
return nil, &gqlerror.Error{
Message: "Validation failed",
Extensions: map[string]interface{}{
"code": "VALIDATION_ERROR",
"validationErrors": []map[string]string{
{"field": "title", "message": "Title is required"},
{"field": "content", "message": "Content must be at least 10 characters"},
},
},
}
}
// ...
}
カスタムスカラーの実装
DateTime スカラー
package scalars
import (
"fmt"
"io"
"strconv"
"time"
"github.com/99designs/gqlgen/graphql"
)
func MarshalDateTime(t time.Time) graphql.Marshaler {
return graphql.WriterFunc(func(w io.Writer) {
// RFC3339形式でシリアライズ
io.WriteString(w, strconv.Quote(t.Format(time.RFC3339)))
})
}
func UnmarshalDateTime(v interface{}) (time.Time, error) {
switch v := v.(type) {
case string:
return time.Parse(time.RFC3339, v)
case int64:
return time.Unix(v, 0), nil
default:
return time.Time{}, fmt.Errorf("%T is not a valid DateTime", v)
}
}
Email スカラー(バリデーション付き)
)
type Email string
func MarshalEmail(e Email) graphql.Marshaler {
return graphql.WriterFunc(func(w io.Writer) {
io.WriteString(w, strconv.Quote(string(e)))
})
}
func UnmarshalEmail(v interface{}) (Email, error) {
str, ok := v.(string)
if !ok {
return "", fmt.Errorf("%T is not a string", v)
}
if !emailRegex.MatchString(str) {
return "", fmt.Errorf("%s is not a valid email address", str)
}
return Email(str), nil
}
スキーマでの使用:
scalar Email
type User {
id: ID!
email: Email!
}
---
5. 発展的学習への道筋
GraphQL Federation(マイクロサービス統合)
複数のGraphQLサービスを統合して、単一のGraphQLエンドポイントとして公開する手法です。
サービスの分割例
User Service:
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
}
extend type Query {
user(id: ID!): User
}
Post Service:
extend type User @key(fields: "id") {
id: ID! @external
posts: [Post!]!
}
type Post {
id: ID!
title: String!
author: User!
}
Gateway(統合エンドポイント):
クライアントは単一のエンドポイントにクエリを送信:
query {
user(id: "1") {
name # User Serviceから取得
email # User Serviceから取得
posts { # Post Serviceから取得
title
}
}
}
GraphQL Subscriptions(リアルタイム通信)
WebSocketを使用したリアルタイムデータ配信。
実装例
func (r *subscriptionResolver) MessageAdded(ctx context.Context, chatID string) (<-chan *Message, error) {
messages := make(chan *Message, 1)
// Redisのpub/subを購読
subscriber := r.redis.Subscribe(ctx, "chat:"+chatID)
go func() {
defer close(messages)
for {
select {
case <-ctx.Done():
return
case msg := <-subscriber.Channel():
var message Message
json.Unmarshal([]byte(msg.Payload), &message)
select {
case messages <- &message:
case <-ctx.Done():
return
}
}
}
}()
return messages, nil
}
クライアント側(JavaScript):
const subscription = client.subscribe({
query: gql`
subscription {
messageAdded(chatID: "chat123") {
id
text
author {
name
}
}
}
`
});
subscription.subscribe({
next: ({ data }) => {
console.log('New message:', data.messageAdded);
}
});
Persisted Queries(セキュリティ向上)
事前に許可されたクエリのみを実行可能にし、任意のクエリ実行を防ぐ手法。
import "github.com/99designs/gqlgen/graphql/handler/extension"
// 許可されたクエリのホワイトリスト
allowedQueries := map[string]string{
"GetUser": `query GetUser($id: ID!) { user(id: $id) { name email } }`,
"ListPosts": `query ListPosts { posts { title author { name } } }`,
}
srv.Use(&extension.PersistedQueries{
Queries: allowedQueries,
})
学習ロードマップ
フェーズ1: 基礎固め(1-2ヶ月)
- gqlgenでのCRUD実装
- Dataloader実装
- 認証・認可の実装
- テストの作成
フェーズ2: 実践(2-3ヶ月)
- 本番環境へのデプロイ
- パフォーマンスモニタリング
- エラーハンドリングの洗練
- GraphQL Playground / GraphiQLのカスタマイズ
フェーズ3: 応用(3-6ヶ月)
- GraphQL Federationの導入
- Subscriptionsの実装
- APQ(Automatic Persisted Queries)
- カスタムディレクティブの実装
フェーズ4: エキスパート(6ヶ月以上)
- スキーマ設計のコンサルティング
- パフォーマンスチューニングの専門化
- GraphQL Gatewayの構築
- OSSへの貢献
- gqlgenの理解: コード生成の仕組みを理解し、適切に設定する
- Resolver設計: 責務を分離し、保守性の高いコードを書く
- パフォーマンス: N+1問題、キャッシュ、クエリ最適化を徹底する
- エラーハンドリング: 構造化されたエラーレスポンスを設計する
- 発展的学習: Federation、Subscriptions、Persisted Queriesへと進む
---
まとめ
GraphQL APIの実装において、以下が重要です:
これらを段階的に習得することで、実務で通用するGraphQLエンジニアになれます。