こんにちは。SaaS事業でLayerX ワークフローの開発を担当している@sh_komineです。 この記事は、LayerX Advent Calender 2021の16日目の記事です。
LayerX ワークフローではGoとGraphQLをフル活用して開発を行なっています。
GraphQLの良さはいろいろと語られていますが、「Goで実際にどう実装するんだ?」と言うところは、gqlgenの簡潔なGet Startedがあるくらいでなかなか手で動かさないと理解できないなという思いがありましたので、graphqlのプロジェクトの基本構成に触れながら、オーバーフェッチを防ぐ実装の仕方について書いていきたいと思います。
本記事は実際の事例ではなく、gqlgen 初学者の全体把握、gqlgenの仕組みについての理解に焦点を当てた記事になります。
以下の流れで話をします。
- graphqlの基本構成
- オーバーフェッチングを防ぐためのmodel resolverの実装
- 補足
graph/schema.graphqls
の分割
今日はしない話
フロントエンドと組み合わせた全体の話や、DBなどの処理も含めた全体を通した話はしません。 実装例については、尊敬する同僚の@mosaさんや@anagoさんが過去に書いた記事がありますので、そちらをご覧ください。
- RESTとGraphQLでの実装の違いについて解説した記事 tech.layerx.co.jp
- GraphQLを用いたフロントエンドからバックエンドまで開発の流れを紹介した記事 tech.layerx.co.jp
1. graphqlの基本構成
graphqlの基本構成について理解するために、以下の流れで見ていきます。 運用しているとどんどんファイルは大きくなり、全体像の把握が難しくなってくるので、一番最初のシンプルな構成から内容を見ていきます。
- 1-1. スケルトンプロジェクトを作る
- 1-2. 生成されたファイルのそれぞれの役割について
1-1. スケルトンプロジェクトを作る
Getting Startedに倣い、Goのプロジェクト内でgqlgenをインストールして、スケルトンプロジェクト(雛形)を作成します。 (多分、どのプロジェクトでも導入の際はここから始まると思います。)
# go moduleの作成 $ mkdir gqlgen-todos $ cd gqlgen-todos $ go mod init github.com/[username]/gqlgen-todos # gqlgenのスケルトンプロジェクトを作成 $ go get github.com/99designs/gqlgen $ go run github.com/99designs/gqlgen init
スケルトンプロジェクトは以下のように作成されます。
├── go.mod ├── go.sum ├── gqlgen.yml // コード生成の設定ファイル ├── graph │ ├── generated // 自動生成されたパッケージ(基本的にいじらない) │ │ └── generated.go │ ├── model // Goで実装したgraph model用のパッケージ(自動生成されたファイルと自分でもファイルを定義することが可能) │ │ └── models_gen.go │ ├── resolver.go // ルートのresolverの型定義ファイル. 再生成で上書きされない。 │ ├── schema.graphqls // GraphQLのスキーマ定義ファイル. 実装者が好きに分割してもOK │ └── schema.resolvers.go // schema.graphqlから生成されたresolverの実装ファイル └── server.go // アプリへのエントリポイント. 再生成で上書きされない。
めちゃくちゃ簡単に雛形ができました。これがgqlgenのシンプルな基本構成になります。 rootの構成としては、以下のようになっています。
- gqlgen.yml: 設定ファイル
- graph: gqlgenの開発で利用するファイルがいろいろと含まれるパッケージ(ここのファイルについては後述)
- server.go: main関数
次は生成されたファイルのそれぞれの役割について見ていきます。
1-2. 生成されたファイルのそれぞれの役割について
graph/schema.graphqls
: GraphQLのスキーマ定義ファイル
graph/schema.graphqls
はGraphQLのスキーマ定義ファイルで、APIエンドポイントとその型を管理するのが役割です。
スケルトンプロジェクトでは以下のように生成されます。
以後、自分が書き加えた箇所には ⭐️ をつけていきます。
# ⭐️ type: 基本のオブジェクトタイプ type Todo { id: ID! text: String! done: Boolean! user: User! } type User { id: ID! name: String! } # ⭐️ type Query: データフェッチ系のエンドポイント定義 type Query { todos: [Todo!]! } # ⭐️ input: Mutaion用のオブジェクト型の定義 input NewTodo { text: String! userId: String! } # ⭐️ type Mutation: Serversideのデータを修正する処理のエンドポイント定義 type Mutation { createTodo(input: NewTodo!): Todo! }
gqlgenでは基本的にこの graph/schema.graphqls
というGraphQLの定義ファイルを編集し、
gqlgen
というコマンドを実行することで、後述する graph/model/models_gen.go
や graph/schema.resolvers.go
を再生成して開発を進めていくので、全ての起点になるファイルです。
gqlgenの話の前にGraphQLの型定義のより詳しい仕様が知りたい方は公式のドキュメントをご覧ください。
graph/model/models_gen.go
: graph/schema.graphqls
から自動生成されたGoの型定義ファイル
graph/model/models_gen.go
は graph/schema.graphqls
から自動生成されたGoの型定義ファイルです。
上記の graph/schema.graphqls
と見比べると分かりますが、 type Query
と type Mutation
以外の type Todo
, type User
と input NewTodo
のstructが自動生成されています。
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT. package model // ⭐️ input NewTodoから生成されたファイル type NewTodo struct { Text string `json:"text"` UserID string `json:"userId"` } // ⭐️ type Todoから生成されたファイル type Todo struct { ID string `json:"id"` Text string `json:"text"` Done bool `json:"done"` User *User `json:"user"` } // ⭐️ type Userから生成されたファイル type User struct { ID string `json:"id"` Name string `json:"name"` Friends []*User `json:"friends"` }
type Query
と type Mutation
は graph/model/models_gen.go
には作成されません。 graph/schema.resolvers.go
に作成されます。
また詳しくは後述しますが、graph/model
配下には自分で定義したGoのモデル定義のファイル *.go
を置くことができます。
自分で定義したGoのモデルも gqlgen コマンドで読み込まれ、graph/schema.graphqls
をベースに*.go
に足りないFieldが graph/schema.resolvers.go
に生成されます。
ひとまずは、graph/model
パッケージはGraphQLの型オブジェクトに対応するgo のstructを管理する役割があると覚えてください。
graph/schema.resolvers.go
: graph/schema.graphqls
ファイルから自動生成されたエンドポイント実装用のGoファイル
graph/schema.resolvers.go
は graph/schema.graphqls
ファイルから自動生成されたエンドポイント実装用のGoファイルです。エンドポイントの管理が役割になります。
最初の状態は以下のように未実装の状態でコードが生成されているので、そこに実装をしていく流れになります。
resolverはよくあるcontrollerやhandlerと役割は同じなので、そこまで難しく考えなくて大丈夫です。 この中の詳細の実装はresolverに処理をベタ書きでも、MVCでもDDDでも実装者の好きに実装していくことができます。
graph/schema.resolvers.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. import ( "context" "fmt" "math/rand" "github.com/shkomine/gqlgen-todos/graph/generated" "github.com/shkomine/gqlgen-todos/graph/model" ) func (r *mutationResolver) CreateTodo(ctx context.Context, input model.NewTodo) (*model.Todo, error) { // ⭐️ ここにデータフェッチの実装をしていく // 例えばこんな処理 // r.todoRepo.Create(input) panic(fmt.Errorf("not implemented")) } func (r *queryResolver) Todos(ctx context.Context) ([]*model.Todo, error) { // ⭐️ ここにデータ変更の実装をしていく // 例えばこんな処理 // r.todoRepo.List() panic(fmt.Errorf("not implemented")) } // Mutation returns generated.MutationResolver implementation. func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} } // Query returns generated.QueryResolver implementation. func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} } type mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver }
ここで、上記の mutationResolver
や queryResolver
のコードを見てみると
type mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver }
どちらも Resolver
の型定義を含んでおり、状態を持つ役割はResolverに集約されています。
graph/resolver.go
, server.go
: gqlgen
コマンドで再生成されないgoパッケージ
Resolver
の型定義は graph/resolver.go
に定義されていて、状態を集約して管理するのが役割です。
自動生成される graph/schema.resolvers.go
から参照されているので、型の名前を変えたり消すことはできませんが、この Resolver
は初期生成の後gqlgen
コマンドを実行しても再生成されることはないので、このstructに参照し続けたいインスタンス(pointer)などを持たせることができます。
MVCなら各model、その他 repositoryやservice, usecaseのstructを持つような実装になるかと思います。
graph/resolver.go
package graph import "github.com/shkomine/gqlgen-todos/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{ // ⭐️ TODO: ここにUserRepositoryなどのインスタンスを保持する }
Resolver
インスタンスを作成しているのは server.go
です。
Goのmain関数が含まれています。
このファイルも最初にスケルトンオブジェクトで作成された後はgqlgen
コマンドの実行で再生成されないので、自由に書き換えることができます。
package main import ( "log" "net/http" "os" "github.com/99designs/gqlgen/graphql/handler" "github.com/99designs/gqlgen/graphql/playground" "github.com/shkomine/gqlgen-todos/graph" "github.com/shkomine/gqlgen-todos/graph/generated" ) const defaultPort = "8080" func main() { port := os.Getenv("PORT") if port == "" { port = defaultPort } // ⭐️ ここでResolverのインスタンスを生成する srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{}})) // ⭐️ graphql playgroundを`/query` に設定 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)) }
ここまで初期生成されたスケルトンプロジェクトを元にgqlgenの基本構成を説明してきました。 実際に動かしてみるところまでやってみたい方は、さくっと終わるので公式のGetting Startedをやってみてください。 より深く理解ができると思います。
次はいよいよオーバーフェッチングを防ぐためのmodel resolverの実装について触れていきます。
2. オーバーフェッチングを防ぐためのmodel resolverの実装
2-1. オーバーフェッチとは
GraphQLでバックエンドのコードをすっきりさせた話でも触れていますが、
オーバーフェッチ = リクエスト元で必要ないのに余分にリソースをフェッチしてしまうこと
です。
gqlgenのREADMEのよくある質問の最初にも「使わないかもしれない子オブジェクトのフェッチを防ぐにはどうしたらいいか? 」という項目が用意されており、GraphQLの重要なテーマであることがわかります。 GraphQLでは通信元のqueryで取得するリソースを制御することができ、より柔軟で効率的な情報取得が可能です。
例えば、上記のGet Startedの例では、Todoの中にUserが含まれていますが、これは、必ず必要とは限りません。
schema.graphqls
type Todo struct { ID string `json:"id"` Text string `json:"text"` Done bool `json:"done"` User *User `json:"user"` }
GraphQLでは情報の取得側がqueryで明示的に指定することによりそのデータが必要かどうかサーバー側に伝えることができます。
userの名前が必要であれば、以下のようにリクエストをします。
query findTodos { todos { text done user { name } } }
結果
{ "data": { "todos": [ { "text": "技術記事を書く", "done": false, "user": { "name": "田中 太郎" } }, { "text": "実装をする", "done": false, "user": { "name": "佐藤 二郎" } } ] } }
また、userが必要なければ、以下のようにuserを含めずにリクエストをします。
query findTodos { todos { text done } }
結果
{ "data": { "todos": [ { "text": "技術記事を書く", "done": false, }, { "text": "実装をする", "done": false, } ] } }
gqlgenではこのuserがリクエストに含まれた時だけ呼ばれるメソッドをgraph/schema.resolvers.go
に作成することができます。
これによって、無駄なDBフェッチが走らない実装が可能になります。
gqlgenのドキュメントでは、特に名前がつけられていませんが、graph/schema.resolvers.go
の中にqueryResolver
, mutationResolver
以外の モデルごとのResolver xxxResolver
が生成されるので、ここでは勝手にmodelresolverと呼ばせていただきます。(上記の例では todoResolver
が生成されます。)
2-2. model resolverの2つの実装方法
gqlgenのREADMEにも書いてありますが、model resolverの実装には2つの方法があります。
- カスタムモデルを用いた暗黙的な生成
- gqlgen.ymlへの記載による明示的な生成
Get Startedでは「カスタムモデルを用いた暗黙的な生成」しか触れられていませんでしたし、自分が試した感触でもそちらの方が便利だったので、カスタムモデルを用いた実装方法を強くおすすめします。
カスタムモデルを用いた暗黙的な生成
model packageの説明で少し触れましたが、gqlgenはgraph/schema.graphqls
での定義と model/*.go
内の型定義の差分によって graph/schama.resolvers
を生成します。
今、graph/schema.graphqls
でのTodoの定義は以下の通りです。
graph/schema.graphqls
type Todo struct { ID string `json:"id"` Text string `json:"text"` Done bool `json:"done"` User *User `json:"user"` }
graph/model/models_gen.go
内のTodoを削除し、graph/model/todo.go
というファイルにカスタムモデルを定義します。
その際、todo.go
内のUserを丸っと消してみます。
graph/model/models_gen.go
-type Todo struct { - ID string `json:"id"` - Text string `json:"text"` - Done bool `json:"done"` - User *User `json:"user"` -}
graph/model/todo.go
package model type Todo struct { ID string `json:"id"` Text string `json:"text"` Done bool `json:"done"` }
つづいて、gqlgen
コマンドを実行すると、graph/schema.graphqls
の型定義をベースにgraph/model/*.go
のカスタムモデルで足りないfieldを検知してgraph/schema.resolvers.go
にmodel resolverを追加します。
graph/schema.resolvers.go
func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) { // ⭐️ ここにUserのデータフェッチ処理を書く // こんなメソッドになる // r.userRepo.GetUserByTodoID(obj.ID) panic(fmt.Errorf("not implemented")) } // ... // Todo returns generated.TodoResolver implementation. func (r *Resolver) Todo() generated.TodoResolver { return &todoResolver{r} } // ... type todoResolver struct{ *Resolver }
ただ、上記で生成された状態だとTodoのIDでUserの情報を取得しないといけません。
実際の場合はTodoがUserIDを持っていることの方が多いと思います。
そこで、model/todo.go
にUserIDを足します。
model/todo.go
package model type Todo struct { ID string `json:"id"` Text string `json:"text"` Done bool `json:"done"` + UserID *string `json:"user_id"` }
これでmodel resolverの中でUserIDを利用できるようになりました。
graph/schema.resolvers.go
func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) { // ⭐️ ここにUserのデータフェッチ処理を書く // obj.UserIDが使えるので、こんなメソッドになる // r.userRepo.GetUserByID(obj.UserID) panic(fmt.Errorf("not implemented")) }
graph/model/todo.go
にUserIDを追加してもgraph/schema.graphqls
には変更を入れていないので、GraphQLのインタフェースには変化がありません。
このように外には出したくないが、relationを追加するために必要なパラメータなどを自由に定義できるので、カスタムモデルの実装はおすすめです。
gqlgen.ymlへの記載による明示的な生成
自分もあまり使っていませんが、一応もう一つのmodel resolverの生成方法もご紹介します。
以下のように、 gqlgen.yml
に具体的な指定をしても、カスタムモデルの時と同様にresolverを生成することができます。
gqlgen.yml
models: Todo: fields: user: resolver: true # force a resolver to be generated
graph/schema.resolvers.go
func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) { // ⭐️ ここにUserのデータフェッチ処理を書く // こんなメソッドになる // r.userRepo.GetUserByTodoID(obj.ID) panic(fmt.Errorf("not implemented")) } // ... // Todo returns generated.TodoResolver implementation. func (r *Resolver) Todo() generated.TodoResolver { return &todoResolver{r} } // ... type todoResolver struct{ *Resolver }
生成できました。 ですが、こちらの方法だとTodoのモデルにUserIDなどのrelationを追加するために必要なパラメータをgoのモデルに追加することができません。 また、Inline config with directives という機能でより詳細に設定できるそうですが、自分が手元で試した際にはうまく動かなかったので、「カスタムモデルを用いた暗黙的な生成」の方を利用することをおすすめします。
2-3. 具体的なmodel resolverの使用例
ここまで model resolverの実装方法について話してきましたが、重要なのはgraph/schema.graphqls
の型定義をベースにgraph/model/*.go
のカスタムモデルで足りないfieldを検知してgraph/schema.resolvers.go
にmodel resolverを追加します。という部分です。
つまりオーバーフェッチがある部分に関しては、カスタムモデルから削除するだけでqueryで指定されなければ呼び出されないmodel resolverを生成することができます。
例えば以下のような場合に便利です。
Todoにタグ情報が複数紐づく場合にmodel resolverを活用
複数のRelationがあるケースです。
schema.graphqls
type Todo { id: ID! text: String! done: Boolean! user: User! + tags: [Tag!]! } +type Tag { + id: ID! + name: String! +}
schema.resolvers.go
func (r *todoResolver) Tags(ctx context.Context, obj *model.Todo) ([]*model.Tag, error) { // ⭐️ TodoのIDでタグの一覧を取得する r.tagRepo.ListByTodoID(obj.ID) }
一点注意点としては、 todoResolverのTags
メソッドは、以下のようにTodoを配列で取得する場合、返却するtodoの件数分呼び出されます。
つまり、有名なN+1問題が発生します。
query findTodos { todos { text done tags } }
このN+1問題の解消には、dataloaderという仕組みがあり、実際にはそれで実装しているのですが、 話し始めるととても長くなるのでまたの機会にさせていただきます。 ざっくり言うと、データの呼び出しを一定期間待ち合わせた後に一斉にデータフェッチしてくる仕組みです。 気になる方は 「golang graphql dataloader」で調べてみてください。
provileURLを求められない限りは S3 アクセスしないためにmodel resolverを活用
単一のフィールドであってもCloudのインフラにアクセスする必要があるケースです。 relationだけではなく、こういった単一のフィールドであってもオーバーフェッチを避ける事ができます。
schema.graphqls
type User { id: ID! name: String! profileURL: String }
model/user.go
package model type User struct { ID string `json:"id"` Name string `json:"name"` }
schema.resolvers.go
func (r *userResolver) ProfileURL(ctx context.Context, obj *model.User) (*string, error) { // ⭐️ awsのS3にアクセスして署名付きURLを発行する r.userRepo.getSignedProfileURL() }
3. 補足 graph/schema.graphqls
の分割
余談ですが、実プロジェクトを回していると、schema.graphgqs
はどんどん大きく膨れていってしまい、メンテナンスが大変になります。
この schema.graphgqs
は簡単に分割できて、例えば、
query.graphgqs
mutation.graphgqs
input.graphqls
type.graphqls
という分割の仕方をすると、
query.resolvers
mutation.resolvers
input.resolvers
type.resolvers
というようにresolversファイルが分割され、全体の見通しをよくすることができます。
この際、model/models_gen.go
に関しては分割されません。
ファイルがとても大きくなってきた際には是非試してみてください。
最後に
今日はgqlgenの基本構成とオーバーフェッチを防ぐmodel reolverの実装について書いてみました。 gqlgenの基本的な仕組みの理解の助けになったら嬉しいです。
We are Hiring
LayerXではエンジニアはもちろん、全職種で積極的に採用中です。 ぜひカジュアル面談からでもお話しましょう!
- LayerXのEntranceBook
LayerXの事業やチームについて、もっと知りたい方はこちら layerx.notion.site
- LayerXのMeety一覧
LayerXのメンバーと、カジュアルに話してみたい方はこちら meety.net
- LayerXの募集ポジション一覧
実際に応募してみたい方はこちら herp.careers