もふもふ技術部

IT技術系mofmofメディア

(Rails7 + GraphQL)graphql-batchでN+1を解消してみた

はじめに

UserとPostが1対多で結びついているような状況で、userとpostを一度 に取得することを想定しています。

記事について

graphql-ruby初学者向けの記事を書いていきます。

関連記事

  1. graphql-batchでN+1を解消してみた(現在の記事)
  2. 無限ページネーション
  3. 個人開発のcodegen.ymlの設定について考えてみた
  4. ファイルアップロード機能を実装してみた

使用技術

ruby 3.1.2
rails 7.0.3.1
graphql 2.0.13
graphql-batch 0.5.1

N+1が発生するコード

has_many :posts
belongs_to :user
module ObjectTypes
  class User < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: false

    # userのオブジェクトタイプにpostsを定義することで
    # user一覧および紐づくpost一覧を取得することができる
    # しかし、この構成にするとN+1が発生する
    field :posts, [ObjectTypes::Post], null: false
  end
end
module ObjectTypes
  class Post < Types::BaseObject
    field :id, ID, null: false
    field :title, String, null: false
  end
end
module Queries
  class Users < Queries::BaseQuery
    type [ObjectTypes::User], null: false

    def resolve
      User.all
    end
  end
end

users一覧取得

{
  users {
    id
    name
    posts {
      id
      title
    }
  }
}

上記のqueryをGraphiQLで実行してみると、N+1が発生しました。

User Load (0.4ms)  SELECT "users".* FROM "users"

Post Load (0.2ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = $1  [["user_id", 1]]
Post Load (0.2ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = $1  [["user_id", 2]]
Post Load (0.1ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = $1  [["user_id", 3]]

では解消していきます!

graphql-batch導入しN+1を解消する

N+1解消用のgemです! https://github.com/Shopify/graphql-batch

gem 'graphql-batch'
class MyappSchema < GraphQL::Schema
  mutation(Types::MutationType)
  query(Types::QueryType)
  use GraphQL::Batch #この行だけ追加

  # 以下省略
end
# fieldのpostsは残したまま、下記メソッド追加
def posts
  Loaders::AssociationLoader.for(::User, :posts).load(object)
end
# graphql-batchのexampleのコピペ
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:, 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
User Load (0.5ms)  SELECT "users".* FROM "users"
Post Load (0.3ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN ($1, $2, $3)  [["user_id", 1], ["user_id", 2], ["user_id", 3]]

N+1が解消されました! 処理の流れとしては、userを全て取得し、下記のメソッドで一括preloadしてpostを取得しています。 引数のrecordsはuserオブジェクトの配列、@association_name:postsがそれぞれ格納されています。

def preload_association(records)
  ::ActiveRecord::Associations::Preloader.new(records:, associations: @association_name).call
end

取得したpost一覧に処理を加える

ついでに、postの順序を作成日順に並び替えたい等、処理を追加したいという時はthenを使います。 下記のようにpostsメソッドを修正すると、作成日順に並び替えることができます。

def posts
  Loaders::AssociationLoader.for(::User, :posts).load(object).then do |posts|
    posts.order(created_at: :desc)
  end
end

post一覧と、関連付いたuserを取得する(おまけ)

下記クエリのように、post一覧で関連付くuserを取得したいというような時は、こんな感じで記述すると取得できます。

{
  posts {
    id
    title
    user {
      id
      name
    }
  }
}
field :user, ObjectTypes::UserType, null: false

def user
  Loaders::AssociationLoader.for(::Post, :user).load(object)
end

会社の紹介

株式会社 mofmof では一緒に働いてくれるエンジニアを募集しています。 興味のある方は是非こちらのページよりお越しください! https://www.mof-mof.co.jp/ https://www.mof-mof.co.jp/recruit/