🦔

N+1の解消だけじゃない。GraphQL::Batchを使ってfieldの並列取得をサクッと実装する

2023/04/04に公開

GraphQLで発生するN+1問題を解消するために、GraphQL::Batchを使っていたのですが、GraphQL::BatchはN+1解消だけではなく、fieldの並列取得にも使えることに気づいたので検証してみました。

https://github.com/Shopify/graphql-batch

なお、GraphQL::Batchを使ってN+1を解消する方法は別途記事を書いているので興味がある方はこちらをどうぞ!

https://zenn.dev/hamchance/articles/b2f8471f189945

検証

取得に5秒かかるフィールドを2つ準備します。

app/models/user.rb
class User < ApplicationRecord
  def slow_field
    5.times.each do
      Rails.logger.info('slow_field')
      sleep(1)
    end
    'slow_field'
  end

  def slow_field2
    5.times.each do
      Rails.logger.info('slow_field2')
      sleep(1)
    end
    'slow_field2'
  end
end

上記フィールドを取得するuserクエリーを実装します。

app/graphql/types/query_type.rb
module Types
  class QueryType < Types::BaseObject
    field :user, Types::UserType, null: true do
      argument :id, Int, required: true
    end

    def user(id:)
      User.find id
    end
  end
end
app/graphql/types/user_type.rb
module Types
  class UserType < Types::BaseObject
    field :slow_field, String, null: false
    field :slow_field2, String, null: false
  end
end

下記のクエリーを実行します。

query User($id: Int!) {
  user(id: $id) {
    slowField
    slowField2
  }
}

クエリーを実行すると実行ログは下記のようになります。

slow_field
slow_field
slow_field
slow_field
slow_field
slow_field2
slow_field2
slow_field2
slow_field2
slow_field2
Completed 200 OK in 10120ms (Views: 1.4ms | ActiveRecord: 1.1ms | Allocations: 9342)

ログからslowFieldslowField2を直列で取得していることがわかります。
それぞれ5秒かかるので返却されるまで10秒ほどかかっています。

並列実行

並列実行できるようにローダーを追加します。

app/loader/promise_loader.rb
class PromiseLoader < GraphQL::Batch::Loader
  def initialize(model)
    super()
    @model = model
  end

  def perform(methods)
    futures = methods.map do |method|
      Concurrent::Promises.future { @model.public_send(method) }
    end
    methods.each_with_index.each do |method, index|
      fulfill(method, futures[index].value)
    end
  end
end

UserTypePromiseLoaderを使うようにします。

app/graphql/types/user_type.rb
module Types
  class UserType < Types::BaseObject
    field :slow_field, String, null: false
    field :slow_field2, String, null: false

    def slow_field
      PromiseLoader.for(object).load(:slow_field)
    end

    def slow_field2
      PromiseLoader.for(object).load(:slow_field2)
    end
  end
end

この状態で先ほどと同じクエリーを実行すると下記のようにログ出力されます。

slow_field
slow_field2
slow_field
slow_field2
slow_field
slow_field2
slow_field
slow_field2
slow_field
slow_field2
Completed 200 OK in 5036ms (Views: 1.1ms | ActiveRecord: 0.9ms | Allocations: 1439)

ログからslowFieldslowField2を並列で取得していることがわかります。
それぞれ5秒かかる処理ですが、並列で取得しているので5秒ほどで取得できています。

Discussion