GraphQL API - 解答例
本解答例では、gqlgenを使用した完全なGraphQL APIの実装を示します。ブログシステムを例に、Query、Mutation、Subscription、認証・認可、N+1問題の解決、テストまでを網羅します。
---
プロジェクト構成
blog-api/
├── graph/
│ ├── schema.graphqls # GraphQLスキーマ定義
│ ├── schema.resolvers.go # リゾルバー実装
│ ├── generated.go # 自動生成コード
│ └── model/ # モデル定義
├── dataloader/
│ └── dataloader.go # Dataloader実装
├── middleware/
│ └── auth.go # 認証ミドルウェア
├── database/
│ └── db.go # データベース接続
├── gqlgen.yml # gqlgen設定
├── go.mod
├── go.sum
└── server.go # エントリーポイント
---
1. GraphQLスキーマ定義
schema.graphqls
# ===== スカラー型の定義 =====
scalar DateTime
scalar Upload
# ===== ユーザー型 =====
type User {
id: ID!
name: String!
email: String!
bio: String
avatarUrl: String
posts(first: Int, after: String): PostConnection!
comments: [Comment!]!
createdAt: DateTime!
updatedAt: DateTime!
}
# ===== 投稿型 =====
type Post {
id: ID!
title: String!
content: String!
published: Boolean!
author: User!
comments(first: Int, after: String): CommentConnection!
tags: [Tag!]!
viewCount: Int!
likeCount: Int!
createdAt: DateTime!
updatedAt: DateTime!
}
# ===== コメント型 =====
type Comment {
id: ID!
content: String!
author: User!
post: Post!
createdAt: DateTime!
updatedAt: DateTime!
}
# ===== タグ型 =====
type Tag {
id: ID!
name: String!
posts(first: Int, after: String): PostConnection!
}
# ===== ページネーション(Relay Connection仕様) =====
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type CommentConnection {
edges: [CommentEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type CommentEdge {
node: Comment!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# ===== Query =====
type Query {
# ユーザー関連
me: User
user(id: ID!): User
users(first: Int, after: String): [User!]!
# 投稿関連
post(id: ID!): Post
posts(
first: Int
after: String
published: Boolean
tag: String
search: String
): PostConnection!
# タグ関連
tag(id: ID!): Tag
tags: [Tag!]!
}
# ===== Mutation =====
type Mutation {
# ユーザー認証
signup(input: SignupInput!): AuthPayload!
login(input: LoginInput!): AuthPayload!
# ユーザー管理
updateUser(input: UpdateUserInput!): User!
uploadAvatar(file: Upload!): User!
# 投稿管理
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
publishPost(id: ID!): Post!
# コメント管理
createComment(input: CreateCommentInput!): Comment!
updateComment(id: ID!, input: UpdateCommentInput!): Comment!
deleteComment(id: ID!): Boolean!
# いいね機能
likePost(postId: ID!): Post!
unlikePost(postId: ID!): Post!
}
# ===== Subscription =====
type Subscription {
postCreated: Post!
commentAdded(postId: ID!): Comment!
}
# ===== Input型 =====
input SignupInput {
name: String!
email: String!
password: String!
}
input LoginInput {
email: String!
password: String!
}
input UpdateUserInput {
name: String
bio: String
}
input CreatePostInput {
title: String!
content: String!
tags: [String!]
published: Boolean
}
input UpdatePostInput {
title: String
content: String
tags: [String!]
published: Boolean
}
input CreateCommentInput {
postId: ID!
content: String!
}
input UpdateCommentInput {
content: String!
}
# ===== 認証レスポンス =====
type AuthPayload {
token: String!
user: User!
}
# ===== ディレクティブ =====
directive @requireAuth on FIELD_DEFINITION
directive @hasRole(role: String!) on FIELD_DEFINITION
---
2. gqlgen設定
gqlgen.yml
# スキーマファイルの場所
schema:
- graph/schema.graphqls
# 生成されるコードの出力先
exec:
filename: graph/generated.go
package: graph
# モデルの出力先
model:
filename: graph/model/models_gen.go
package: model
# リゾルバーの出力先
resolver:
layout: follow-schema
dir: graph
package: graph
filename_template: "{name}.resolvers.go"
# スカラー型のマッピング
models:
ID:
model:
- github.com/99designs/gqlgen/graphql.ID
Int:
model:
- github.com/99designs/gqlgen/graphql.Int
DateTime:
model:
- time.Time
Upload:
model:
- github.com/99designs/gqlgen/graphql.Upload
# 既存のモデル型を使用する場合
User:
model:
- blog-api/database.User
Post:
model:
- blog-api/database.Post
Comment:
model:
- blog-api/database.Comment
# 自動生成をスキップするリゾルバー
skip_runtime:
- Post.author
- Comment.author
- User.posts
---
3. リゾルバー実装
graph/schema.resolvers.go
package graph
import (
"blog-api/database"
"blog-api/dataloader"
"blog-api/graph/model"
"context"
"errors"
"fmt"
"strconv"
"time"
"golang.org/x/crypto/bcrypt"
)
// Resolver はルートリゾルバー
type Resolver struct {
DB *database.DB
DataLoader *dataloader.Loaders
Subscribers map[string]chan *model.Post
}
// ===== Query Resolvers =====
func (r *queryResolver) Me(ctx context.Context) (*database.User, error) {
// コンテキストから現在のユーザーを取得
userID := ctx.Value("userID")
if userID == nil {
return nil, errors.New("not authenticated")
}
user, err := r.DB.GetUserByID(ctx, userID.(string))
if err != nil {
return nil, err
}
return user, nil
}
func (r *queryResolver) User(ctx context.Context, id string) (*database.User, error) {
return r.DB.GetUserByID(ctx, id)
}
func (r *queryResolver) Users(ctx context.Context, first *int, after *string) ([]*database.User, error) {
limit := 10
if first != nil {
limit = *first
}
offset := 0
if after != nil {
offset, _ = strconv.Atoi(*after)
}
return r.DB.GetUsers(ctx, limit, offset)
}
func (r *queryResolver) Post(ctx context.Context, id string) (*database.Post, error) {
// 閲覧数をインクリメント
if err := r.DB.IncrementViewCount(ctx, id); err != nil {
// エラーはログに記録するが、クエリは継続
fmt.Printf("failed to increment view count: %v\n", err)
}
return r.DB.GetPostByID(ctx, id)
}
func (r *queryResolver) Posts(
ctx context.Context,
first *int,
after *string,
published *bool,
tag *string,
search *string,
) (*model.PostConnection, error) {
limit := 10
if first != nil {
limit = *first
}
offset := 0
if after != nil {
offset, _ = strconv.Atoi(*after)
}
// フィルター条件を構築
filter := database.PostFilter{
Published: published,
Tag: tag,
Search: search,
}
posts, total, err := r.DB.GetPosts(ctx, limit, offset, filter)
if err != nil {
return nil, err
}
// Relay Connection形式に変換
edges := make([]*model.PostEdge, len(posts))
for i, post := range posts {
edges[i] = &model.PostEdge{
Node: post,
Cursor: strconv.Itoa(offset + i + 1),
}
}
hasNextPage := offset+len(posts) < total
var endCursor *string
if len(edges) > 0 {
c := edges[len(edges)-1].Cursor
endCursor = &c
}
return &model.PostConnection{
Edges: edges,
PageInfo: &model.PageInfo{
HasNextPage: hasNextPage,
HasPreviousPage: offset > 0,
StartCursor: nil,
EndCursor: endCursor,
},
TotalCount: total,
}, nil
}
func (r *queryResolver) Tag(ctx context.Context, id string) (*database.Tag, error) {
return r.DB.GetTagByID(ctx, id)
}
func (r *queryResolver) Tags(ctx context.Context) ([]*database.Tag, error) {
return r.DB.GetAllTags(ctx)
}
// ===== Mutation Resolvers =====
func (r *mutationResolver) Signup(ctx context.Context, input model.SignupInput) (*model.AuthPayload, error) {
// パスワードをハッシュ化
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
// ユーザー作成
user := &database.User{
Name: input.Name,
Email: input.Email,
Password: string(hashedPassword),
}
if err := r.DB.CreateUser(ctx, user); err != nil {
return nil, err
}
// JWTトークン生成
token, err := generateToken(user.ID)
if err != nil {
return nil, err
}
return &model.AuthPayload{
Token: token,
User: user,
}, nil
}
func (r *mutationResolver) Login(ctx context.Context, input model.LoginInput) (*model.AuthPayload, error) {
user, err := r.DB.GetUserByEmail(ctx, input.Email)
if err != nil {
return nil, errors.New("invalid email or password")
}
// パスワード検証
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.Password)); err != nil {
return nil, errors.New("invalid email or password")
}
// JWTトークン生成
token, err := generateToken(user.ID)
if err != nil {
return nil, err
}
return &model.AuthPayload{
Token: token,
User: user,
}, nil
}
func (r *mutationResolver) UpdateUser(ctx context.Context, input model.UpdateUserInput) (*database.User, error) {
userID := ctx.Value("userID")
if userID == nil {
return nil, errors.New("not authenticated")
}
user, err := r.DB.GetUserByID(ctx, userID.(string))
if err != nil {
return nil, err
}
// 更新
if input.Name != nil {
user.Name = *input.Name
}
if input.Bio != nil {
user.Bio = input.Bio
}
if err := r.DB.UpdateUser(ctx, user); err != nil {
return nil, err
}
return user, nil
}
func (r *mutationResolver) CreatePost(ctx context.Context, input model.CreatePostInput) (*database.Post, error) {
userID := ctx.Value("userID")
if userID == nil {
return nil, errors.New("not authenticated")
}
published := false
if input.Published != nil {
published = *input.Published
}
post := &database.Post{
Title: input.Title,
Content: input.Content,
AuthorID: userID.(string),
Published: published,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := r.DB.CreatePost(ctx, post); err != nil {
return nil, err
}
// タグを関連付け
if input.Tags != nil {
for _, tagName := range input.Tags {
tag, err := r.DB.GetOrCreateTag(ctx, tagName)
if err != nil {
return nil, err
}
if err := r.DB.AddTagToPost(ctx, post.ID, tag.ID); err != nil {
return nil, err
}
}
}
// Subscriptionに通知
if published {
r.notifyPostCreated(post)
}
return post, nil
}
func (r *mutationResolver) UpdatePost(ctx context.Context, id string, input model.UpdatePostInput) (*database.Post, error) {
userID := ctx.Value("userID")
if userID == nil {
return nil, errors.New("not authenticated")
}
post, err := r.DB.GetPostByID(ctx, id)
if err != nil {
return nil, err
}
// 権限チェック:投稿者本人のみ更新可能
if post.AuthorID != userID.(string) {
return nil, errors.New("permission denied")
}
// 更新
if input.Title != nil {
post.Title = *input.Title
}
if input.Content != nil {
post.Content = *input.Content
}
if input.Published != nil {
post.Published = *input.Published
}
post.UpdatedAt = time.Now()
if err := r.DB.UpdatePost(ctx, post); err != nil {
return nil, err
}
return post, nil
}
func (r *mutationResolver) DeletePost(ctx context.Context, id string) (bool, error) {
userID := ctx.Value("userID")
if userID == nil {
return false, errors.New("not authenticated")
}
post, err := r.DB.GetPostByID(ctx, id)
if err != nil {
return false, err
}
// 権限チェック
if post.AuthorID != userID.(string) {
return false, errors.New("permission denied")
}
if err := r.DB.DeletePost(ctx, id); err != nil {
return false, err
}
return true, nil
}
func (r *mutationResolver) CreateComment(ctx context.Context, input model.CreateCommentInput) (*database.Comment, error) {
userID := ctx.Value("userID")
if userID == nil {
return nil, errors.New("not authenticated")
}
comment := &database.Comment{
PostID: input.PostID,
AuthorID: userID.(string),
Content: input.Content,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := r.DB.CreateComment(ctx, comment); err != nil {
return nil, err
}
return comment, nil
}
func (r *mutationResolver) LikePost(ctx context.Context, postID string) (*database.Post, error) {
userID := ctx.Value("userID")
if userID == nil {
return nil, errors.New("not authenticated")
}
if err := r.DB.AddLike(ctx, postID, userID.(string)); err != nil {
return nil, err
}
return r.DB.GetPostByID(ctx, postID)
}
// ===== Field Resolvers(N+1問題を回避) =====
func (r *postResolver) Author(ctx context.Context, obj *database.Post) (*database.User, error) {
// Dataloaderを使用してバッチ処理
return r.DataLoader.UserLoader.Load(ctx, obj.AuthorID)
}
func (r *postResolver) Comments(ctx context.Context, obj *database.Post, first *int, after *string) (*model.CommentConnection, error) {
limit := 10
if first != nil {
limit = *first
}
comments, err := r.DB.GetCommentsByPostID(ctx, obj.ID, limit)
if err != nil {
return nil, err
}
edges := make([]*model.CommentEdge, len(comments))
for i, comment := range comments {
edges[i] = &model.CommentEdge{
Node: comment,
Cursor: strconv.Itoa(i),
}
}
return &model.CommentConnection{
Edges: edges,
PageInfo: &model.PageInfo{
HasNextPage: false,
HasPreviousPage: false,
},
TotalCount: len(comments),
}, nil
}
func (r *postResolver) Tags(ctx context.Context, obj *database.Post) ([]*database.Tag, error) {
return r.DB.GetTagsByPostID(ctx, obj.ID)
}
func (r *commentResolver) Author(ctx context.Context, obj *database.Comment) (*database.User, error) {
return r.DataLoader.UserLoader.Load(ctx, obj.AuthorID)
}
func (r *userResolver) Posts(ctx context.Context, obj *database.User, first *int, after *string) (*model.PostConnection, error) {
limit := 10
if first != nil {
limit = *first
}
posts, total, err := r.DB.GetPostsByAuthorID(ctx, obj.ID, limit)
if err != nil {
return nil, err
}
edges := make([]*model.PostEdge, len(posts))
for i, post := range posts {
edges[i] = &model.PostEdge{
Node: post,
Cursor: strconv.Itoa(i),
}
}
return &model.PostConnection{
Edges: edges,
PageInfo: &model.PageInfo{
HasNextPage: false,
HasPreviousPage: false,
},
TotalCount: total,
}, nil
}
// ===== Subscription Resolvers =====
func (r *subscriptionResolver) PostCreated(ctx context.Context) (<-chan *database.Post, error) {
posts := make(chan *database.Post, 1)
// サブスクライバーを登録
id := fmt.Sprintf("%d", time.Now().UnixNano())
r.Subscribers[id] = posts
// コンテキストがキャンセルされたらクリーンアップ
go func() {
<-ctx.Done()
delete(r.Subscribers, id)
close(posts)
}()
return posts, nil
}
func (r *Resolver) notifyPostCreated(post *database.Post) {
for _, ch := range r.Subscribers {
select {
case ch <- post:
default:
// チャンネルがブロックされている場合はスキップ
}
}
}
// ===== ヘルパー関数 =====
func generateToken(userID string) (string, error) {
// 実際の実装ではJWTライブラリを使用
// ここでは簡略化のため固定値を返す
return "jwt_token_" + userID, nil
}
// ===== Resolver Type Assertions =====
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
func (r *Resolver) Post() PostResolver { return &postResolver{r} }
func (r *Resolver) Comment() CommentResolver { return &commentResolver{r} }
func (r *Resolver) User() UserResolver { return &userResolver{r} }
func (r *Resolver) Subscription() SubscriptionResolver {
return &subscriptionResolver{r}
}
type queryResolver struct{ *Resolver }
type mutationResolver struct{ *Resolver }
type postResolver struct{ *Resolver }
type commentResolver struct{ *Resolver }
type userResolver struct{ *Resolver }
type subscriptionResolver struct{ *Resolver }
---
4. Dataloader実装(N+1問題の解決)
dataloader/dataloader.go
package dataloader
import (
"blog-api/database"
"context"
"time"
"github.com/vikstrous/dataloadgen"
)
type Loaders struct {
UserLoader *dataloadgen.Loader[string, *database.User]
PostLoader *dataloadgen.Loader[string, *database.Post]
CommentLoader *dataloadgen.Loader[string, *database.Comment]
}
func NewLoaders(db *database.DB) *Loaders {
return &Loaders{
UserLoader: dataloadgen.NewLoader(
func(ctx context.Context, keys []string) ([]*database.User, []error) {
users, err := db.GetUsersByIDs(ctx, keys)
if err != nil {
errors := make([]error, len(keys))
for i := range errors {
errors[i] = err
}
return nil, errors
}
// キーの順序に合わせて結果を並べ替え
userMap := make(map[string]*database.User)
for _, user := range users {
userMap[user.ID] = user
}
result := make([]*database.User, len(keys))
errors := make([]error, len(keys))
for i, key := range keys {
result[i] = userMap[key]
}
return result, errors
},
dataloadgen.WithWait(time.Millisecond),
dataloadgen.WithBatchCapacity(100),
),
PostLoader: dataloadgen.NewLoader(
func(ctx context.Context, keys []string) ([]*database.Post, []error) {
posts, err := db.GetPostsByIDs(ctx, keys)
if err != nil {
errors := make([]error, len(keys))
for i := range errors {
errors[i] = err
}
return nil, errors
}
postMap := make(map[string]*database.Post)
for _, post := range posts {
postMap[post.ID] = post
}
result := make([]*database.Post, len(keys))
errors := make([]error, len(keys))
for i, key := range keys {
result[i] = postMap[key]
}
return result, errors
},
dataloadgen.WithWait(time.Millisecond),
dataloadgen.WithBatchCapacity(100),
),
}
}
// ミドルウェア:リクエストごとにDataloaderを初期化
func Middleware(db *database.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
loaders := NewLoaders(db)
ctx := context.WithValue(r.Context(), "dataloaders", loaders)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
---
5. 認証ミドルウェア
middleware/auth.go
package middleware
import (
"context"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
)
var jwtSecret = []byte("your-secret-key") // 実際は環境変数から取得
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Authorizationヘッダーからトークンを取得
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
// トークンがない場合は認証なしで続行
next.ServeHTTP(w, r)
return
}
// "Bearer <token>" 形式からトークンを抽出
tokenString := strings.Replace(authHeader, "Bearer ", "", 1)
// トークンを検証
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil || !token.Valid {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
// クレームからユーザーIDを取得
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
return
}
userID := claims["user_id"].(string)
// コンテキストにユーザーIDを設定
ctx := context.WithValue(r.Context(), "userID", userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
---
6. サーバーのエントリーポイント
server.go
package main
import (
"blog-api/database"
"blog-api/dataloader"
"blog-api/graph"
"blog-api/middleware"
"log"
"net/http"
"os"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/handler/extension"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/gorilla/websocket"
"github.com/rs/cors"
)
const defaultPort = "8080"
func main() {
port := os.Getenv("PORT")
if port == "" {
port = defaultPort
}
// データベース接続
db, err := database.NewDB("postgres://user:pass@localhost/blog")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// リゾルバーの初期化
resolver := &graph.Resolver{
DB: db,
Subscribers: make(map[string]chan *database.Post),
}
// GraphQLサーバーの設定
srv := handler.New(graph.NewExecutableSchema(graph.Config{
Resolvers: resolver,
}))
// WebSocketサポート(Subscription用)
srv.AddTransport(transport.Websocket{
Upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
},
})
srv.AddTransport(transport.POST{})
srv.AddTransport(transport.Options{})
// 拡張機能の追加
srv.Use(extension.Introspection{}) // イントロスペクション有効化
srv.Use(extension.FixedComplexityLimit(1000)) // クエリ複雑度制限
srv.Use(extension.DepthLimit(5)) // クエリ深度制限
// 本番環境ではイントロスペクションを無効化
if os.Getenv("ENV") == "production" {
srv.Use(extension.DisableIntrospection())
}
// ミドルウェアの設定
handler := middleware.AuthMiddleware(srv)
handler = dataloader.Middleware(db)(handler)
// CORS設定
c := cors.New(cors.Options{
AllowedOrigins: []string{"http://localhost:3000"},
AllowCredentials: true,
AllowedHeaders: []string{"Authorization", "Content-Type"},
})
handler = c.Handler(handler)
// ルーティング
http.Handle("/", playground.Handler("GraphQL Playground", "/query"))
http.Handle("/query", handler)
log.Printf("Server running at http://localhost:%s/", port)
log.Printf("GraphQL Playground: http://localhost:%s/", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
---
7. テストコード
graph/resolver_test.go
package graph_test
import (
"blog-api/database"
"blog-api/graph"
"blog-api/graph/model"
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSignup(t *testing.T) {
db := database.NewTestDB(t)
defer db.Close()
resolver := &graph.Resolver{DB: db}
mutationResolver := resolver.Mutation()
input := model.SignupInput{
Name: "Test User",
Email: "test@example.com",
Password: "password123",
}
result, err := mutationResolver.Signup(context.Background(), input)
require.NoError(t, err)
assert.NotEmpty(t, result.Token)
assert.Equal(t, "Test User", result.User.Name)
assert.Equal(t, "test@example.com", result.User.Email)
}
func TestCreatePost(t *testing.T) {
db := database.NewTestDB(t)
defer db.Close()
// テストユーザーを作成
user := &database.User{
ID: "user1",
Name: "Test User",
Email: "test@example.com",
}
db.CreateUser(context.Background(), user)
resolver := &graph.Resolver{DB: db}
mutationResolver := resolver.Mutation()
// 認証コンテキストを設定
ctx := context.WithValue(context.Background(), "userID", user.ID)
published := true
input := model.CreatePostInput{
Title: "Test Post",
Content: "This is a test post",
Published: &published,
}
post, err := mutationResolver.CreatePost(ctx, input)
require.NoError(t, err)
assert.Equal(t, "Test Post", post.Title)
assert.Equal(t, user.ID, post.AuthorID)
assert.True(t, post.Published)
}
func TestDataloaderBatching(t *testing.T) {
db := database.NewTestDB(t)
defer db.Close()
// テストデータの準備
users := []*database.User{
{ID: "1", Name: "User 1"},
{ID: "2", Name: "User 2"},
}
for _, u := range users {
db.CreateUser(context.Background(), u)
}
posts := []*database.Post{
{ID: "1", Title: "Post 1", AuthorID: "1"},
{ID: "2", Title: "Post 2", AuthorID: "2"},
}
for _, p := range posts {
db.CreatePost(context.Background(), p)
}
// Dataloaderを使用したクエリ
loaders := dataloader.NewLoaders(db)
ctx := context.Background()
// 複数の投稿の著者を同時に取得
author1, _ := loaders.UserLoader.Load(ctx, "1")
author2, _ := loaders.UserLoader.Load(ctx, "2")
assert.Equal(t, "User 1", author1.Name)
assert.Equal(t, "User 2", author2.Name)
// Dataloaderが適切にバッチ処理していることを確認
// (実際の実装ではモックDBでクエリ回数を計測)
}
---
まとめ
この解答例では、以下を実装しました:
- 完全なGraphQLスキーマ: Query、Mutation、Subscription、ページネーション
- gqlgen設定: スキーマファースト開発の設定
- リゾルバー実装: 認証、認可、CRUD操作
- Dataloader: N+1問題の解決
- 認証ミドルウェア: JWT認証の実装
- サーバー設定: WebSocket、CORS、セキュリティ設定
- テストコード: ユニットテストの例
これらを組み合わせることで、実務レベルのGraphQL APIを構築できます。