はじめに
こんにちは、ギフティでエンジニアをしている @memetics10 です。
ギフティの一部のプロダクトでは、 Go の GraphQL サーバーライブラリである gqlgen を利用して GraphQL API を構築しています。 クライアントにとっては便利な GraphQL ですが、サーバー側の実装を高品質に保つためには少しばかり工夫が必要です。 そこでこの記事では、ギフティで実践している gqlgen を使った GraphQL サーバーの開発プラクティスをご紹介していきます。 まずは、gqlgen における Eager Fetch の問題について見ていきましょう。
Eager Fetch の問題
GraphQL には、クライアントが欲しいデータだけを取得できるという特徴があります。 裏を返せばそれは「クライアントが不要なデータは取得しないこと」であるとも言えます。 gqlgen で GraphQL サーバーを構築する際は、この点に留意して実装を行う必要があります。
より話をわかりやすくするために、以下の例をみてみましょう。
type Query { users: [User!] } type User { id: String! name: String! articles: [Article!] } type Article { title: String! }
このような GraphQL schema があったとき、users field のリゾルバーを以下のように実装してみます。
func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) { return r.UserService.All() }
UserService は、model.User struct の articles field にデータを詰め、以下のような slice を構築して返します。
※ データ永続化には RDB が使われており、users table と articles table があることを想定します。
[]*model.User{ { ID: "1", Name: "user1", Articles: []*model.Article{ { Title: "foo", }, }, }, { ID: "2", Name: "user2", Articles: []*modelArticle{ { Title: "bar", }, }, }, }
さて、このリゾルバー実装は以下のようなクエリの場合、最適に機能するでしょう。
query { users{ id name articles { title } } }
しかし、クライアントが articles field を指定しなかった場合はどうでしょうか?
query { users{ id name } }
このとき、UserService はクライアントが指定していないデータ、つまり articles を内部的に取得してしまっています。 本来であれば、クライアントの field 指定に関わらずすべてのデータを Eager Fetch するのではなく、articles field が指定された場合にのみ、articles を取得したいところです。
リゾルバー分割による解決
gqlgen で過剰なデータ取得を回避するためには、リゾルバー分割と呼ばれる方法が使えます。 これによってデータベースへのクエリのタイミングを分割し、一部を Lazy Fetch することができます。 なお、この方法は gqlgen の document でも紹介がされています(https://gqlgen.com/getting-started/#dont-eagerly-fetch-the-user)
リゾルバー分割をするにあたり、最初に行うのは設定の追加です。 今回は User type の articles field のリゾルバーを実装したいので、gqlgen.yml に以下の設定を追加します。
models: User: fields: articles: resolver: true
この設定で go run github.com/99designs/gqlgen
を実行すると、新しく articles field のリゾルバーメソッドの雛形が生成されます。
雛形を実装すると、最終的にリゾルバーメソッドは以下のようになります。
func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) { return r.UserService.All() } func (r *userResolver) Articles(ctx context.Context, obj *model.User) ([]*model.Article, error) { return r.ArticleService.QueryByUserID(ctx, obj.ID) } func (r *Resolver) User() generated.UserResolver { return &userResolver{r} } type userResolver struct{ *Resolver }
この実装では、最初の実装と異なり UserService が articles のデータを返す必要はありません。 なぜなら、クライアントが articles field を指定してクエリした場合は、userResolver の Articles メソッドが呼び出されるようになったからです。 反対に、クライアントが articles を指定しない場合は、userResolver の Articles は呼び出されません。 つまり、ユーザーが必要な情報だけをデータベースから取得できるようになっています。
リゾルバー分割の 2 つの恩恵
リゾルバーの分割によって得られる恩恵は 2 つあります。
まず 1 つは、データ取得効率が上がることです。取得データに無駄があると、サーバー側の負荷につながる恐れがあります。 この取得データの無駄は REST API の課題でもあった点で、GraphQL を利用する利点の一つになっています。
2 つめは、GraphQL schema を Single source of truth にできるという点です。 個人的な意見としては、これは 1 つめの恩恵よりもさらに重要に感じています。
この重要性を説明するために、具体的を出してみましょう。 先ほどの例では、query type は users field だけでした。 しかしここに、user field や userCircle field が追加されたらどうなるでしょうか。
type Query { users: [User!] user(id: String!): User! userCircle(id: String!): UserCircle! } type UserCircle { id: String! members: [User!] }
少なくともまず、以下のようなリゾルバーメソッドを実装しなくてはなりません。
func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) { return r.UserService.All(ctx, id) } func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) { return r.UserService.Find(ctx, id) } func (r *queryResolver) UserCircle(ctx context.Context, id string) (*model.UserCircle, error) { return r.UserCircleService.Find(ctx, id) }
さて、このときにリゾルバーの分割が行われていない場合を想定してみます。 UserService の Find は UserService の All と同じように articles を取得する必要がありますし、UserCircleService の Find も articles を取得する必要があります。 どのメソッドも User type の Articles field を返したいという点は同じにもかかわらず、その取得データが何であるかは各 Service のメソッドの実装次第となってしまいます。
これでは「schema 上で同じ type の field であれば、必ず同じデータを返す」という SSOT 的なアプローチではなくなってしまいます。 データスキーマの変更があらゆる Service に影響を及ぼすので、サーバー側の開発者は修正箇所漏れがないように注意しなくてはならないでしょう。 また、クライアント側の開発者も、GraphQL schema を信頼することができなくなってしまいます。
ですが、リゾルバー分割がされていれば、同じ type の field は必ず分割されたリゾルバーメソッドを経由して取得されます。 したがって、GraphQL schema を Single source of truth にできるようになります。
性能の問題はマシンスペックが上がれば解決されたり、あるいはそもそも性能が求められなかったりすることもあるかもしれません。 また、リゾルバー分割によって実装量が増えてしまったり、少なくとも分割したリゾルバーの数だけデータベースへアクセスするので、分割前よりもオーバーヘッドが増す懸念はあると思います。 ですが、GraphQL schema ベースの SSOT であることはデータの信頼性や保守性の観点で重要だと感じており、その実現のためにもリゾルバーを分割する価値があると考えています。
次回予告
今回は、gqlgen のリゾルバー分割についてお話しさせていただきました。 gqlgen を使った開発プラクティスはまだまだ資料が少ないので、少しでも貢献できれば幸いです。 さて、リゾルバー分割によって過剰なデータ取得は防ぐことができましたが、さらに別の問題が発生するようになってしまいました。 そうです、N+1 クエリです。こちらの問題に対しても、もちろん解決策は用意されています。 こちらは記事の続編で実際のプラクティスを交えながらご紹介しますので、ぜひお待ちください。 最後まで読んでいただきありがとうございました!