graphql-batch で最強の Loader を実装した
2022-10-27
graphql-batch を利用した N+1 解決に対する課題を、Loader の実装で解決した話
はじめに
こんにちは、ギフティでエンジニアをしている @memetics10 です。
ギフティでは最近、 web API として GraphQL を採用するケースが増えてきており、私自身も GraphQL の柔軟性や効率性を日々実感しています。
ですが、そんな利便性の高い GraphQL にも特有の課題があります。 それは N+1 クエリの問題です。
この問題に対する対策としては Facebook が提供する Dataloader を用いる方法が一般的かと思いますが、 ギフティでは graphql-ruby を用いて GraphQL API を実装することが多いため、Shopify の提供する graphql-batchや graphql-ruby の提供する Dataloaderで対処しています。
これらのライブラリは大変便利なものですが、ギフティでは先述の graphql-batch を利用する上で、とある課題に直面していました。 そこで今回は、ギフティが graphql-batch にどのような課題を抱いていたのか、そしてそれをどのように解決したのかについてお話しします。
GraphQL の N+1 について
まずは、GraphQL で発生する N+1 問題について簡単に説明します。
GraphQL の大きな特性は、クライアントがグラフ構造にそって柔軟にデータを取得できることです。 ですが、その柔軟さゆえに Rails の ActiveRecord::Associations::Preloader#preload のような先読み方式では N+1 を解決できません。
たとえば User has_one Profile
というリレーションをを考えたとき、GraphQL query の投げ方によって N+1 クエリになるケースとならないケースが発生します。
以下に具体的を提示します。
まず、複数ユーザーを取得する際には、profile の解決で N+1 になります。
users {
id
profile {
name
}
}
実装で見ると Types::UserType#profile の部分が N+1 クエリの原因です。
module Types
class UserType < Types::BaseObject
field :profile, ProfileType, null: true
# User の数だけ Types::UserType#profile が呼ばれ、 その度に DB read しにいく
def profile
object.profile
end
end
end
一方、profile を取得しない際には Types::UserType#profile が呼ばれず、 N+1 にはなりません。
users {
id
}
また、単一ユーザーを取得する際にも Types::UserType#profile は1度しか呼ばれず、 N+1 にはなりません。
user {
id
profile {
name
}
}
現実世界ではこれ以外にもさまざまな取得パターンが考えられるため、サーバーは発行される SQL を事前に予測できず、以下のような先読み方式では N+1 に対処することができません。
以下の例では articles や profile を取得しない場合に余計にテーブルを読んでしまうことになり、非効率になってしまいます。
module Types
class QueryType < Types::BaseObject
field :users, [UserType], null: false
def users
User.all.preload([:articles, :profile])
end
end
end
end
これが、GraphQL 特有の N+1 問題です。
graphql-batch について
graphql-ruby を利用している場合、graphql-batch を用いて GraphQL の N+1 問題を解決することができます。 (Dataloader を利用する方法もありますが、今回は説明を省略します。)
この gem は DB read を field 解決の再帰処理が終了するまで遅延させる後読み方式をとっており、
field 解決時に key を溜めていき、再帰終了時に溜まった keys をもとにまとめて SQL を発行する仕組みになっています。
このコンセプトを batching
と呼ぶそうです。
データを実際に取得する処理の実行遅延は promise.rb によって実現されているらしいのですが、詳細な原理まではわかりませんでした。
graphql-batch を利用すると、以下のような実装で N+1 問題を解決できます。
module Types
class UserType < Types::BaseObject
field :profile, ProfileType, null: true
# graphql query の形によっては再帰的に複数回呼び出されるが、即座に SQL を発行せず Promise を返す
# load メソッドで key だけを溜め続ける
def profile
# object.profile だと N+1 になるので以下のようにする
ProfileLoader.for.load(object.id)
end
end
end
class ProfileLoader < GraphQL::Batch::Loader
# 再帰終了時に1度だけ呼ばれ、load によって溜められた keys をもとにSQL を発行する
def perform(keys)
# GraphQL::Batch::Loader#fullfill が Promise を解決する
Profile.where(user_ids: keys).each{|profile| fulfill(profile.id, profile)}
end
end
Loader の実装
さて、冒頭でも述べた通り、graphql-batch は GraphQL::Batch::Loader というクラスを提供してくれていますが、 利用者が N+1 を解決するためには、このクラスを継承した Loader を自前で実装する必要があります。
たとえば、 GraphQL::Batch::Loader#perform を見ると、Loader クラスは perform の実装が必須となっていることがわかります。
module GraphQL::Batch
class Loader
# Must override to load the keys and call #fulfill for each key
def perform(keys)
raise NotImplementedError
end
end
end
GraphQL::Batch::Loader を利用した Loader を自前で実装できる、という自由度が与えられている一方で、 ギフティのプロダクトでは、「どのような粒度で Loader クラスを実装すべきか?」という点で課題感を持っていました。
つまり、「あらゆる field で汎用的かつ共通で使える Loader を実装すべきか、field ごとに具体個別の Loader を実装すべきか」という点です。 この両者はトレードオフの関係性であり、どちらも完璧な選択ではない、というのが当時の答えでした。 以下で両者の具体的な違いを記載します。
あらゆる field で汎用的かつ共通で使える Loader
この Loader は、 実質的に graphql-batch でサンプル実装が用意されている AssocationLoader を指しています。
引数に関連を渡せるので、あらゆる field でこの Loader を利用することができます。
module Types
class UserType < Types::BaseObject
field :profile, ProfileType, null: true
field :articles, [ArticleType], null: false
def profile
AssociationLoader.for(User, :profile).load(object)
end
def articles
AssociationLoader.for(User, :articles).load(object)
end
end
end
具体個別の Loader
一方、field ごとに個別に Loader を実装する方法も考えられます。
各 Loader の実装は GraphQL::Batch::Loader が規定する範囲で自由です。 たとえば「graphql-batch について」の章で書いた ProfileLoader は具体個別の Loader の実装例となります。
module Types
class UserType < Types::BaseObject
field :profile, ProfileType, null: true
field :articles, [ArticleType], null: false
def profile
# ProfileLoader の実装は自由
ProfileLoader.for.load(object)
end
def articles
# ArticlesLoader の実装は自由
ArticlesLoader.for.load(object)
end
end
end
ギフティでは、前者のあらゆる field で汎用的かつ共通で使える Loader(=AssociationLoader)を利用していました。 開発当初は汎用的な Loader がうまく機能していましたが、GraphQL 周辺のコードが 増加していくにつれて、とある問題に直面しました。
それは、以下のコードのように GraphQL Object Type で callback によるネストや if による分岐が頻出してしまう、ということでした。
※ 実際のプロダクションコードではないので、GraphQL Schema の設計は参考にしないでください
class UserType < Types::BaseObject
field :gender, GenderType, null: true
def gender
:AssociationLoader.for(User, :profile).load(object).then do |profile|
if profile
:AssociationLoader.for(Profile, :gender).load(profile).then do
object.gender
end
end
end
end
end
この問題に対しての解決策として、具体個別の Loader を利用することが最適とは思えませんでした。
たしかに GraphQL Object Type でのネストや分岐を減らすことはできそうですが、 Loader の実装が個人に委ねられるため、単に実装のコストが高まるだけでなく、 Loader にうっかりビジネスロジックを含めてしまったりすることも考えられ、保守性の低下のリスクがあったからです。
これが先述した、あらゆる field で汎用的かつ共通で使える Loader と field ごとの具体個別な Loader のトレードオフの関係です。
解決策
このトレードオフを解決するために、ギフティでは AssociationLoader.for に、symbol だけでなく array や hash で関連を渡せるよう、 AssociationLoader の実装を変更しました。つまり、AssociationLoader をより汎用的かつ柔軟に利用できるようにした、ということです。 Rails を利用している方であれば、「ActiveRecord::Associations::Preloader#preload と同じような使い方ができる」と言えばわかりやすいかもしれません。
この Loader を利用すると、先述のネストや分岐を含んだコードは以下のように書き換えることができます。
class UserType < Types::BaseObject
field :gender, GenderType, null: true
def gender
AssociationLoader.for(User, {profile: :gender})
.then { object.gender }
end
end
大まかな実装は以下のようになっています。
-
AssociationLoader
GraphQL::Batch::Loader
を継承し、load や perform を実装- 以下に記載する {Hash | Array | Symbol} AssociationResolver を呼び出して関連を解決した上で、perform で ActiveRecord::Associations::Preloader#preload を呼ぶ
-
AssociationLoader::SymbolAssociationResolver
- symbol で渡された関連を読み取り、関連が正しいかのバリデーションをしたり、load 済みかなどを判定したりする
- ex.
AssociationLoader.for(User, :profile)
-
AssociationLoader::HashAssociationResolver
- hash で渡された関連を読み取り、関連が正しいかのバリデーションをしたり、load 済みかなどを判定したりする
- ex.
AssociationLoader.for(User, {profile: :gender})
-
AssociationLoader::ArrayAssociationResolver
- array で渡された関連の要素を HashAssociationResolver または SymbolAssociationResolver に渡す Container layer
- ex.
AssociationLoader.for(User, [{profile: :gender}, {articles: :comments}])
このようにして、汎用的な Loader を利用しながら、GraphQL Object Type での ネストや分岐を防ぐことができるようになりました!
最後に
以上、graphql-batch の利用における課題感と、その解決策についてお話しさせていただきました。
GraphQL はベストプラクティスがあまりないため、よりよい方法を皆で模索しながら開発することが大切だと思っています。 今回の記事も、読んでくださった GraphQL ユーザーの方々のご参考になれば幸いです。
また、私たちと一緒にギフティで GraphQL を使った開発がしたい!と思ってくださった方がいましたら、ぜひ一度カジュアル面談にご参加ください!
最後まで読んでいただきありがとうございました!