はじめに
こんにちは、SaaS事業部(サクミル)のエンジニアの栃川です。
今回は、N+1問題を防ぐGraphQL Rubyの「GraphQL::Dataloader」について調べたことをまとめていきたいと思います。
対象とする読者
- GraphQL Rubyについて基本的な知識があるという方
- GraphQL::Dataloaderについて概要を知りたい方
GraphQL::Dataloaderとは
GraphQL::Dataloaderは、データベースやマイクロサービスなどの外部サービスへの効率的なバッチ アクセスを提供してくれます。
その仕組みはとてもシンプルで2つのアプローチを行います。
- GraphQLフィールドは、何が要求されているかをクエリパラメーターやオブジェクトIDなどをリクエストキーとして登録します。
- できるだけ多くのリクエストキーを収集した後に、まとめて外部サービス(例えば、DB)へのフェッチを開始する
このアプローチによって、GraphQLでは取得したいデータのノードを辿って必要なデータを一度に取得できるため、不必要にSQLが大量発行されてしまうN+1問題を防ぐことができます。
GraphQL::Dataloaderの実装例
実際の実装例を紹介します。
前提
ある会社Aの従業員は必ず1つの部署だけに属しているとします。
この場合、ある会社Aの従業員と部署名を取得する例を考えます。
query Employees ( $companyId: ID! ) { company: node(id: $companyId) { ... onCompany { id employees { nodes { id department { id name } } } } } }
fieldの定義
まずはfieldを定義していきます。
departmentフィールドは、dataloader.with(::Sources::Department).load(object.id)
と定義します。
こうすることで、object.id
(employeeのid)をリクエストキーとして、「リクエストをしたい」ということだけをGraphQL::Dataloaderに伝えます。
あとで記載しますが、これによって逐一SQLが発行されることを防ぎ、N+1問題の発生を回避できます。
module Types module Objects class Employee < Types::Bases::Object field :department, Types::Objects::Department, null: false def department dataloader.with(::Sources::Department).load(object.id) end end end end
sourcesの実装
次にSourcesを定義していきます。
fetchメソッドには、GraphQL::Dataloader が外部サービスからデータをフェッチするためのロジックを定義します。
引数のemployee_ids
は、loadメソッドに渡されたobject.id
の集まりが配列として渡されます。
つまり、要求されているリクエストの全リクエストキーがfetchメソッドの引数に渡されます。
今回の例の場合、取得するべき全ての従業員のidがemployee_ids
としてfetchメソッドに渡されます。
fetchメソッドでは、このemployee_ids
を使い、@model_class.where(employee_id: employee_ids)
と定義します。
# frozen_string_literal: true module Sources class Department < GraphQL::Dataloader::Source def initialize() @model_class = :Department end def fetch(employee_ids) @model_class.where(employee_id: employee_ids) end end end
上記のように定義することで、以下のようなSQLが発行されて、一気に部署を取得してくることができます!!
SELECT "departments".* FROM "departments" WHERE "departments"."employee_id" IN (1, 2, 3, 4)
どんな仕組みになっているのか?
GraphQL::Dataloaderが実際にどんな仕組みで動いているのかを見ていきます。
Fiberについて
前提として、GraphQL::Dataloaderの実装は内部的にはノンプリエンプティブな軽量スレッドであるRubyのFiberを利用して、実装されています。
このFiberについては、普段からRubyを使われていたとしてもあまり知らない方もいると思うので簡単に説明します。
Fiberは、任意の場所で実行を中断して再開できる機能を提供します。
例として、非同期処理の実装例を紹介します。
# 非同期処理のシミュレーション fiber1 = Fiber.new do 5.times do |i| puts "Fiber 1 - ステップ #{i}" Fiber.yield end end fiber2 = Fiber.new do 3.times do |i| puts "Fiber 2 -ステップ #{i}" Fiber.yield end end # メインループでFiberを順番に実行 loop do if fiber1.alive? fiber1.resume end if fiber2.alive? fiber2.resume end break unless fiber1.alive? || fiber2.alive? end puts "完了!"
このコードでは、fiber1
とfiber2
を順番に実行しています。
Fiber.yield
によって処理が中断するたびにメインループに戻ります。
そして、resume
メソッドを使い、再度再開することができます。
つまり、Fiberを通じてコンテキストを切り替えることで、処理を交互に行っています。
なお、上記のコード出力結果は以下の通りです。
Fiber 1 -ステップ 0 Fiber 2 - ステップ 0 Fiber 1 - ステップ 1 Fiber 2 - ステップ 1 Fiber 1 - ステップ 2 Fiber 2 - ステップ 2 Fiber 1 - ステップ 3 Fiber 1 - ステップ 4 完了!
このように、Fiberを使うことで処理を途中で中断したり再開したりすることができます。
GraphQL::Dataloaderの仕組み
では、GraphQL::DataloaderではFiberは何の用途で使われているのでしょうか?
答えは、上述した「2. できるだけ多くのリクエストキーを収集した後に、まとめて外部サービス(例えば、DB)へのフェッチを開始する」という挙動を実現するために利用されています。
GraphQLでは、クエリ全体の処理を親Fiberが担い、各フィールド処理を子Fiberが担当します。 (親Fiberと子ファイバーはGraphQL::DataLoader#runメソッドで生成されます。)
GraphQLフィールドの処理が行われる時、loadメソッドが呼び出されると、内部的には下記のsyncメソッドが呼び出されます。
syncメソッドは、子Fiberの処理を中断し、親Fiberへコンテキストを移します。
MAX_ITERATIONS = 1000 # Wait for a batch, if there's anything to batch. # Then run the batch and update the cache. # @return [void] def sync(pending_result_keys) # 親Fiberにコンテキストを移す @dataloader.yield iterations = 0 # 処理が保留されたフィールドがある場合 while pending_result_keys.any? { |key| !@results.key?(key) } iterations += 1 if iterations > MAX_ITERATIONS raise "#{self.class}#sync tried #{MAX_ITERATIONS} times to load pending keys (#{pending_result_keys}), but they still weren't loaded. There is likely a circular dependency." end # 親Fiberにコンテキストを移す @dataloader.yield end nil # メソッドの終了 end
こうすることで、GraphQLフィールドの処理が行われる際に、loadメソッドが呼ばれると、 そのフィールドの処理は一時中断し、次のフィールドへ処理が移ります。
この処理を兄弟フィールド全てに対して行います。
その後、親Fiberは未処理状態のフィールドを解決するため、GraphQL::DataLoader::Source#fetchメソッド
に定義された処理を行います。
fetchメソッドは、実装例にあるように、データを取得するためのロジックを定義する必要があります。
# Subclasses must implement this method to return a value for each of `keys` # @param keys [Array<Object>] keys passed to {#load}, {#load_all}, {#request}, or {#request_all} # @return [Array<Object>] A loaded value for each of `keys`. The array must match one-for-one to the list of `keys`. def fetch(keys) # somehow retrieve these from the backend raise "Implement `#{self.class}#fetch(#{keys.inspect}) to return a record for each of the keys" end
このように、Fiberの特性を用いることで、複数のフィールドの処理を一時停止して、最後にまとめて取得するといったことをGraphQL::Dataloaderでは行っているということがわかりました。
まとめ
今回は、簡単にGraphQL::Dataloaderについて内部実装を確認しながら自分なりにまとめてみました。
GraphQL::Dataloaderがあることで、開発時に特別意識しなくとも、 N+1問題を回避しつつ効率的にデータ取得することができると理解できました。
まだまだGraphQL::Dataloaderへの理解は浅いので、これからもさらに深掘っていきたいと思います。
さいごに
最後に、弊社では全ての事業部でエンジニア採用を積極的に行なっています。
少しでも興味を持っていただけた方は業務委託や副業からでも、ぜひご応募ください!