mcpField
G
go-graphql

go-graphql

github.com/eduardo-sl開発8368 回インストール2 週間前に更新
リポジトリ

go-graphql について

go-graphqlは、gqlgenでGraphQL APIを構築するGo開発者向けのガイダンススキルです。スキーマ先行開発、リゾルバーとデータローダー、クエリ制限、認可、エラー処理、テストを扱います。

主な機能

  1. 01gqlgenによるスキーマ先行開発
  2. 02依存性注入による薄いリゾルバー
  3. 03リクエスト単位のデータローダーでクエリを一括化
  4. 04複雑度、深度、ページネーションの制限
  5. 05フィールド単位の認可とエラー処理
  6. 06クエリ数を検証するスキーマ駆動テスト

活用シーン

  1. 01GoでGraphQL APIを構築
  2. 02データベースのN+1クエリを防止
  3. 03高コストまたは深いリクエストを制限
  4. 04リゾルバーの認可と一括処理をテスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/eduardo-sl/go-agent-skills --skill go-graphql

インストール後、Claude Code で/go-graphqlを入力して読み込まれたか確認します。

同梱ファイルreferences/errors-auth-testing.md

詳細

Go GraphQL

GraphQL ではクエリの計画をクライアント側で行います。これは便利な機能であると同時に危険でもあります。何気ないクエリがデータベースへの往復を一万回発生させることもあれば、権限が緩すぎるフィールドから別のテナントのデータが漏れることもあります。どちらもスキーマレビューではなく、サーバー側で解決する問題です。

詳細なリファレンス資料は、必要に応じて読み込んでください。

  • references/errors-auth-testing.md — エラープレゼンター、フィールド認可ディレクティブ、クエリ数のアサーションを含むクライアント駆動テスト。

以下の概要だけでは不十分な場合に限り、リファレンスファイルを読んでください。

1. スキーマファーストと gqlgen

.graphql スキーマが信頼できる唯一の情報源です。gqlgen はスキーマからモデル、リゾルバースタブ、実行レイヤーを生成します。

go get -tool github.com/99designs/gqlgen
go tool gqlgen init      # once
go tool gqlgen generate  # after every schema change
# gqlgen.yml — bind generated types to your own models
models:
  User:
    model: github.com/myorg/app/internal/domain.User
  ID:
    model:
      - github.com/99designs/gqlgen/graphql.ID
      - github.com/99designs/gqlgen/graphql.Int64

ドメイン型を明示的にバインドしてください。何もしないと、gqlgen は同じような貧血構造体を別途生成し、すべてのリゾルバーがマッピング関数になってしまいます。

生成コードをコミットし、古い状態のままなら CI が失敗するようにしてください。

go tool gqlgen generate && git diff --exit-code

generated.go と models_gen.go は編集しないでください。resolver.go と *.resolvers.go は自分で編集するファイルです。

2. リゾルバーは薄く保つ

リゾルバーは GraphQL リクエストをサービス呼び出しに変換します。ビジネスロジックも SQL も含めません。

func (r *queryResolver) User(ctx context.Context, id string) (*domain.User, error) {
    u, err := r.users.Find(ctx, id)
    if errors.Is(err, domain.ErrNotFound) {
        return nil, nil // nullable field: absent, not an error
    }
    if err != nil {
        return nil, fmt.Errorf("find user %s: %w", id, err)
    }
    return u, nil
}

依存関係は Resolver 構造体を通じて注入し、パッケージグローバル変数は決して使わないでください。

type Resolver struct {
    users  UserService
    orders OrderService
    loader *Loaders
}

常に ctx を伝播させてください。ctx にはリクエストの期限、認証済みのプリンシパル、リクエストごとのデータローダーが含まれます。

3. N+1 問題 — 重要なのはこれです

リスト型のフィールドリゾルバーは、要素ごとに一度実行されます。

// ❌ 1 query for the orders, then N queries for the users
func (r *orderResolver) Customer(ctx context.Context, obj *domain.Order) (*domain.User, error) {
    return r.users.Find(ctx, obj.CustomerID)
}

データローダーでバッチ処理します。データローダーは短い時間枠内に要求されたキーを集め、クエリを一度だけ実行します。

import "github.com/vikstrous/dataloadgen"

type Loaders struct {
    UserByID *dataloadgen.Loader[string, *domain.User]
}

func NewLoaders(s UserService) *Loaders {
    return &Loaders{
        UserByID: dataloadgen.NewLoader(func(ctx context.Context, ids []string) ([]*domain.User, []error) {
            return s.FindMany(ctx, ids) // ONE query for all ids
        }, dataloadgen.WithWait(time.Millisecond)),
    }
}

// ✅ 1 query for the orders, 1 for all customers
func (r *orderResolver) Customer(ctx context.Context, obj *domain.Order) (*domain.User, error) {
    return loadersFrom(ctx).UserByID.Load(ctx, obj.CustomerID)
}

ローダーはミドルウェアによって設定されるリクエストごとのものです。プロセス全体で共有するローダーはユーザー間でキャッシュを共有し、ユーザー間のデータ漏えいを引き起こします。

func withLoaders(svc UserService, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := context.WithValue(r.Context(), loadersKey{}, NewLoaders(svc))
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

バッチ関数は、渡されたキーの順序で結果を返し、キーが見つからない場合は nil のエントリーとエラーを返す必要があります。スライスを短くして返すと、すべての結果が気付かないうちにずれてしまいます。

4. すべてのクエリに上限を設ける

制限のないパブリックな GraphQL エンドポイントは、サービス拒否攻撃の入り口になります。

srv := handler.New(generated.NewExecutableSchema(cfg))
srv.AddTransport(transport.POST{})
srv.SetQueryCache(lru.New[*ast.QueryDocument](1000))
srv.Use(extension.FixedComplexityLimit(300))
srv.Use(extension.AutomaticPersistedQuery{Cache: lru.New[string](100)})
  • 複雑度の上限 — フィールドごとにコストを割り当てます。first の値が大きいリストフィールドには、より高いコストを設定します。まずは、最も遅い正当なクエリが上限未満に収まる数値を設定し、その後、計測してください。
  • 深さ — 再帰型(user { orders { customer { orders ... } } })には上限が必要です。gqlgen には組み込みの深さ制限がないため、オペレーションミドルウェアで適用してください。
  • ページネーションはすべてのリストフィールドで必須です。上限のないリストを返すフィールドは、スキーマのバグです。
  • イントロスペクションは extension.Introspection をインストールした場合にのみ有効になります。本番環境ではインストールしないか、認証済みのロールに限定してください。
  • 永続化クエリを使うと、パブリッククライアントはドキュメントの代わりにハッシュを送信できます。これにより、サーバーは配布済みのクエリだけを実行します。

srv.AroundOperations を設定してオペレーションごとのタイムアウトを適用し、ReadTimeout と WriteTimeout を設定した http.Server の背後で必ず実行してください。

5. エラー

GraphQL は errors 配列とともに 200 を返します。内部情報を決してその中に漏らさないでください。

srv.SetErrorPresenter をインストールし、既知のドメインエラー(検証エラー、未検出)を、メッセージと安定した extensions.code に変換してください。それ以外はすべてサーバー側に記録し、コード INTERNAL と internal server error を返します。例は references/errors-auth-testing.md を参照してください。

srv.SetRecoverFunc を使ってリゾルバーのパニックをエラーに変換し、接続が終了しないようにしてください。スタックトレースとともに記録します。

null 許容性のルールにも注意してください。非 null フィールドでエラーが発生すると、最も近い null 許容の祖先が null になります。フィールドを非 null にするのは、正当な理由で欠落する可能性がない場合に限ってください。

6. 認可はフィールド単位で行います

オブジェクト単位のチェックだけでは不十分です。クライアントは複数の経路からオブジェクトにアクセスできます。

機密性の高いフィールドに @hasRole(または同様の)ディレクティブを宣言し、コンテキスト内のプリンシパルに対するチェックとして実装します。詳細を含めずに access denied を返してください。例:references/errors-auth-testing.md。

GraphQL ハンドラーの前に、HTTP ミドルウェアで認証します。認可は、クエリ引数ではなくコンテキスト内のプリンシパルを使用して、ディレクティブまたはリゾルバーで行ってください。

7. テスト

gqlgen のテスト用 client で実際のスキーマを実行し、デコードしたレスポンスを検証します。データローダーを介するすべてのリゾルバーについて、クエリ数も検証してください。これが、N+1 の再発をダッシュボードではなくビルドで検出する唯一の方法です。例:references/errors-auth-testing.md。

検証チェックリスト

  1. スキーマを信頼できる唯一の情報源とし、生成ファイルをコミットして CI でチェックする
  2. gqlgen.yml を介して、生成された型をドメインモデルにバインドする
  3. リゾルバーにビジネスロジックを含めず、常に ctx を伝播する
  4. ID による取得を行うすべてのリストフィールドリゾルバーで、データローダーを使用する
  5. データローダーはリクエストごとに構築し、リクエスト間で共有しない
  6. バッチ関数はキーごとに1つの結果を、キーの順序どおりに返す
  7. 複雑度の上限と深さの上限を設定し、テストする
  8. すべてのリストフィールドをページネーションする
  9. 本番環境ではイントロスペクションを無効にするか、ロールで制限する
  10. エラープレゼンターで内部エラーを除去し、recover 関数を設定する
  11. コンテキスト内のプリンシパルに基づき、フィールドごとに認可を適用する
  12. 少なくとも1つのバッチ処理フィールドについて、クエリ数を検証するテストを用意する

コメント