🏎️

graphql-batchが何をしているか

2023/12/01に公開

最近仕事で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メソッドが指定されています。

https://github.com/Shopify/graphql-batch/blob/8f5b102abada9703617807cc32f68e96a7031fa2/lib/graphql/batch.rb#L30

Promise

Promisepromise.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
ArticleTypecommentsAssociationLoaderを利用しています

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での指定により遅延実行の対象となります。
forloadの処理内容は以下の通りです。

for

forで指定した引数はそのままAssociationLoader#initializeに渡されてLoaderインスタンスが作成されます。
また、そのインスタンスは GraphQL::Batch::Executor@loadersハッシュにキーと共に格納されます。

ここでキーの値は「Loaderのクラスとforで指定した引数の配列」になります
これはAssociationLoaderの親クラスであるGraphQL::Batch::Loaderloader_key_forメソッドで定義されています。

https://github.com/Shopify/graphql-batch/blob/8f5b102abada9703617807cc32f68e96a7031fa2/lib/graphql/batch/loader.rb#L16-L18

もし同じキーですでに@loadersにloaderインスタンスが存在する場合、新たにインスタンスを作成せずに既存インスタンスを返します。
どちらにしてもAssociationLoader.for(Article, :comments)は最終的にAssociationLoaderのインスタンスを返します

load

load(object)ではAssociationLoader@queueにobjectが格納されます。またPromiseのインスタンスが作成されるとともにcache_keyをハッシュキーとして@cacheに格納されます
この例でobjectはArticleモデルのインスタンスです。

https://github.com/Shopify/graphql-batch/blob/8f5b102abada9703617807cc32f68e96a7031fa2/lib/graphql/batch/loader.rb#L54-L59

cache_keyAssociationLoaderで以下のように実装されておりobject_idを返します

https://github.com/Shopify/graphql-batch/blob/8f5b102abada9703617807cc32f68e96a7031fa2/examples/association_loader.rb#L21-L23

@cacheにすでに同じcache_keyが存在する場合は新たにPromiseインスタンスを作成せずに既存のものを返します。
AssociationLoader.for(Article, :comments).load(object)は最終的にPromiseのインスタンスを返します

GraphQL::Batch::ExecutorAssociationLoaderのインスタンスを図示すると以下のようになります。
executor_and_loader

遅延実行

一通りfieldのresolveが実行された後で、Promiseを返していたfieldの解決が遅延実行されます。
途中の過程を省きますが、GraphQL::Schema#sync_lazyというメソッドが実行されます。
ここで、lazy_methodの値はlazy_resolveで指定したsyncであり、valueは前述のloadで作成されたPromiseのインスタンスです。

https://github.com/rmosolgo/graphql-ruby/blob/109c26c5fa9118a9c69ed4c4823048c1ce1aea6d/lib/graphql/schema.rb#L1283-L1291

syncの実行に必要なwaitメソッドはGraphQL::Batch::Loaderで定義されています

https://github.com/Shopify/graphql-batch/blob/8f5b102abada9703617807cc32f68e96a7031fa2/lib/graphql/batch/loader.rb#L84-L91

syncからwaitが実行されます。その後いくつかの処理を経由し、対象となるPromiseのsourceに指定されているAssociationLoaderインスタンスのperformメソッドを実行します。

https://github.com/Shopify/graphql-batch/blob/8f5b102abada9703617807cc32f68e96a7031fa2/examples/association_loader.rb#L25-L28

ここで引数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インスタンスの値を確定する

参考

https://zenn.dev/isaka102/articles/e8eb5d1c096ee0

https://lab.mo-t.com/blog/andonlabo-graphql-ruby

https://zenn.dev/necocoa/articles/setup-graphql-batch

Discussion