こんにちは, メディア開発部の今村です.
この記事はGunosy Advent Calendar 2021の23日目の記事です. 昨日の記事は洪さんの「Swift Concurrencyの気になるところ」でした.
グノシーの社内管理画面用APIはGo + GraphQLで作っており, ライブラリはgqlgenを使っています. 開発開始からある程度経って使い方も固まってきたので, この記事ではgqlgenをどのように使っているかを紹介します. (主旨は事例紹介なのですが, 記事を書いていて「あまりメジャーな使い方じゃないしもっといい方法もあるな...」という気持ちになったのでこんなタイトルになっています. 詳しくは後半に書きました.)
- gqlgenの紹介
- プロジェクトの構成
- resolverとdomain serviceの依存関係
- リクエスト次第で不要な処理を飛ばす
- 型変換にFOPを使う
- それ, resolverを分ければ解決しませんか?
- 終わりに
gqlgenの紹介
まずはgqlgenについて簡単に説明します.
gqlgenはGoでGraphQLサーバーを作るためのschema-firstなライブラリです. GraphQLスキーマから, 対応するGoの型やresolverなどを生成できます. resolverというのは, リクエストに応じて実行されデータ取得処理を行う関数のことです.
例えば, 次のようなGraphQLスキーマであれば,
type Article { id: Int! title: String! } type Query { article(id: Int!): Article! }
生成される型とresolverはこんな感じです.
package model type Article struct { ID int `json:"id"` Title string `json:"title"` }
package resolver ... func (r *queryResolver) Article(ctx context.Context, id int) (*model.Article, error) { panic(fmt.Errorf("not implemented")) }
初回のコード生成ではresolverをhttp serverに登録するコードも生成されますし, クエリのパースやバリデーションなどの処理はライブラリが行ってくれます. なので, あとはresolver内部の実装 (上の例であればDBからArticleを取得して返すなど) を埋めるだけでGraphQLサーバーとして動かすことができます. 便利ですね.
プロジェクトの構成
コード生成をどう活かすか, resolver内部をどう記述するのか, というのは人によって分かれるところだと思います. 開発中のAPIでは次のような使い方をしています.
- ビジネスロジックを書くパッケージをresolverとは別に作り, resolverではそれを呼び出す
- スキーマから生成される型とビジネスロジック層で使う型を別で用意する
具体的には, /domain/article
のように, /domain
以下にドメイン*1ごとに分かれたパッケージがあり, DBとのやりとりやビジネスロジックはこの中に記述します.
パッケージ外部へは /domain
内で使う型やサービスのinterfaceなどをexportしています.
package article type Article struct { ID int Title string } type Service interface { GetArticle(ctx context.Context, id int) (Article, error) }
スキーマから生成された型は/generated/model
というパッケージに置いています.
resolverについては /resolver/article_resolver.go
のように, /domain
以下のパッケージ分けに対応する形でファイルを分けています.
resolver内部ではサービスの呼び出しや型変換を行います.
package resolver ... func (r *queryResolver) Article(ctx context.Context, id int) (*model.Article, error) { a, _ := r.articleService.GetArticle(ctx, id) // エラーハンドリングは省略 ret := model.Article(a) // 実際は複雑な変換になる return &ret, nil }
このような構成にした理由ですが, まず, GraphQL公式のベストプラクティスに従って「resolverは薄く留めてビジネスロジックとは分離する」という方針を決めました.
それに伴い, /resolver
で使う型と /domain
で使う型を分けることにしました.
GraphQLのスキーマはクライアント側の都合で変わり, 対応するGoの型も同様に変わる可能性があります.
そのような変化からビジネスロジックを分離したかったからです.
以降は, この構成で実装を進めた結果生じた問題と, その対処について説明していきます.
resolverとdomain serviceの依存関係
スキーマ上の型と /domain
以下のパッケージが一対一で対応しているとき,
resolverの実装は上に書いたように単純なのですが, 実際にはGraphQLの型は複数のドメインに依存する場合があります.
スキーマを次のように拡張することを考えます.
type Article { id: Int! title: String! publisher: Publisher! # 記事を作った媒体 } type Publisher { id: Int! name: String! }
Goのコード上では /domain/article
と /domain/publisher
は別パッケージになっているとします.
このとき, resolverを愚直に書くと, 次のように2つのサービスを呼ぶ形になります.
func (r *queryResolver) Article(ctx context.Context, id int) (*model.Article, error) { a, _ := r.articleService.GetArticle(ctx, id) p, _ := r.publisherService.GetPublisherByArticleID(ctx, id) ret := model.Article(a) _p := model.Publisher(p) ret.Publisher = &_p return &ret, nil }
2つくらいなら問題ありませんが, スキーマ上の Publisher
のフィールドにさらに新しいobjectを加えたくなった場合どうでしょうか.
publisherを取得するresolver (あれば) だけではなく, publisherに依存しているarticleのresolverでも新しいサービス呼ぶ必要が出てきます.
このような依存関係を人手で管理するのは面倒なので避けたいところです.
そこで次のような書き方をしています.
- resolverでは直接対応するドメインのサービスのみを呼び出す
- 直接対応しないドメインを呼びたい場合はresolverを経由して呼び出す
具体的には, /resolver/publisher_resolver.go
に他のresolverから呼ぶための非公開メソッドを用意し,
func (r *queryResolver) getPublisherByArticleID(ctx context.Context, articleID int) (model.Publisher, error) { p, _ := r.publisherService.GetPublisherByArticleID(ctx, articleID) _p := model.Publisher(p) // publicherが依存するドメインがあればresolver経由で呼び出して埋める return _p, nil }
articleのresolverからそれを呼び出します.
func (r *queryResolver) Article(ctx context.Context, id int) (*model.Article, error) { a, _ := r.articleService.GetArticle(ctx, id) _p, _ := r.getPublisherByArticleID(ctx, id) _a := model.Article(a) _a.Publisher = &_p return &_a, nil }
publisherがスキーマ上で依存しているドメインがある場合, それを埋めるのは /resolver/publisher_resolver.go
の方で担当するので, articleのresolverでは細かい依存関係を気にする必要がなくなります.
リクエスト次第で不要な処理を飛ばす
クライアントが要求したフィールドのみを返すことで通信を効率化できるのはGraphQLのメリットの1つです. さらに, レスポンスに影響のない処理はそもそもサーバーでスキップすれば効率的です.
gqlgenでこれを実現する方法の1つに, クライアントが要求したフィールド一覧を取得して評価するというものがあります. フィールド一覧を取得する関数は公式ドキュメントに載っているのでコピペするだけです.
例えば, このようなクエリが送られてきた場合,
query { article(id: 1) { id publisher { name } } }
["id", "publisher", "publisher.name"]
という配列が得られるので, これを評価すれば不要な処理をスキップできます.*2.
func (r *queryResolver) Article(ctx context.Context, id int) (*model.Article, error) { a, _ := r.articleService.GetArticle(ctx, id) ret := model.Article(a) preloads := getPreloads(ctx) // この関数の中身は公式ドキュメント参照 if contains(preloads, "publisher") { ret, _ := r.getPublisherByArticleID(ctx, id) ret.Publisher = &_p } return &ret, nil }
型変換にFOPを使う
gqlgenで生成する型と /domain
以下で型を分けているので, resolverでは型の相互変換をする必要があります.
ここでも愚直に書くとやや面倒なポイントがあります.
再び, article.publisher
を取得する状況を考えます.
DB上に該当するレコードがなかった場合, 最終的にはresolverまで「見つからなかったよ」というエラーが渡ってきます.
このエラーは article.publisher
がnullableならば握りつぶせますが, そうでない場合は対処する必要があります.
このようなスキーマ上の関係を個々のresolverで気にするのは面倒なので, 変換処理をまとめた関数を作りたい気持ちになります. しかし, どのフィールドを返すかはリクエストによって動的に変えているので, 引数の形に悩むところです.
そこで, Functional Options Patternを使って次のような実装にしています.
package translator func NewGQLArticle(a article.Article, opts ...ArticleOption) (model.Article, error) { ret := model.Article(a) for _, opt := range opts { err := opt.apply(&ret) if err != nil { return model.Article{}, err } } return ret, nil } type ArticleOption interface { apply(*model.Article) error } type WithPublisher struct { model.Publisher } func (wp WithPublisher) apply(a *model.Article) error { if (wp == WithPublisher{}) { // nullableな場合はreturn nil return errors.New("article.publisher is non-null field") } p := model.Publisher(wp) a.Publisher = &p return nil }
resolverは最終的にこのような形になります.
func (r *queryResolver) Article(ctx context.Context, id int) (*model.Article, error) { a, _ := r.ArticleService.GetArticle(ctx, id) var opts []translator.ArticleOption preloads := getPreloads(ctx) if contains(preloads, "publisher") { p, err := r.getPublisherByArticleID(ctx, a.ID) var e publisher.ErrNotFound if err != nil && !errors.As(err, &e) { return model.Article{}, err } opts = append(opts, translator.WithPublisher(p)) } // 依存しているフィールドが他にもあれば取得してoptsに加える ... ret, err := translator.NewGQLArticle(a, opts...) if err != nil { return model.Article{}, err } return ret, nil }
「見つからなかったよ」なエラーはresolverでは (スキーマ上の関係を気にせずに) 握りつぶしてしまいます.
見つからない場合は0値が渡るので, 個々のoptionの apply()
の内部でスキーマ上の関係に応じてエラーを投げます.
また, 返すフィールドが動的に変わったりスキーマに変更があったりしても, 変換用関数の引数の形は変わらずoptionの増減で対処できます.
それ, resolverを分ければ解決しませんか?
ここまで, resolverを書いていて困ったところや対処を紹介してきましたが, 記事を書いている途中で「もっといいやり方があったのでは...?」という気持ちになったので, その辺の話をします.
冒頭では紹介していませんが, gqlgenでは設定ファイルの編集や型のカスタム*3によってresolverを分割することができます*4.
func (r *queryResolver) Article(ctx context.Context, id int) (*model.Article, error) { panic(fmt.Errorf("not implemented")) } func (r *articleResolver) Publisher(ctx context.Context, obj *model.Article) (*model.Publisher, error) { panic(fmt.Errorf("not implemented")) }
articleのpublisher以外のフィールドは queryResolver.Article()
で返した値が使われ, publisherは articleResolver.Publisher()
で返した値が使われるということです.
articleResolver.Publisher()
の内部では, 引数に入っているarticleのIDを使ってpublisherを取得する処理を書くことになります.
この場合, resolverが呼ばれるかどうかはリクエストによって動的に決まります.
つまり, リクエストに article.publisher
が含まれていない限りarticleResolver.Publisher()
は呼ばれません.
また, /domain
以下のパッケージ分けに対応する形でresolverを分割すれば, 1つのresolverで1つのサービスのみを呼ぶことになり依存関係は単純に保てますし, 変換処理も複雑になりません.
要は, この記事で挙げたような問題は起こらないということです. 賢い!
............................................................
...ではなぜ最初からこの書き方にしなかったのかという話ですが, それは開発当初に「へ〜そんな機能もあるのか〜」くらいで流しており, 以降特に使い方を見直すこともなかったからです. ドキュメントをよく読んでライブラリの使い方を見直すのは大事ですね.
とはいえ, この記事で紹介した書き方にもメリットがないわけではないので真面目に比較をします.
デメリットとしては, 自分で書くコードが多いので冗長, そもそもこの記事で紹介した書き方に従うのが面倒, というのがあると思います.
メリットとしては, N+1問題が起こらないというのがあります.
articles: [Article!]!
というクエリがあった場合に上の articleResolver.Publisher()
が何回呼ばれるかを考えてみると分かるのですが, resolverを分割する場合はN+1問題が容易に発生します.
なので現実的にはdataloader*5を使うのが必須になると思います.
一方で, この記事の書き方ではdomain serviceにバッチ取得用の関数 (今回の例でいえば publisherService.getPublishersByArticleIDs(articleIDs []int) map[int]Publisher
) を用意してresolverで呼び出せば特に問題は生じません.
あとは細かいことですが, コード生成への依存が薄い分, スキーマやresolverの書き方, ファイル分割に自由度があったり, ライブラリが剥がしやすくなっているのもメリットかもしれません.
総じて, 現状特に困っていはいないので書き直すほどではないかな, といった感じです.
まあでも今から新しく作るならresolverを分割する方法にするかな...
終わりに
というわけで, グノシーの社内管理画面におけるgqlgenの使い方を紹介しました.
なんだかマッチポンプ感のある結論になってしまいましたが, 個人的にはGraphQLの仕様のありがたさが腑に落ちたのでよかったです.
gqlgenを使い始めたけど実装のイメージが湧かない方, 宗教上の理由でdataloaderが使えない方など, この記事が誰かの参考になれば幸いです. ありがとうございました!