Shopify/graphql-batch がどのように batching するのかを追う
この記事は GraphQL Advent Calendar 2020 4日目の記事です。
前回の記事は @qsona さんの 動的型付け言語での GraphQL Client でした。
題の通り、Shopify/graphql-batch がどのようにリクエストを batching するのかをコードを読みつつ追っていきます。
時間の都合で、この記事ではシンプルな単一の Query のみ(Mutation でない)を実行した場合を対象とします。また Loader については README に記載されている RecordLoader の実装をそのまま使います。
3行まとめ
- graphql-ruby の lazy-loading class として Promise を使う
Loader.for(Klass).load(object.klass_id)
が呼ばれるたびに Loader に Promise を登録する- Query にある遅延評価しない field を全て解決した後、Promise を解決する
コードリーディングに使ったサンプルコード
require 'bundler/inline' require 'logger' gemfile do source 'https://rubygems.org' gem 'graphql' gem 'graphql-batch' gem 'activerecord', require: 'active_record' gem 'sqlite3' # gem 'pry-byebug' end ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') ActiveRecord::Base.logger = Logger.new(STDOUT) ActiveRecord::Schema.define do create_table :courses, force: true do |t| t.references :textbook end create_table :textbooks, force: true do |t| end end class Course < ActiveRecord::Base belongs_to :textbook end class Textbook < ActiveRecord::Base has_many :courses end textbook1 = Textbook.create! textbook2 = Textbook.create! textbook3 = Textbook.create! Course.create!(textbook: textbook1) Course.create!(textbook: textbook2) Course.create!(textbook: textbook3) class RecordLoader < GraphQL::Batch::Loader def initialize(model) @model = model end def perform(ids) @model.where(id: ids).each { |record| fulfill(record.id, record) } ids.each { |id| fulfill(id, nil) unless fulfilled?(id) } end end class TextbookType < GraphQL::Schema::Object field :id, ID, null: false end class CourseType < GraphQL::Schema::Object field :id, ID, null: false field :textbook, TextbookType, null: false def textbook RecordLoader.for(Textbook).load(object.textbook_id) end end class QueryType < GraphQL::Schema::Object field :courses, [CourseType], null: false def courses Course.all end end class MySchema < GraphQL::Schema query QueryType use GraphQL::Batch end result = MySchema.execute(<<~GQL) query Courses { courses { id textbook { id } } } GQL pp result.as_json
1. graphql-ruby の lazy-loading class として Promise を使う
- この辺り: https://github.com/Shopify/graphql-batch/blob/7c7e3016923f9aa346d0cd3b9be40f85bf61a3a1/lib/graphql/batch.rb#L18-L43
- Lazy Execution に関するドキュメントはこちら: GraphQL - Lazy Execution
MySchema
が参照されるとGraphQL::Batch.use
が実行される- この中で、
GraphQL::Batch::SetupMultiplex
を Multiplex instrumentation の instrumenter として登録し、また lazy-loading class としてPromise
(promise.rb) を、値を取得するメソッドとして:sync
を登録する
- この中で、
2. Loader.for(Klass).load(object.klass_id)
が呼ばれるたびに Loader に Promise を登録する
- この辺り: https://github.com/Shopify/graphql-batch/blob/7c7e3016923f9aa346d0cd3b9be40f85bf61a3a1/lib/graphql/batch/loader.rb#L47-L52
textbook
field を resolve した際の返り値としてはインスタンス変数@source
にRecordLoader
のオブジェクトを持つPromise
のオブジェクト(文字で表すとややこしい)となる
3. Query にある遅延評価しない field を全て解決した後、Promise を解決する
- この辺り: https://github.com/rmosolgo/graphql-ruby/blob/ea571fd8db2b20f46299313c544ba0690869df28/lib/graphql/execution/multiplex.rb#L82-L87
Promise
オブジェクトを返す field はGraphQL::Execution::Lazy
としてハンドリングされ、次実行対象の Query(複数あるときはそれらに含まれる全てっぽい)にある Lazy でない field を resolve するフェイズのあとに resolve される- Loader に cache していた Promise の object を sync するのは
multiplex.schema.query_execution_strategy.finish_multiplex(results, multiplex)
から進んでいったここ: https://github.com/rmosolgo/graphql-ruby/blob/ea571fd8db2b20f46299313c544ba0690869df28/lib/graphql/schema.rb#L123 promise.sync
が実行されるとGraphQL::Batch::Loader.resolve
が実行され、RecordLoader
に実装していたperform
が実行される
所感
思ったより理解に時間がかかり(そしてまだ十分に理解できていない)複雑度に対してだいぶラフなまとめになってしまったのですが、graphql-batch がどのように batching するか、また graphql-ruby についてもその実装の一部を理解することができ便利でした。
そしてコードリーディングってスコープを決めてやらないと一生終わらないですよね。楽しい。