雰囲気でgemを使ったことを反省して学びなおす 〜 graphql-batch AssociationLoader編 〜
はじめに
直近で関わっているプロジェクトで、GraphQLのクエリで発生するN+1問題を解決するために、graphql-batch の AssociationLoaderを利用する機会がありました。
既存の類似実装箇所を参考にコードを書いてN+1問題を解決できたものの、「内部でどのような処理が行われているんだろう?」という疑問が出てきたので、関連部分のコードリーディングに挑戦してみました。
この記事では、そのコードリーディングの結果、自分なりに理解した内容をまとめています。
graphql-batchのLoaderを使ったことはあるけど内部で何をやっているのかはよくわかっていない...という自分と同じような方の参考になれば幸いです!
筆者の経験が浅いため、理解が浅い部分や誤っている内容が含まれているかもしれません。
そのような部分がありましたら、是非コメント等でご指摘いただけると嬉しいです🙇♂️
GraphQL::Batchとは
公式のREADMEによると...
Provides an executor for the graphql gem which allows queries to be batched.
直訳:クエリをバッチ処理できる graphql gem 用のエクゼキュータを提供します
直訳だとよくわからなかったのですが、主にGraphQLで発生するN+1問題を解決するためのライブラリだと理解してます。
N+1問題の例とGraphQL::Batchによる解決策
version
下記を前提とします。
- Ruby 3.3.1
- Rails 7.0.8
- graphql 2.4.9
- graphql-batch 0.6.0
N+1問題の具体例
まずはN+1問題を引き起こす💥ために、下記のようなModel、Type、Query、Resolverを用意しました。
Model
UserモデルとPhotoモデルを作成し、1対多の関連を設定します。
# app/models/user.rb
class User < ApplicationRecord
has_many :photos, dependent: :destroy
end
# app/models/photo.rb
class Photo < ApplicationRecord
belongs_to :user
end
Type
UserTypeとPhotoTypeを作成し、UserTypeにはposted_photosフィールドを設定して、Userが投稿したPhotoを取得できるようにします。
# app/graphql/types/user_type.rb
module Types
class UserType < Types::BaseObject
field :id, ID, null: false
field :name, String
field :posted_photos, [Types::PhotoType], null: false
def posted_photos
object.photos
end
end
end
# app/graphql/types/photo_type.rb
module Types
class PhotoType < Types::BaseObject
field :id, ID, null: false
field :name, String, null: false
field :posted_by, Types::UserType, null: false
def posted_by
object.user
end
end
end
Resolvers
すべてのユーザーを取得するQueryを作成します。
# app/graphql/resolvers/all_users.rb
module Resolvers
class AllUsers < BaseResolver
type [Types::UserType], null: false
def resolve
::User.all
end
end
end
Query
用意したModel、Type、Resolverを利用して、GraphiQLで下記のクエリを実行すると...
query {
allUsers {
id
name
postedPhotos {
name
}
}
}
Response
クエリの結果はこのようになります。
{
"data": {
"allUsers": [
{
"id": "1",
"name": "テストユーザー1",
"postedPhotos": [
{
"name": "カフェでの一枚"
},
{
"name": "美味しい料理"
},
{
"name": "チームミーティング"
}
]
},
{
"id": "2",
"name": "テストユーザー2",
"postedPhotos": [
{
"name": "山の頂上"
},
{
"name": "お気に入りの場所"
},
// 以下略
]
}
]
}
}
発行されるSQL
Railsのログを確認すると、下記のようなSQLが発行されており、N+1問題が発生していることがわかります。
User Load (0.9ms) SELECT `users`.* FROM `users`
Photo Load (0.3ms) SELECT `photos`.* FROM `photos` WHERE `photos`.`user_id` = 1
Photo Load (0.2ms) SELECT `photos`.* FROM `photos` WHERE `photos`.`user_id` = 2
Photo Load (0.1ms) SELECT `photos`.* FROM `photos` WHERE `photos`.`user_id` = 3
Photo Load (0.1ms) SELECT `photos`.* FROM `photos` WHERE `photos`.`user_id` = 4
Photo Load (0.1ms) SELECT `photos`.* FROM `photos` WHERE `photos`.`user_id` = 5
GraphQL::Batchによる解決策
graphql-batch gem の Loaders::AssociationLoaderを利用して、N+1問題を解決します。
GraphQL::Batchの導入
Gemfileに下記を追加して、bundle install を実行します。
gem 'graphql-batch'
schemaの設定を下記のように修正します。
class ****Schema < GraphQL::Schema
mutation(Types::MutationType)
query(Types::QueryType)
+ use GraphQL::Batch
# 以下略
end
AssociationLoader
GraphQL::Batchのexamplesディレクトリに、ActiveRecord用のLoaderの例が用意されています。
そのうちの一つであるAssociationLoaderを利用すると、ActiveRecordのアソシエーションを効率的にまとめて読み込むことができます。
今回は、examplesディレクトリにある association_loader.rb の内容そのままで下記のLoaderを作成し、UserTypeのposted_photosフィールドで利用してみます。
module Loaders
class AssociationLoader < GraphQL::Batch::Loader
def self.validate(model, association_name)
new(model, association_name)
nil
end
def initialize(model, association_name)
super()
@model = model
@association_name = association_name
validate
end
def load(record)
raise TypeError, "#{@model} loader can't load association for #{record.class}" unless record.is_a?(@model)
return Promise.resolve(read_association(record)) if association_loaded?(record)
super
end
# We want to load the associations on all records, even if they have the same id
def cache_key(record)
record.object_id
end
def perform(records)
preload_association(records)
records.each {|record| fulfill(record, read_association(record)) }
end
private
def validate
unless @model.reflect_on_association(@association_name)
raise ArgumentError, "No association #{@association_name} on #{@model}"
end
end
def preload_association(records)
::ActiveRecord::Associations::Preloader.new(records: records, associations: @association_name).call
end
def read_association(record)
record.public_send(@association_name)
end
def association_loaded?(record)
record.association(@association_name).loaded?
end
end
end
Type
UserTypeのposted_photosフィールドを下記のように修正します。
AssociationLoaderは、AssociationLoader.for(model, association_name).load(object) という形式で、指定されたモデルの指定された関連をロードできます。
module Types
class UserType < Types::BaseObject
field :id, ID, null: false
field :name, String
field :posted_photos, [Types::PhotoType], null: false
def posted_photos
+ Loaders::AssociationLoader.for(User, :photos).load(object)
- object.photos
end
end
end
発行されるSQL
UserTypeの変更後に下記クエリを再度GraphiQLで実行してみます。
query {
allUsers {
id
name
postedPhotos {
name
}
}
}
クエリ実行後にRailsのログを確認すると、下記のようなSQLが発行されており、N+1問題が解決されていることがわかります🎊
User Load (0.2ms) SELECT `users`.* FROM `users`
Photo Load (2.6ms) SELECT `photos`.* FROM `photos` WHERE `photos`.`user_id` IN (1, 2, 3, 4, 5)
N+1問題が解決されのでめでたしめでたし...という感じなのですが、ここで思ったのです。
AssociationLoaderの中身をコピペしただけで、中身をよく理解しないまま使ってしまってるなと...
ということで、仕組みを少しでも理解するため、無謀にもAssociationLoaderのコードリーディングに挑戦してみることにしました😉
以下はその足掻いた記録です📝
コードリーディング
GraphQL::Batch::Loader.for
Loaders::AssociationLoader.for(User, :photos) を実行した場合の処理の流れを追ってみます。
上記を実行すると、AssociationLoader が継承している GraphQL::Batch::Loader 内に定義されている 下記のfor メソッドが呼び出されます。
Ruby 2.7 以降であれば、委譲記法(...)を利用するdef self.for(...) の部分が実行されます。
それでは、for メソッドの中身を見ていきます。
GraphQL::Batch::Loader.loader_key_for
current_executor.loader(loader_key_for(...)) { new(...) }のloader_key_for(...)の部分から見ていきます。
forメソッドで渡された引数 User と :photos が、group_args として [User, :photos] にまとめられ、キーワード引数は空のハッシュ {} となります。
また、self はこの場合 Loaders::AssociationLoader になります。
結果として、ローダーキーは
[Loaders::AssociationLoader, {}, [User, :photos]]
となります。
GraphQL::Batch::Loader.current_executor
current_executor.loader(loader_key_for(...)) { new(...) }のcurrent_executorの部分を見ていきいます。
current_executor は GraphQL::Batch::Executor.current を呼び出して、executor 変数に結果を代入して返しています。
GraphQL::Batch::Executor.current の中身はどうなっているのでしょうか?
GraphQL::Batch::Executor.current
このコードを見てあれ?と思いました。
これまで読んできたコードでThread.current[THREAD_KEY] に何か格納しているような処理を見たことがなかったからです。
Thread.current[THREAD_KEY]が空で、GraphQL::Batch::Executor.current がnilだと、GraphQL::Batch::Loader.current_executor がGraphQL::Batch::NoExecutorErrorをraiseするはずですが、Loaders::AssociationLoader.for(User, :photos) を実行した際にそのようなエラーは発生していませんでした。
ということは、Thread.current[THREAD_KEY] は何か格納されているはずです。
Thread.current[THREAD_KEY]の中身を確認してみると、下記の通り、GraphQL::Batch::Executor のインスタンスが格納されていました。
#<GraphQL::Batch::Executor:0x0000ffff956eebd8
@loaders={},
@loading=false,
@nesting_level=1
>
ここでまた疑問が浮かんできます🤔
Thread.current[THREAD_KEY] に GraphQL::Batch::Executor のインスタンスを格納する処理はどこで行われているのでしょうか?
GraphQL::Batch::Executor.start_batch
currentメソッドの近くを嗅ぎ回っていると、それっぽいコードを見つけました。
この部分では、Thread.current[THREAD_KEY] に既存のExecutorがなければ、新しいExecutorインスタンスを作成し、それをThread.current[THREAD_KEY]に格納しています。
それではstart_batchメソッドはどこで呼ばれているのでしょうか?
GraphQL::Batch.batch
ここで呼ばれていました。
batchメソッドがどこで呼ばれているか探しましたが、見つかりません。
どうやら、app/graphql/****_schema.rb に use GraphQL::Batch を書くと、下記のようにGraphQL::Batch.batchのブロック内でLoaderが実行されるようになるようです。
GraphQL::Batch.batch do
Loaders::AssociationLoader.for(User, :photos)
end
コードを追ってみると...
スキーマにuse GraphQL::Batchを書く → GraphQL::Schema.useが呼ばれる → GraphQL::Batch.useが呼ばれる → GraphQLのクエリ実行時に内部で自動的に GraphQL::Batch.batch が呼ばれるようになる
という流れっぽかったのですが、GraphQL::Batch.useの部分が難しかったので今回はスルーします😇
ここまで読んできてやっとGraphQL::Batch::Loader.current_executor で何が行われているのか、なんとなく理解できました。
今回の事例(Loaders::AssociationLoader.for(User, :photos))でGraphQL::Batch::Loader.current_executorの実行結果を確認すると、下記のようになっていました。
#<GraphQL::Batch::Executor:0x0000ffff956eebd8
@loaders={
[Loaders::AssociationLoader, {}, [User, :photos]] =>
#<Loaders::AssociationLoader:0x0000ffff9499f068
@loader_key=[Loaders::AssociationLoader, {}, [User, :photos]],
@executor=#<GraphQL::Batch::Executor:0x0000ffff956eebd8 ...>,
@queue=nil,
@cache=nil,
@model=User,
@association_name=:photos
>
},
@loading=false,
@nesting_level=1
>
GraphQL::Batch::Loader.forの中身current_executor.loader(loader_key_for(...)) { new(...) }でまだ確認していないのはloaderメソッドです。
GraphQL::Batch::Executor#loader
-
@loaders[key] ||= ...の部分
まず、このloaderメソッドは、与えられたkeyに対して既に作成済みのローダーがあるかどうかを確認します。
もし既存のローダーがあればそのまま返し、なければyield以降の処理を実行します。 -
yieldの部分
ブロックが渡されている場合、そのブロックを実行します。
loaderメソッドに渡されているブロック内の処理はnew(...)となっているため、今回の例ではLoaders::AssociationLoader.new(User, :photos).newが実行され、新しいローダーのインスタンスが生成されます。 -
yield.tap do |loader| ... endの部分
Rubyのtapメソッドは、オブジェクト自身を返す前にブロックを実行し、何らかの処理を行いたいときに使います。
今回の場合、yieldによって生成されたローダーインスタンスを受け取り、そのインスタンスに対して下記の処理を行い、処理後のインスタンスを返します。- ローダーインスタンスの
executorにself(現在のExecutorインスタンス)を設定 - ローダーインスタンスの
loader_keyにkey(今回の例では[Loaders::AssociationLoader, {}, [User, :photos]])を設定
- ローダーインスタンスの
ここまで読んできた内容を確かめるため、下記の通りGraphQL::Batch::Loader.forを実行して、どのような結果が返ってくるのか確認してみます。
GraphQL::Batch.batch do
Loaders::AssociationLoader.for(User, :photos)
end
結果は下記の通りでした。
#<Loaders::AssociationLoader:0x0000ffff9499f068
@loader_key=[Loaders::AssociationLoader, {}, [User, :photos]],
@executor=#<GraphQL::Batch::Executor:0x0000ffff956eebd8
@loaders={
[Loaders::AssociationLoader, {}, [User, :photos]] =>
#<Loaders::AssociationLoader:0x0000ffff9499f068 ...>
},
@loading=false,
@nesting_level=1
>,
@queue=nil,
@cache=nil,
@model=User,
@association_name=:photos
>
続いて、AssociationLoader#load について確認していきます。
AssociationLoader#load
Loaders::AssociationLoader.for(User, :photos).load(User.first) を実行した際の処理の流れを追ってみます。
loadメソッドは、Loaders::AssociationLoader のインスタンスメソッドです。
Loaders::AssociationLoader.for(User, :photos).load(User.first) を実行した際、User.firstの :photos はまだロードされていない状態ですので、2行目のif文はfalseとなり、3行目のsuperが実行されます。
superは、Loaders::AssociationLoader の親クラスである GraphQL::Batch::Loader の load メソッドを呼び出します。
GraphQL::Batch::Loader#load
load メソッドは、GraphQL::Batch::Loader のインスタンスメソッドです。
引数としてkeyを受け取ります。
今回の例では、Loaders::AssociationLoader.for(User, :photos).load(User.first) のように実行するので、keyはUserモデルのインスタンスになります。
下記の部分について確認してきます。
cache[cache_key(key)] ||= begin
-
cacheは、Promiseインスタンスを格納するハッシュです。 -
cache_key(key)では、AssociationLoaderのcache_key(record)メソッドが呼び出されるため、今回の例だとUser.first.object_idが返されます。object_idは、各オブジェクトに対して一意な整数を返します。 -
cache[cache_key(key)]がnilまたはfalseの場合に右側の値を代入するようになっています。
続いて、下記の部分を確認してきます。
queue << key
-
queueは、keyを格納するための配列です。 -
queue << keyで、指定されたkeyをqueueに追加します。
続いて、下記の部分を確認してきます。
::Promise.new.tap { |promise| promise.source = self }
-
::Promise.newは、新しいPromiseインスタンスを作成します。 -
tapメソッドは、ブロック内でPromiseインスタンスを操作し、操作後のインスタンスを返します。 -
promise.source = selfは、Promiseのsource属性に現在のLoaderインスタンスを設定します。
以上をまとめると、loadメソッドは、指定されたcache_key(key)に対応するPromiseインスタンスがあればそれを返し、なければqueueにkeyを追加した上で新しいPromiseインスタンスを作成して返すようになっています。
Promiseについてはまだ詳しい調査ができておらず、非同期処理の状態や結果を表現するJSのPromiseのRuby版なのかな?くらいのイメージなのですが、難しそうなので深追いせず、ひとまず先を読み進めることにします。
Promise.sync
Promise.syncメソッドが呼び出されていそうなのは、下記部分でした(難しかったので自信はない...)。
Promise.syncがどうやって呼び出されるのかについては、graphql-rubyのGraphQL::Schema#sync_lazyが関係していそうなのですが、難しそうなので一旦スルーして先を読み進めます😇
GraphQL::Batch.batchのブロック引数は、下記のようになるため、Promise.sync(yield)のyieldはGraphQL::Batch::Loader#loadの実行結果(Promiseインスタンス)になります。
GraphQL::Batch.batch do
Loaders::AssociationLoader.for(User, :photos).load(User.first)
end
Promise.syncの内容は下記の通りとなっています。
今回、syncの引数はPromiseインスタンスですので、条件式はtrueとなり、Promise#syncが実行されます。
Promise#sync
Promise#syncの内容は下記の通りとなっています。
syncメソッドのレシーバとなっているPromiseインスタンスのstateは:pendingですので、Promise#syncはPromise#waitを実行します。
AssociationLoader#wait
Promise#waitの内容は下記の通りとなっていますが、
今回の例では、下記のAssociationLoader#waitが実行されます。
こちらで確認したように、executorはAssociationLoaderのインスタンスにセットされているので、if文の条件式はtrueとなり、executor.resolve(self)が実行されます。
selfはAssociationLoaderのインスタンスです.
GraphQL::Batch::Executor#resolve
@loadingの初期値はfalseですので、was_loadingはfalseとなります。
その後、GraphQL::Batch::Loaderのresolveメソッドが実行されます。
GraphQL::Batch::Loader#resolve
下記の部分について、
return if resolved?
resolved?は下記の通りとなっており、ここで確認したように今回の例ではqueueにUserモデルのインスタンスが格納されているため、falseとなります。
よって、if文の条件式はfalseとなり、returnは実行されません。
下記部分で、queueに格納されたUserモデルのインスタンスの配列をload_keysに格納し、queueをnilにしています。
load_keys = queue
@queue = nil
その後、load_keysを引数にperformメソッドが実行されます。
AssociationLoader#perform
performメソッドではまず、preload_associationメソッドが実行されます。
preload_associationメソッドの内容は下記の通りです。
今回の事例では、引数のrecordsにUserモデルのインスタンスが代入されますので、ActiveRecord::Associations::Preloader.new(records: [User.first], associations: :photos).callが実行され、結果は下記のようになります。
[#<ActiveRecord::Associations::Preloader::Association:0x0000ffff7da75fa8
@associate=true,
@key_conversion_required=false,
@klass=
Photo(id: integer, name: string, url: string, description: string, created_at: datetime, updated_at: datetime, category: integer, user_id: integer),
@model=User(id: integer, github_login: string, name: string, avatar: string, created_at: datetime, updated_at: datetime),
@owners=
[#<User:0x0000ffff7da76ae8
id: 1,
github_login: "test_user1",
name: "テストユーザー1",
avatar: "https://example.com/avatars/1.jpg",
created_at: Sun, 16 Feb 2025 13:10:56.924173000 UTC +00:00,
updated_at: Sun, 16 Feb 2025 13:10:56.924173000 UTC +00:00>],
@owners_by_key=
{1=>
[#<User:0x0000ffff7da76ae8
id: 1,
github_login: "test_user1",
name: "テストユーザー1",
avatar: "https://example.com/avatars/1.jpg",
created_at: Sun, 16 Feb 2025 13:10:56.924173000 UTC +00:00,
updated_at: Sun, 16 Feb 2025 13:10:56.924173000 UTC +00:00>]},
@preload_scope=nil,
@preloaded_records=
[#<Photo:0x0000ffff7da73ac8
id: 6,
name: "カフェでの一枚",
url: "https://example.com/photos/6.jpg",
description: "カフェでの一枚の説明文です。",
created_at: Sun, 16 Feb 2025 13:10:56.961609000 UTC +00:00,
updated_at: Sun, 16 Feb 2025 13:10:56.961609000 UTC +00:00,
category: "graphic",
user_id: 1>,
... 略 ...
#<Photo:0x0000ffff7da730c8
id: 30,
name: "リモートワーク",
url: "https://example.com/photos/30.jpg",
description: "リモートワークの説明文です。",
created_at: Sun, 16 Feb 2025 13:10:57.034712000 UTC +00:00,
updated_at: Sun, 16 Feb 2025 13:10:57.034712000 UTC +00:00,
category: "selfie",
user_id: 1>]},
@reflection=
#<ActiveRecord::Reflection::HasManyReflection:0x0000ffff7ecb8df0
@active_record=User(id: integer, github_login: string, name: string, avatar: string, created_at: datetime, updated_at: datetime),
@active_record_primary_key="id",
@class_name="Photo",
@foreign_key="user_id",
@inverse_name=:user,
@inverse_of=
#<ActiveRecord::Reflection::BelongsToReflection:0x0000ffff7df18178
@active_record=
Photo(id: integer, name: string, url: string, description: string, created_at: datetime, updated_at: datetime, category: integer, user_id: integer),
@class_name="User",
@foreign_key="user_id",
@inverse_name=nil,
@klass=User(id: integer, github_login: string, name: string, avatar: string, created_at: datetime, updated_at: datetime),
@name=:user,
@options={},
@plural_name="users",
@scope=nil>,
@klass=
Photo(id: integer, name: string, url: string, description: string, created_at: datetime, updated_at: datetime, category: integer, user_id: integer),
@name=:photos,
@options={:dependent=>:destroy},
@plural_name="photos",
@scope=nil>,
@reflection_scope= Photo Load (33.1ms) SELECT `photos`.* FROM `photos` /*application:Myapp*/
[#<Photo:0x0000ffff7d8919a8
id: 1,
name: "美しい夕日",
url: "https://example.com/photos/1.jpg",
description: "美しい夕日の説明文です。",
created_at: Sun, 16 Feb 2025 13:10:56.948130000 UTC +00:00,
updated_at: Sun, 16 Feb 2025 13:10:56.948130000 UTC +00:00,
category: "selfie",
user_id: 5>,
... 以下略 ...
preload_associationメソッドにより、渡されたすべての records に対して、一括で指定されたアソシエーション(今回の例では :photos)を読み込みます。
これにより、各レコードの関連データを個別に問い合わせるのではなく、1回のクエリでデータを一括取得し、N+1問題を回避することができます。
今回の例で、recordsに格納されているのは[User.first]ですが、recordsに10件のUserモデルのインスタンスが格納されている場合でも、1回のクエリで関連する全ての写真データを一括取得することができます。(preload_associationを実行しない場合、次の行のrecords.each〜で10回のクエリが実行されることになります。)
それでは、次の行のrecords.each〜を見ていきます。
records.each { |record| fulfill(record, read_association(record)) }
read_association(record)は、下記の通りとなっています。
このメソッドは、各レコードから、すでにpreload_associationによって読み込まれたアソシエーションを取得します。
今回の例だと、User.first.public_send(:photos)のようになり、User オブジェクトの関連データ(Photoの配列)を返します。
こうして、loadメソッドで作成されたすべてのPromiseインスタンスがfulfilled状態となり値が確定します。
まとめ
最後に、ここまで確認してきた内容をまとめてみます。
GraphQL::Batch::Loader.for がやってること
-
AssociationLoader.forを実行すると、GraphQL::Batch::Loader.forが呼び出される -
GraphQL::Batch::Loader.forは、GraphQL::Batch::Executor.currentを呼び出して、現在のExecutorインスタンスを取得する -
GraphQL::Batch::Executor#loaderは、与えられたkeyに対して既に作成済みのローダーがあるかどうかを確認し、なければ新しいローダーのインスタンスを生成する
AssociationLoader#load がやってること
-
AssociationLoader.for(model, association_name).load(object)を実行すると、GraphQL::Batch::Loader#loadが呼び出される -
GraphQL::Batch::Loader#loadは、指定されたcache_key(key)に対応するPromiseインスタンスがあればそれを返し、なければqueueにkeyを追加した上で新しいPromiseインスタンスを作成して返す -
AssociationLoader#waitを通じて、GraphQL::Batch::Loader#resolveが実行され、AssociationLoader#performが実行される -
AssociationLoader#performは、preload_associationメソッドを実行し、渡されたすべてのrecordsに対して一括で指定されたアソシエーションを読み込む -
records.each { |record| fulfill(record, read_association(record)) }により、各レコードから、すでにpreload_associationによって読み込まれたアソシエーションを取得する
graphql-batchのAssociationLoaderが何をしているのか、コードリーディングをすることで、全容理解までは至らなくとも、以前よりは理解を深めることができました。
今後、examplesにある他のLoaderを利用したり、カスタムLoaderを作成する際に今回学んだ内容を活かせたらなと思います!
理解できた部分がある一方で、下記のように新しく「わからないこと」も出てきました...
- graphql-rubyの遅延実行の仕組み
- graphql-rubyのDataloaderとの違い
- Promise.rbによる非同期処理の仕組み
これらについては、今回スルーしてしまったので、別で機会を見つけて調べてみたいと思います!
最後まで読んでいただき、ありがとうございました🙇♂️
Discussion