雰囲気で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