graphql-batchが何をしているか
最近仕事でAPIのGraphQLへの移行を進めており、N+1を回避するためにgraphql-batchを利用しています。
どういう処理をしているか理解が曖昧だったのでコードを読んでみてわかったことをかきます。
version
- ruby: 3.2.2
- Rails 7.0
- graphql-ruby: 2.1.6
- graphql-batch: 0.5.3
遅延実行
graphql-batchはgraphql-rubyのLazy Execution(遅延実行)という仕組みを利用しています。
GraphQL::Schema#lazy_resolve(lazy_class, value_method)
というメソッドで遅延実行対象のクラスとメソッドを指定します。
fieldのresolverがlazy_class
のインスタスを返す場合、遅延実行の対象とみなされます。
そして遅延実行しないfieldの解決が完了した後にvalue_method
が実行されます。
graphql-batchにおいては、use GraphQL::Batch
の処理の中でlazy_resolve
が指定されます。
class MySchema < GraphQL::Schema
query MyQueryType
mutation MyMutationType
use GraphQL::Batch
end
具体的にはPromise
クラスとそのsync
メソッドが指定されています。
Promise
Promise
はpromise.rbで定義されているクラスで、Promises/A+のRuby実装です。
ざっくりいうとJavaScriptで非同期処理をする際に利用するPromiseのRuby版です。
sync
メソッドは非同期処理が完了する(fulfilledかrejectedになる)まで待ちます。
なお、sync
メソッドを使うにはwait
メソッドを実装する必要があります。
処理の流れ
ここからはリポジトリのexamplesにあるAssociationLoaderを例に処理を追っていきます。
前提となるサンプルコード
記事とコメントのモデルがあり、1対多の関係です
class Article < ApplicationRecord
has_many :comments
end
class Comment < ApplicationRecord
belongs_to :article
end
記事とコメントのType
ArticleType
のcomments
でAssociationLoader
を利用しています
module Types
class ArticleType < Types::BaseObject
field :id, ID, null: false
field :title, String, null: false
field :comments, [Types::CommentType], null: false
def comments
AssociationLoader.for(Article, :comments).load(object)
end
end
end
module Types
class CommentsType < Types::BaseObject
field :id, ID, null: false
field :body, string, null: false
end
end
記事の一覧を取得するQuery
class Types::QueryType < GraphQL::Schema::Object
field :articles, [Types::ArticleType], null: false
def articles
Article.all
end
end
リクエスト
query {
articles {
id
title
comments {
body
}
}
}
フィールドの解決
リクエスの内容に応じて各fieldの解決がされます。
Articleのidやtitleは遅延実行でないため先に値が解決されます
commentsは前述の通りAssociationLoader.for(Article, :comments).load(object)
が実行されます。
load
メソッドはPromise
のインスタンスを返すため、前述のlazy_resolve
での指定により遅延実行の対象となります。
for
とload
の処理内容は以下の通りです。
for
for
で指定した引数はそのままAssociationLoader#initialize
に渡されてLoaderインスタンスが作成されます。
また、そのインスタンスは GraphQL::Batch::Executor
の@loaders
ハッシュにキーと共に格納されます。
ここでキーの値は「Loaderのクラスとforで指定した引数の配列」になります
これはAssociationLoader
の親クラスであるGraphQL::Batch::Loader
のloader_key_for
メソッドで定義されています。
もし同じキーですでに@loaders
にloaderインスタンスが存在する場合、新たにインスタンスを作成せずに既存インスタンスを返します。
どちらにしてもAssociationLoader.for(Article, :comments)
は最終的にAssociationLoader
のインスタンスを返します
load
load(object)
ではAssociationLoader
の@queue
にobjectが格納されます。またPromiseのインスタンスが作成されるとともにcache_key
をハッシュキーとして@cache
に格納されます
この例でobjectはArticleモデルのインスタンスです。
cache_key
はAssociationLoader
で以下のように実装されておりobject_idを返します
@cache
にすでに同じcache_key
が存在する場合は新たにPromiseインスタンスを作成せずに既存のものを返します。
AssociationLoader.for(Article, :comments).load(object)
は最終的にPromiseのインスタンスを返します
GraphQL::Batch::Executor
とAssociationLoader
のインスタンスを図示すると以下のようになります。
遅延実行
一通りfieldのresolveが実行された後で、Promiseを返していたfieldの解決が遅延実行されます。
途中の過程を省きますが、GraphQL::Schema#sync_lazy
というメソッドが実行されます。
ここで、lazy_method
の値はlazy_resolve
で指定したsync
であり、value
は前述のload
で作成されたPromiseのインスタンスです。
sync
の実行に必要なwait
メソッドはGraphQL::Batch::Loader
で定義されています
sync
からwait
が実行されます。その後いくつかの処理を経由し、対象となるPromiseのsourceに指定されているAssociationLoader
インスタンスのperform
メソッドを実行します。
ここで引数records
にはload
メソッドで蓄積された@queue
の中身が渡されます。
つまり、この例ではrecords
の中身はArticleのインスタンスの配列になります。
load
メソッドで指定したすべてのArticleインスタンスに対して、comments
アソシエーションがpreloadされます
このタイミングで初めてCommentを取得するSQLが実行されます。
最後にfulfill
メソッドを各recordに対して実行します。
これによりload
メソッドで作成されたすべてのPromise
のインスタンスがfulfilled状態となり値が確定します
以降はsync_lazy
でfulfilledなPromise
インスタンスに対してsync
が実行された場合はperform
は実行されずに確定した値がそのまま返されます。
これですべてのfieldが解決済みになります。
実行ログを見てみる
ログを仕込んで実際に実行してみました。SQLログともあわせて実行順序を見てみます。
データはArticleが2件あり、それぞれに対してCommentが2件あります。
loaderを使う場合
module Types
class ArticleType < Types::BaseObject
field :id, ID, null: false
field :title, String, null: false
field :comments, [Types::CommentType], null: false
def title
Rails.logger.info "resolve title of #{object}"
object.title
end
def comments
Rails.logger.info "resolve comments of #{object}"
AssociationLoader.for(::Article, :comments).load(object)
end
end
end
module Types
class CommentType < Types::BaseObject
field :id, ID, null: false
field :body, String, null: false
def body
Rails.logger.info "resolve body of #{object}"
object.body
end
end
end
# AssociationLoader#performを抜粋
def perform(records)
Rails.logger.info { "AssociationLoader#perform for #{@model} #{@association_name}" }
preload_association(records)
records.each { |record| fulfill(record, read_association(record)) }
end
出力されたログは以下の通りです。Commentが1回のSQLで取得されており、Commentのbodyが最後に解決されていることがわかります。
Article Load (1.5ms) SELECT `articles`.* FROM `articles`
resolve title of #<Article:0x0000000112dd7d00>
resolve comments of #<Article:0x0000000112dd7d00>
resolve title of #<Article:0x0000000112dd7bc0>
resolve comments of #<Article:0x0000000112dd7bc0>
AssociationLoader#perform for Article comments
Comment Load (1.3ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`article_id` IN (46, 47)
resolve body of #<Comment:0x0000000112dd5280>
resolve body of #<Comment:0x0000000112dd5140>
resolve body of #<Comment:0x0000000112dd4ec0>
resolve body of #<Comment:0x0000000112dd4d80>
loaderを使わない場合
module Types
class ArticleType < Types::BaseObject
field :id, ID, null: false
field :title, String, null: false
field :comments, [Types::CommentType], null: false
def title
Rails.logger.info "resolve title of #{object}"
object.title
end
def comments
Rails.logger.info "resolve comments of #{object}"
object.comments
end
end
end
loaderを使わない場合はCommentが逐次解決されており、その度にSQLが実行されています。(N+1)
Article Load (1.9ms) SELECT `articles`.* FROM `articles`
resolve title of #<Article:0x000000011e4f49c0>
resolve comments of #<Article:0x000000011e4f49c0>
Comment Load (25.1ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`article_id` = 44
resolve body of #<Comment:0x000000011e4f3e80>
resolve body of #<Comment:0x000000011e4f3d40>
resolve title of #<Article:0x000000011e4f4880>
resolve comments of #<Article:0x000000011e4f4880>
Comment Load (7.5ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`article_id` = 45
resolve body of #<Comment:0x000000011e4f3ac0>
resolve body of #<Comment:0x000000011e4f3980>
まとめ
graphql-batchでLoaderを利用もしくはカスタマイズする際は以下のことを知っていると良さそうです。
- graphql-batchはgraphql-rubyのLazy Executionの仕組みを利用している
- fieldがPromiseのインスタンスを返すときに遅延実行の対象となる
- LoaderのforメソッドではLoaderのインスタンスが作成される
- インスタンスはLoaderクラスとforの引数の組み合わせごとに作成される
- LoaderのloadメソッドではPromiseインスタンスが作成される
- 引数にはLoader内でPromiseインスタンスを一意に特定できるキーを指定する
- 遅延実行時にLoaderのperformが実行される
- loadの引数に指定した値の配列がperformメソッドの引数として渡される
- performメソッド内ではデータの読込みを実行し、loadで作成したPromiseインスタンスの値を確定する
参考
Discussion