GoとGraphQLで実現する効率的なリゾルバ分割
GraphQLの利点とリゾルバ分割について
GraphQLの最大の利点は、単一のAPIエンドポイントを通じて、あらゆるデータにアクセスできることです。しかし、何も考えずに実装しているとオーバーフェッチ(必要ないSQLの実行など)に陥りやすくなります。今回はGoのGraohQLライブラリであるgqlgenを使用し、リゾルバ分割を利用してオーバーフェッチを防ぐ方法についてご紹介します。
コードについては以下で公開しているのでご参照ください
リゾルバ分割の導入前
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
# 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
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
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
// 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
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
を修正していきます。
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
以下のようにファイルが更新されているかと思います。
// 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"`
}
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
が発生するので中身を簡単に実装していきます。
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で管理することにします。
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に関するデータが取得できていることが確認できます。
// 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の設定ファイルです。
# 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
に新しいコードが生成されます。
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
リゾルバに移行してみましょう。
// 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
リゾルバをもう少し細かく見ていきます。
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でオーバーフェッチを防ぐためには必須の機能になります。
コードも公開しているのでぜひ活用してください。
Discussion