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
        }
      }
    }
    

    上記のクエリでは、nameemailpostsの各リゾルバーが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 スカラー(バリデーション付き)

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
        }
      }
    }
    

    上記のクエリでは、nameemailpostsの各リゾルバーが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への貢献
  • ---

    まとめ

    GraphQL APIの実装において、以下が重要です:

  • gqlgenの理解: コード生成の仕組みを理解し、適切に設定する
  • Resolver設計: 責務を分離し、保守性の高いコードを書く
  • パフォーマンス: N+1問題、キャッシュ、クエリ最適化を徹底する
  • エラーハンドリング: 構造化されたエラーレスポンスを設計する
  • 発展的学習: Federation、Subscriptions、Persisted Queriesへと進む

これらを段階的に習得することで、実務で通用するGraphQLエンジニアになれます。