🙆

GoとGraphQLで実現する効率的なリゾルバ分割

2023/07/23に公開

GraphQLの利点とリゾルバ分割について

GraphQLの最大の利点は、単一のAPIエンドポイントを通じて、あらゆるデータにアクセスできることです。しかし、何も考えずに実装しているとオーバーフェッチ(必要ないSQLの実行など)に陥りやすくなります。今回はGoのGraohQLライブラリであるgqlgenを使用し、リゾルバ分割を利用してオーバーフェッチを防ぐ方法についてご紹介します。

コードについては以下で公開しているのでご参照ください
https://github.com/ryohei1216/gqlgen-resolver

リゾルバ分割の導入前

GraphQLはスキーマ定義にてクライアントが操作できるクエリや様々な型を定義しています。このスキーマから生成されるリゾルバを実装して実際のデータ操作を行います。リゾルバの実態は特定のフィールドのデータを返す関数(メソッド)です。
例えば、下記のようなGraphQLスキーマがあったとします。

type Book {
  id: Int!
  title: String!
  authorId: Int!
}

type Author {
  id: Int!
  name: String!
  books: [Book!]!
}

type Query {
  author(id: Int!): Author!
}

type Mutation {
  createBook(title: String!, authorId: Int!): Book!
  createAuthor(name: String!): Author!
}

仮に、bookとauthorのデータがMySQLなどのRDBに保存されているとします。この時、以下のauthor Query

query {
  author(id: 1){
    id
    name
    books {
      id
      title
      authorId
    }
  }
}

を実行するとアプリ内部では以下のようなSQLが実行されます。

SELECT id, name FORM authors WHERE id = 1;                                 -SELECT id, title, author_id FROM books WHERE author_id = <①で取得したid>;   -

では、Queryからbooksのデータを消して実行してみます。

query {
  author(id: 1){
    id
    name
  }
}

この時、②のSQLが実行されないかというとそうではありません。bookに関するデータを取得していないにも関わらず②のSQLは実行されてしまうのです。
ここで使用するのがリゾルバ分割です。先ほど、リゾルバの実態は特定のフィールドのデータを返す関数(メソッド)だと説明しました。つまり、リゾルバを分割することでAuthorを取得するQueryとBookを取得するQueryに分けることができるのです。
なので、リゾルバ分割を導入することで上記のようなオーバーフェッチを防ぐことができます。

では、実際にリゾルバ分割を導入しながら確かめていきます。

プロジェクトの初期設定

初期設定の説明が必要ない人は飛ばしてください。

まず、gqlgenの公式ドキュメントに沿ってプロジェクトの初期設定を行なっていきます。
作業ディレクトリを作成し、Goプロジェクトの初期化をします。

mkdir gqlgen-resolver
cd gqlgen-resolver
go mod init github.com/[username]/gqlgen-resolver

それから、gqlgenと依存関係があるtools.goを作成し、go mod tidyを実行します。

printf '// +build tools\npackage tools\nimport (_ "github.com/99designs/gqlgen"\n _ "github.com/99designs/gqlgen/graphql/introspection")' | gofmt > tools.go

go mod tidy

その後、

go run github.com/99designs/gqlgen init

を実行すると、これまでに作成したファイルと合わせて以下のような構成でファイルが作成されます。

gqlgen-resolver
┣ graph
┃ ┣ model
┃ ┃ ┗ model_gen.go
┃ ┣ generated.go 
┃ ┣ resolver.go
┃ ┣ schema.graphqls
┃ ┗ schema.resolver.go
┣ go.mod
┣ go.sum
┣ gqlgen.yml
┣ server.go
┗ tools.go

自動生成されたファイルの中身

簡単に作成されたファイルの説明をします。ここも説明が必要ない人は飛ばしてください。

schema.graphqls

schema.graphqls
# GraphQL schema example
#
# https://gqlgen.com/getting-started/

type Todo {
  id: ID!
  text: String!
  done: Boolean!
  user: User!
}

type User {
  id: ID!
  name: String!
}

type Query {
  todos: [Todo!]!
}

input NewTodo {
  text: String!
  userId: String!
}

type Mutation {
  createTodo(input: NewTodo!): Todo!
}

gqlgenの初期設定時には、あらかじめGraphQLのスキーマが記述されています。
.graphqlsファイルで記述したスキーマをもとに、models_gen.go, generated.go, schema.resolver.goが自動で生成されます。
schema.graphqlsではGraphQLで実行するAPIエンドポイント(Query, Mutation)とエンドポイントで使用するmodelのスキーマ定義をするファイルです。初期時にはこのようにTodoに関するスキーマが定義されていますが、プロジェクトに合わせてここの定義ファイルをいじっていきます。

schema.resolver.go

schema.resolver.go
package graph

// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.34

import (
	"context"
	"fmt"

	"github.com/ryohei1216/gqlgen-resolver/graph/model"
)

// CreateTodo is the resolver for the createTodo field.
func (r *mutationResolver) CreateTodo(ctx context.Context, input model.NewTodo) (*model.Todo, error) {
	panic(fmt.Errorf("not implemented: CreateTodo - createTodo"))
}

// Todos is the resolver for the todos field.
func (r *queryResolver) Todos(ctx context.Context) ([]*model.Todo, error) {
	panic(fmt.Errorf("not implemented: Todos - todos"))
}

// Mutation returns MutationResolver implementation.
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }

// Query returns QueryResolver implementation.
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }

type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }

schema.graphqlsファイルから自動生成されたエンドポイント実装用のGoファイルです。自動生成直後にはpanicが実装されておりエンドポイントとして機能しないので、ここを自分たちのエンドポイントの目的に合わせて実装していきます。

resolver.go

resolver.go
package graph

// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require here.

type Resolver struct{}

resolver.goでは状態を集約して管理します。このファイルはschema.graphqlsによって自動生成されることはないので、参照したいポインタなどを記述しておくことができます。usecasseやrepositoryなどのレイヤーを保持するのに向いています。

models_gen.go

models_gen.go
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.

package model

type NewTodo struct {
	Text   string `json:"text"`
	UserID string `json:"userId"`
}

type Todo struct {
	ID   string `json:"id"`
	Text string `json:"text"`
	Done bool   `json:"done"`
	User *User  `json:"user"`
}

type User struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

models_gen.goではshcema.graohqlsによって自動生成されたmodelがGoのstructとして記述されています。基本的にはschema.resolver.goで実装するエンドポイントの処理の返り値にこの自動生成されたmodelの型を使用します。そうすることでgenerated.goを通して内部的によしなにレスポンスとして値を返してくれるようになります。

server.go

server.go
package main

import (
	"log"
	"net/http"
	"os"

	"github.com/99designs/gqlgen/graphql/handler"
	"github.com/99designs/gqlgen/graphql/playground"
	"github.com/ryohei1216/gqlgen-resolver/graph"
)

const defaultPort = "8080"

func main() {
	port := os.Getenv("PORT")
	if port == "" {
		port = defaultPort
	}

	srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{Resolvers: &graph.Resolver{}}))

	http.Handle("/", playground.Handler("GraphQL playground", "/query"))
	http.Handle("/query", srv)

	log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
	log.Fatal(http.ListenAndServe(":"+port, nil))
}

server.goはリクエストされたQueryやMutationのレスポンスを返すhandlerになっています。このファイルのmain関数を実行することで簡単にGraphQLサーバーをローカルで起動することができます。初期設定時にはlocalhost:8080/queryでプレイグラウンドも実行することができるので色々動きを試してみてください。

リゾルバの実装

Resolver分割を導入する前に、導入前の動きを確認するためエンドポイントの実装をして導入前の動きを確認します。

schema.graphqlsの修正

まずは上のBookとAuthorの例に沿ってschema.graphqlsを修正していきます。

schema.graphqls
type Book {
  id: Int!
  title: String!
  authorId: Int!
}

type Author {
  id: Int!
  name: String!
  books: [Book!]!
}

type Query {
  author(id: Int!): Author!
}

type Mutation {
  createBook(title: String!, authorId: Int!): Book!
  createAuthor(name: String!): Author!
}

ここで定義しているのは、先ほどの例で示したBook、Authorのtypeと、Authorを取得するQuery、BookとAuthorを作成するMutationです。
次にschema.graphqlファイルからコードを自動生成します。

go run github.com/99designs/gqlgen generate

以下のようにファイルが更新されているかと思います。

models_gen.go
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.

package model

type Author struct {
	ID    int     `json:"id"`
	Name  string  `json:"name"`
	Books []*Book `json:"books"`
}

type Book struct {
	ID       int    `json:"id"`
	Title    string `json:"title"`
	AuthorID int    `json:"authorId"`
}

schema.resolver.go
package graph

// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.34

import (
	"context"
	"fmt"

	"github.com/ryohei1216/gqlgen-resolver/graph/model"
)

// CreateBook is the resolver for the createBook field.
func (r *mutationResolver) CreateBook(ctx context.Context, title string, authorID int) (*model.Book, error) {
	panic(fmt.Errorf("not implemented: CreateBook - createBook"))
}

// CreateAuthor is the resolver for the createAuthor field.
func (r *mutationResolver) CreateAuthor(ctx context.Context, name string) (*model.Author, error) {
	panic(fmt.Errorf("not implemented: CreateAuthor - createAuthor"))
}

// Author is the resolver for the author field.
func (r *queryResolver) Author(ctx context.Context, id int) (*model.Author, error) {
	panic(fmt.Errorf("not implemented: Author - author"))
}

// Mutation returns MutationResolver implementation.
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }

// Query returns QueryResolver implementation.
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }

type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }

schema.resolver.goの自動生成がうまくいかない場合はschema.resolver.goを削除してから、もう一度go run github.com/99designs/gqlgen generateを試してみてください。

QueryとMutationの実装

今の状態だとQueryのbook、MutationのcreateBookとcreateAuthorを実行してもpanicが発生するので中身を簡単に実装していきます。

resolver.go
package graph

+ import "github.com/ryohei1216/gqlgen-resolver/graph/model"

// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require here.

type Resolver struct {
+ 	Authors []*model.Author
+ 	Books   []*model.Book
}

Resolver構造体では状態を集約して管理することができます。ここでは作成したAuthorとBookをResolver構造体で定義したAuthorsとBooksで管理することにします。

schema.resolver.go
package graph

// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.34

import (
	"context"

	"github.com/ryohei1216/gqlgen-resolver/graph/model"
)

// CreateBook is the resolver for the createBook field.
func (r *mutationResolver) CreateBook(ctx context.Context, title string, authorID int) (*model.Book, error) {
+ 	book := &model.Book{
+ 		ID:       len(r.Books) + 1,
+ 		Title:    title,
+ 		AuthorID: authorID,
+ 	}
+ 
+ 	r.Books = append(r.Books, book)
+ 
+ 	return book, nil
}

// CreateAuthor is the resolver for the createAuthor field.
func (r *mutationResolver) CreateAuthor(ctx context.Context, name string) (*model.Author, error) {
+ 	author := &model.Author{
+ 		ID:   len(r.Authors) + 1,
+ 		Name: name,
+ 	}
+ 
+ 	r.Authors = append(r.Authors, author)
+ 
+ 	return author, nil
}

// Author is the resolver for the author field.
func (r *queryResolver) Author(ctx context.Context, id int) (*model.Author, error) {
+ 	var author *model.Author
+ 
+ 	for _, a := range r.Authors {
+ 		if a.ID == id {
+ 			author = a
+ 			break
+ 		}
+ 	}
+ 
+ 	books := make([]*model.Book, 0)
+ 	for _, book := range r.Books {
+ 		if book.AuthorID == id {
+ 			books = append(books, book)
+ 		}
+ 	}
+ 
+ 	author.Books = books
+ 
+ 	return author, nil
}

// Mutation returns MutationResolver implementation.
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }

// Query returns QueryResolver implementation.
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }

type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }

次にschema.resolver.goで定義している各Query、Mutationの実装についてです。
CreateBookとCreateAuthorは基本的に同じ処理をしていて、引数で渡されたパラメーターをもとにBookとAuthorを作成しています。IDはBooksとAuthorsの数に応じてインクリメントするようにしています。

ここで、一度QueryとMutationを実行してみます。go run server.goでアプリをローカルホストで起動してからhttp://localhost:8080にアクセスするとプレイグラウンドの画面が表示されると思います。ここからQueryとMutationを実行していきます。

createAuthor mutation 実行

createAuthor mutation 結果

idが1、nameがauthor01のAuthorが作成されました。

続いて、

createBook mutation 実行

createBook mutation 結果

idが1、titleがbook01、authorIdが1のBookを作成することができました。

AuthorとBookを作成した状態で、author Queryを実行してみましょう。

author query 実行

author query 結果

先ほど作成したAuthorとBookに関するデータが取得できていることが確認できます。

schema.resolver.go
// Author is the resolver for the author field.
func (r *queryResolver) Author(ctx context.Context, id int) (*model.Author, error) {
+ 	var author *model.Author
+ 
+ 	for _, a := range r.Authors {
+ 		if a.ID == id {
+ 			author = a
+ 			break
+ 		}
+ 	}
+ 
+ 	books := make([]*model.Book, 0)
+ 	for _, book := range r.Books {
+ 		if book.AuthorID == id {
+ 			books = append(books, book)
+ 		}
+ 	}
+ 
+ 	author.Books = books
+ 
+ 	return author, nil
}

schema.resolver.goのコードからも見てわかるように、author queryからbooksのフィールドを削除したとしても、booksを取得するオーバーフェッチ処理が実行されてしまうのです。
本来であれば、booksを取得しないのであればbooksを取得する処理は実行させたくありません。
ここで使用するのがリゾルバ分割です。

リゾルバ分割の導入

ここからリゾルバ分割を導入する方法についてご紹介します。
まず、gqlgen.ymlを以下のように修正します。このファイルはgqlgenの設定ファイルです。

gqlgen.yml
# Where are all the schema files located? globs are supported eg  src/**/*.graphqls
schema:
  - graph/*.graphqls

# Where should the generated server code go?
exec:
  filename: graph/generated.go
  package: graph

# Uncomment to enable federation
# federation:
#   filename: graph/federation.go
#   package: graph

# Where should any generated models go?
model:
  filename: graph/model/models_gen.go
  package: model

# Where should the resolver implementations go?
resolver:
  layout: follow-schema
  dir: graph
  package: graph
  filename_template: "{name}.resolvers.go"
  # Optional: turn on to not generate template comments above resolvers
  # omit_template_comment: false

models:
  ID:
    model:
      - github.com/99designs/gqlgen/graphql.ID
      - github.com/99designs/gqlgen/graphql.Int
      - github.com/99designs/gqlgen/graphql.Int64
      - github.com/99designs/gqlgen/graphql.Int32
  Int:
    model:
      - github.com/99designs/gqlgen/graphql.Int
      - github.com/99designs/gqlgen/graphql.Int64
      - github.com/99designs/gqlgen/graphql.Int32
+  Author:
+     fields:
+       books:
+         resolver: true

それからコードを自動生成するために以下を実行します。

go run github.com/99designs/gqlgen generate

するとschema.resolver.goに新しいコードが生成されます。

schema.resolver.go
package graph

// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.34

import (
	"context"
	"fmt"

	"github.com/ryohei1216/gqlgen-resolver/graph/model"
)

+ // Books is the resolver for the books field.
+ func (r *authorResolver) Books(ctx context.Context, obj *model.Author) + + + ([]*model.Book, error) {
+ 	panic(fmt.Errorf("not implemented: Books - books"))
+ }


+ // Author returns AuthorResolver implementation.
+ func (r *Resolver) Author() AuthorResolver { return &authorResolver{r} }

// Mutation returns MutationResolver implementation.
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }

// Query returns QueryResolver implementation.
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }

+ type authorResolver struct{ *Resolver }
type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }

これで新しくauthorResolverというResolverの構造体が生成されました。
これこそがリゾルバ分割です。
先ほども説明しましたが、リゾルバの実態は特定のフィールドのデータを返す関数(メソッド)です。authorResolver構造体に定義されているBooksリゾルバの中身を定義することでbookのデータを返す処理を実行することができます。

では、次にAuthorリゾルバで実行していたbookデータを取得する処理をBooksリゾルバに移行してみましょう。

schema.resolver.go
// Books is the resolver for the books field.
func (r *authorResolver) Books(ctx context.Context, obj *model.Author) ([]*model.Book, error) {
- 	panic(fmt.Errorf("not implemented: Books - books"))

+ 	books := make([]*model.Book, 0)
+ 	for _, book := range r.Resolver.Books {
+ 		if book.AuthorID == obj.ID {
+ 			books = append(books, book)
+ 		}
+ 	}
+ 
+ 	return books, nil
}



// Author is the resolver for the author field.
func (r *queryResolver) Author(ctx context.Context, id int) (*model.Author, error) {
	var author *model.Author

	for _, a := range r.Authors {
		if a.ID == id {
			author = a
			break
		}
	}

- 	books := make([]*model.Book, 0)
- 	for _, book := range r.Books {
- 		if book.AuthorID == id {
- 			books = append(books, book)
- 		}
- 	}
- 
- 	author.Books = books

	return author, nil
}

これでbooksを取得する処理をBooksリゾルバからAuthorリゾルバに移行することができました。こうすることで、Author QueryによってAuthorリゾルバが実行されてもbooksデータを取得するオーバーフェッチが発生しなくなりました。

新しく作成されたBooksリゾルバをもう少し細かく見ていきます。

schema.resolver.go
func (r *authorResolver) Books(ctx context.Context, obj *model.Author) ([]*model.Book, error) {
	books := make([]*model.Book, 0)
	for _, book := range r.Resolver.Books {
		if book.AuthorID == obj.ID {
			books = append(books, book)
		}
	}

	return books, nil
}

Booksリゾルバの引数にobj *model.Authorがあります。このobjにはAuthorリゾルバでreturnされた*model.Authorが入ってきます。
先ほどの例で説明すると、

var obj = *model.Author{
	 ID:   1,
	 Name: "author01",
	 Books: nil,
	}

objには上記のような値が入ります。Booksには値を入れていないのでnilになります。
このobjに入ってくる値を使用することで、分割したリゾルバの中身を実装していきます。

では、最後にリゾルバ分割した後のauthor queryの結果を見てみましょう。

author query

結果

リゾルバ分割したあとでも同じ結果を得ることができました。

リゾルバ分割のまとめ

リゾルバ分割によってオーバーフェッチを防ぐことができました。
今回は作成したデータ(BookやAuthor)をメモリに保存しましたが、実際にはRDBやNoSQL、KVSなどのストレージに保存することが多いかと思います。リゾルバ分割を導入することで、欲しいフィールドのみを指定してデータ取得するという仕組みをつくることができました。
GraphQLでオーバーフェッチを防ぐためには必須の機能になります。
コードも公開しているのでぜひ活用してください。
https://github.com/ryohei1216/gqlgen-resolver

Discussion