Goのgqlgen入門:dataloadgenでN+1を潰すスキーマファースト設計

Goのgqlgen入門:dataloadgenでN+1を潰すスキーマファースト設計 | mohablog

gqlgen の公式 Recipes に “Optimizing N+1 database queries using Dataloaders” というページがあります。中で紹介される loader が vikstrous/dataloadgen に切り替わっているのに、検索上位の日本語記事は graph-gophers/dataloader 時代のままですよね。

v0.17 系(執筆時 v0.17.90)で生成コードの形が変わり、resolver の書き方も追従しています。古いサンプルをコピペすると generated.go が再生成されたタイミングで差分が消える構造。スキーマファースト設計の境界と、dataloadgen 連携の構成を実装ベースで整理します。

目次

gqlgenが分けるgenerated.goとresolver.goの境界

gqlgen は schema-first のコードジェネレータです。schema.graphqls を書いて go generate を叩くと、ランタイムと型は generated.go に、ビジネスロジックの差し込み口は resolver.go に分かれて出力されます。

再生成されるファイルと手書きファイル

境界を間違えると差分が消えます。役割を整理します。

ファイル性質編集可否
graph/generated.goスキーマから自動生成。実行ランタイム・引数のバインディング・エラーハンドリングが入る編集禁止。再生成で上書き
graph/model/models_gen.goスキーマ型からの構造体定義。autobind で外部 model に差し替え可能編集禁止。再生成で上書き
graph/resolver.goroot resolver の type Resolver struct{} 定義。DI 用フィールドの置き場手書き。再生成では上書きされない
graph/schema.resolvers.go各 Query/Mutation の本体実装。新しい field を schema に足すと自動追記される手書き。既存メソッドは保持

resolver.goにDIを差し込む設計

resolver は再生成でも上書きされないため、DB クライアントや service レイヤーをここに集約します。

// graph/resolver.go
package graph

import (
	"database/sql"
	"example.com/api/internal/loader"
)

type Resolver struct {
	DB        *sql.DB
	Loaders   *loader.Loaders
}
$ go run github.com/99designs/gqlgen generate
# generated.go と schema.resolvers.go は更新されるが
# resolver.go の DB / Loaders フィールドは保持される

スキーマ1ファイルからサーバーが立ち上がるまで

新規プロジェクトで動くまでの最小構成を見ます。gqlgen init で雛形が出力されますが、付随する tools.gogqlgen.yml の役割は把握しておく必要があります。

初期化と最小スキーマ

go mod init example.com/api
go get -tool github.com/99designs/gqlgen@latest
go run github.com/99designs/gqlgen init

初期化で graph/schema.graphqls, gqlgen.yml, server.go, tools.go が出力されます。スキーマを書き換えてみます。

# graph/schema.graphqls
type User {
  id: ID!
  name: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  authorID: ID!
}

type Query {
  posts: [Post!]!
}
go run github.com/99designs/gqlgen generate
go run ./server.go
# => 2026/05/08 12:00:00 connect to http://localhost:8080/ for GraphQL playground

Playground を開いて { posts { id title } } を投げると JSON が返ります。ただし posts の中で author をネストすると挙動が変わります。

resolverのシグネチャで詰まるポインタとスライス

生成された resolver は gqlgen.yml の設定でシグネチャが変わります。デフォルト設定だと [Post!]![]*Post に展開され、[Post] なら []*Post に nil が混じる可能性が出ます。スキーマの NonNull 表現とスライス要素のポインタ可否は別物。混同するとコンパイラは通ってもランタイムで落ちます。

2つの設定キーで挙動を切り替える

# gqlgen.yml
resolvers_always_return_pointers: true
omit_slice_element_pointers: false

resolvers_always_return_pointers: true(デフォルト)では、resolver の戻り値が常に *Post*[]*Post になります。プロジェクトで pointer を嫌うなら false にすると Post[]Post が返り値になりますが、resolver の途中で nil を返したいケースで詰まります。

典型的なコンパイルエラー

// 誤: 戻り値の形がスキーマと一致していない
func (r *queryResolver) Posts(ctx context.Context) ([]Post, error) {
	return r.svc.ListPosts(ctx) // []*Post を返している
}
./schema.resolvers.go:42:9: cannot use posts (variable of type []*model.Post)
  as type []model.Post in return statement

このエラーが出たら、まず gqlgen.yml のスライス・ポインタ設定とスキーマの ! を見直すのが早道です。

dataloadgenでN+1を潰す

posts 各要素から author をフェッチするクエリが入ると、Post resolver の Author field が件数分ループで走ります。10件で 11 クエリ、100件で 101 クエリ。この件数比例のクエリ発行が GraphQL の N+1 です。

dataloadgenを公式が推奨に切り替えた背景

v0.17 系で gqlgen の Recipes が graph-gophers/dataloader から vikstrous/dataloadgen に書き換わりました。dataloadgen は Go 1.18 以降のジェネリクスを活用し、型アサーションが不要です。any でラップしないので、batch 関数の戻り値の型がそのまま使えます。

go get github.com/vikstrous/dataloadgen

middlewareでcontextにloaderを注入する

// internal/loader/loader.go
package loader

import (
	"context"
	"net/http"
	"time"

	"example.com/api/graph/model"
	"github.com/vikstrous/dataloadgen"
)

type ctxKey int

const loadersKey ctxKey = 0

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

func Middleware(repo UserRepo, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		loaders := &Loaders{
			UserByID: dataloadgen.NewLoader(
				func(ctx context.Context, ids []string) ([]*model.User, []error) {
					return repo.UsersByIDs(ctx, ids) // WHERE id IN (?)
				},
				dataloadgen.WithWait(2*time.Millisecond),
			),
		}
		ctx := context.WithValue(r.Context(), loadersKey, loaders)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func For(ctx context.Context) *Loaders {
	return ctx.Value(loadersKey).(*Loaders)
}

resolverからLoadを呼ぶだけ

// graph/schema.resolvers.go
func (r *postResolver) Author(ctx context.Context, obj *model.Post) (*model.User, error) {
	return loader.For(ctx).UserByID.Load(ctx, obj.AuthorID)
}

処理の流れは次の通り。Load を呼ぶたびに ID がバッファに溜まり、WithWait の閾値(ここでは 2ms)が経過するか、すべての resolver の同期処理が止まったタイミングで一括フェッチされます。

$ curl -s -X POST http://localhost:8080/query \
  -H 'Content-Type: application/json' \
  -d '{"query":"{ posts { id title author { name } } }"}'

# 実行されるSQL(ログ抜粋)
SELECT id, title, author_id FROM posts;
SELECT id, name FROM users WHERE id IN ('u01','u02','u03','u04','u05');
# => 100件取得しても2クエリで完了

graph-gophers版との違い

古い記事との切り分けのため、両者の違いを並べます。

項目graph-gophers/dataloader(旧)vikstrous/dataloadgen(v0.17推奨)
型表現dataloader.Result + 型アサーションジェネリクスで Loader[K, V]
batch関数の戻り値[]*dataloader.Result([]V, []error)
キャッシュ内蔵LRUリクエスト単位の単純Map
依存Go 1.13+Go 1.18+ (generics)

graph-gophers 版は今も動きますが、新規実装で採用する理由は薄くなっています。

@hasRoleディレクティブで認可を埋め込む

認可は middleware でも書けますが、field 単位の制御はディレクティブが向きます。スキーマに権限要件を載せるとフロントエンドにも要求が伝わるのが利点です。

schemaで宣言する

directive @hasRole(role: Role!) on FIELD_DEFINITION

enum Role {
  ADMIN
  USER
}

type Mutation {
  deletePost(id: ID!): Boolean! @hasRole(role: ADMIN)
}

Configに実装を登録する

// server.go
c := generated.Config{Resolvers: &graph.Resolver{DB: db}}
c.Directives.HasRole = func(
	ctx context.Context,
	obj interface{},
	next graphql.Resolver,
	role model.Role,
) (interface{}, error) {
	u, ok := auth.UserFor(ctx)
	if !ok {
		return nil, fmt.Errorf("unauthenticated")
	}
	if u.Role != role {
		return nil, fmt.Errorf("forbidden: requires %s", role)
	}
	return next(ctx)
}
# 一般ユーザーで叩く
$ curl -s -X POST http://localhost:8080/query -H 'Authorization: Bearer USER_TOKEN' \
  -d '{"query":"mutation { deletePost(id:\"p1\") }"}'
{"errors":[{"message":"forbidden: requires ADMIN","path":["deletePost"]}],"data":null}

ミドルウェアでパス単位に書くより、スキーマ側で「この field は ADMIN だけ」と宣言できる差は記述量に効きます。複数 field に同じ要件があるときの行数が減り、フロントエンドの自動生成にも権限情報が乗ります。

gqlgen.ymlの設定キー早見

v0.17 系で初期化したときの主要キーをまとめます。生成挙動を変えたいときに見る場所。

キー役割典型値
schemaスキーマファイルの globgraph/*.graphqls
execgenerated.go の出力先graph/generated.go
model自動生成 model の出力先graph/model/models_gen.go
resolverresolver スケルトンの出力先graph/
autobind既存型を model に紐付けexample.com/api/internal/db
resolvers_always_return_pointersresolver 戻り値を pointer にするかtrue
omit_slice_element_pointersスライス要素のポインタを外すfalse
struct_tag生成 model に付与する struct tagjson

まとめ

  • gqlgen は generated.goresolver.go の境界を理解しないと再生成で差分が消える
  • v0.17 公式推奨の dataloader は vikstrous/dataloadgen。ジェネリクス対応で型アサーション不要
  • N+1 は middleware で context に loader を注入し、resolver から Load を呼ぶだけで一括フェッチに集約できる
  • field 単位の認可はミドルウェアより @hasRole ディレクティブが宣言的で読みやすい
  • resolver のシグネチャエラーは resolvers_always_return_pointersomit_slice_element_pointers を疑う

GORM の N+1 は SQL 側の対処(Preload など)が中心ですが、GraphQL ではリクエスト境界の loader でまとめるのが基本になります。GORM 編は GORMのN+1問題とマイグレーション設計 にまとめてあるので、ORM 側の打ち手と合わせて見ると違いが分かります。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次