GraphQL::Batchを使ってよくあるユースケースをTwitterを例に実装してみる

f:id:ishitsukajun:20220314122450p:plain

こんにちは、プレックスの 石塚 です。

今回のブログでは、Ruby向けのデータローダーである GraphQL::Batch を使用して、よくアプリケーションで実装されるユースケースの実装方法をTwitterを例に紹介します。

データローダーとは?

データローダーとはGraphQLに用意されているデータを一括取得するための仕組みです。

例えば以下のようにユーザーを5件取得して、そのユーザーのツイート一覧をデータローダーを使用しないで取得しようとすると下記のように6回(5 + 1回)のクエリが発生することになります。

{
  users(first: 5) {
    id
    tweets {
      id
      content
    }
  }
}
SELECT id FROM users limit 5;
SELECT id, content FROM tweets WHERE id = 1;
SELECT id, content FROM tweets WHERE id = 2;
SELECT id, content FROM tweets WHERE id = 3;
SELECT id, content FROM tweets WHERE id = 4;
SELECT id, content FROM tweets WHERE id = 5;

これをデータローダーを使用することにより、以下のように2回のクエリでデータを取得できるようになります。

SELECT id FROM users limit 5;
SELECT id, content FROM tweets WHERE id IN (1, 2, 3, 4, 5);

GraphQLにおけるデータローダーの必要性

GraphQLではオブジェクトがネストしており、再帰的に処理を読み出せる構造になっているため、N+1が起きやすいという特徴があります。

Railsに慣れている方ならば、それってpreloadやincludesを使用すればN+1を避けることができるのではないのか?と思うかもしれません。 しかしその方法で対処してしまうと関連するモデルの呼び出しが必要のないところでも、不必要にpreloadやincludesなどが走ってしまったり、再帰的に呼び出された場合にN+1を避けることが難しくなってしまいます。そのため、基本的にはデータローダーを使うのがベターです。

本エントリーで実装したいこと

以下の3つのユースケースを実装していきます。Twitterを例にしていますが、サブテーマとして書いているように、アプリケーションを作っていると似たような機能を作ることがよくあるのではないかと思います。

  • 複数のユーザーのツイート一覧を取得する
    • ActiveRecordのリレーションをデータローダーに置き換える
  • あるユーザーのツイート一覧をいいね数と一緒に取得する
    • GROUP BYでのカウントの実装
  • あるユーザーのツイート一覧を自身がいいねしたかどうかと一緒に取得する
    • WHERE句などの条件指定をできるように拡張する

前準備

動作環境やライブラリのバージョンは下記のとおりです。

バージョン
ruby 3.0.2
rails 6.1.4
graphql-ruby 1.12.17
graphql-batch 0.4.3

Rubyにはgraphql-ruby標準のデータローダーもありますが、弊社では、スターの数が多く、使いやすいと感じた GraphQL::Batch を使っています。

以下のモデルファイルと対応するテーブルが存在すると仮定します。

class User < ApplicationRecord
  has_many :tweets
  has_many :favorites
end

class Tweet < ApplicationRecord
  has_many :favorites
  belongs_to :user
end

class Favorite < ApplicationRecord
  belongs_to :user
  belongs_to :tweet
end

実装

複数のユーザーのツイート一覧を取得する(ActiveRecordのリレーションをデータローダーに置き換える)

まずは一番基本的なユースケースとして起こり得そうな、複数のユーザーのツイート一覧を取得するQueryから実装していきます。

N+1を引き起こすコードを書いてから、それをデータローダーを使った実装にリファクタリングしていくという流れで書いていきます。

N+1を引き起こすコード

# app/graphql/types/query_type.rb

module Types
  class QueryType < Types::BaseObject
    include GraphQL::Types::Relay::HasNodeField
    include GraphQL::Types::Relay::HasNodesField

    field :users, [UserType], null: false do
      description 'ユーザー一覧をツイート一覧と共に取得する'
    end

    def users
      User.limit(5)
    end
  end
end
# app/graphql/types/user_type.rb

module Types
  class UserType < Types::BaseObject
    field :id, ID, null: false
    field :tweets, [TweetType], null: false
  end
end
# app/graphql/types/tweet_type.rb

module Types
  class TweetType < Types::BaseObject
    field :id, ID, null: false
    field :content, String, null: false
  end
end

これでN+1を引き起こすQueryの実装は完了です。幸か不幸かRailsにはアソシエーションという機能があるため、たったこれだけのコードでツイートとともにユーザーを取得するAPIを書けるのですが、N+1を引き起こしてしまいます。

データローダーを使ったコード

これをデータローダーを使ったコードに書き換えていきます。まずは GraphQL::Batch のインストール。

$ gem install graphql-batch

次にスキーマGraphQL::Batch を使うように宣言します。

# app/graphql/my_schema.rb

class MySchema < GraphQL::Schema
  query MyQueryType
  mutation MyMutationType
  use GraphQL::Batch
end

GraphQL::Batch にはRailsのアソシエーションをよしなに読み込んでくれる AssociationLoader というクラスがサンプルで用意されています。 これと同じコードを app/graphql/loaders/association_loader.rb に記述します。

# app/graphql/loaders/association_loader.rb

module Loaders
  class 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

そして UserType から作成した AssociationLoader を使うように変更します。

# app/graphql/types/user_type.rb

module Types
  class UserType < Types::BaseObject
    field :id, ID, null: false
    field :tweets, [TweetType], null: false

    def tweets
      Loaders::AssociationLoader.for(User, :tweets).load(object)
    end
  end
end

これでリレーションではなく、データローダーを使ってツイートを読み込んでくれるため、N+1を解消できます。

あるユーザーのツイート一覧をいいね数と一緒に取得する(GROUP BYでのカウントの実装)

次にいいね数をカウントしてツイート一覧を返す実装です。

N+1を引き起こすコード

こちらもN+1を起こす実装から作っていきましょう。

# app/graphql/types/query_type.rb

module Types
  class QueryType < Types::BaseObject
    field :tweets, [TweetType], null: false do
      description 'ツイート一覧をいいね数と共に取得する'
    end

    def tweets
      Tweet.limit(5)
    end
  end
end
# app/graphql/types/tweet_type.rb

module Types
  class TweetType < Types::BaseObject
    field :id, ID, null: false
    field :content, String, null: false
    field :favorite_count, Int, null: false

    def favorite_count
      object.favorites.size
    end
  end
end

こちらで無事に?N+1クエリが発生します。

データローダーを使ったコード

次にデータローダーを作ってN+1を解消していきます。アソシエーション用のデータローダーは GraphQL::Batch で用意されていましたが、集計用のデータローダーは用意されていないため、アソシエーションを参考に自前で作っていきます。

# app/graphql/loaders/record_count_loader.rb

module Loaders
  class RecordCountLoader < GraphQL::Batch::Loader
    def initialize(model, column: model.primary_key, distinct_column: nil)
      super()
      @model = model
      @column = column.to_s
      @column_type = model.type_for_attribute(@column)
      @distinct_column = distinct_column
    end

    def load(key)
      super(@column_type.cast(key))
    end

    def perform(keys)
      query(keys).each { |key, count| fulfill(key, count) }
      keys.each { |key| fulfill(key, 0) unless fulfilled?(key) }
    end

    private

      def query(keys)
        scope = @model
        scope.where(@column => keys)
             .group(@column)
             .distinct(@distinct_column)
             .count
      end
  end
end

そして TweetType で実装したデータローダーを使うようにします。

# app/graphql/types/tweet_type.rb

module Types
  class TweetType < Types::BaseObject
    field :id, ID, null: false
    field :content, String, null: false
    field :favorite_count, Int, null: false

    def favorite_count
      Loaders::RecordCountLoader.for(Favorite,
                                     column: 'tweet_id',
                                     distinct_column: :tweet_id).load(object.id)
    end
  end
end

これでN+1を回避できます。データローダーを汎用的に使い回せる形で実装しておくと、タイプから呼び出すだけで実装が済むので便利ですね。

あるユーザーのツイート一覧を自身がいいねしたかどうかと一緒に取得する(WHERE句などの条件指定をできるように拡張する)

最後に特定のユーザーのツイート一覧を自身がいいねしたかどうかと一緒に取得するクエリを考えます。

N+1を引き起こすコード

こちらもまずはN+1を起こすクエリから書いていきます。

# app/graphql/types/query_type.rb

module Types
  class QueryType < Types::BaseObject
    field :user, UserType, null: false do
      description '特定のユーザーのツイート一覧を自身がいいねしたかどうかと一緒に取得する'
      argument :id, ID, required: true
    end

    def tweet(id:)
      Tweet.find(id)
    end
  end
end

UserType には先ほど作成した AssociationLoader でツイート一覧を読み込みます。

# app/graphql/types/user_type.rb

module Types
  class UserType < Types::BaseObject
    field :id, ID, null: false
    field :tweets, [TweetType], null: false

    def tweets
      Loaders::AssociationLoader.for(User, :tweets).load(object)
    end
  end
end

TweetType に新しく is_favorited という自身がいいねしたかどうかを表すフィールドを追加します。

認証情報は context に入っていると仮定して、 自身のユーザーIDをそこから取得しています。

# app/graphql/types/tweet_type.rb

module Types
  class TweetType < Types::BaseObject
    field :id, ID, null: false
    field :content, String, null: false
    field :favorite_count, Int, null: false

    def favorite_count
      Loaders::RecordCountLoader.for(Favorite,
                                     column: 'tweet_id',
                                     distinct_column: :tweet_id).load(object.id)
    end

    def is_favorited
      object.favorites.exists?(user_id: context[:user].id)
    end
  end
end

これでいいねしたかどうかの情報が取れるようになり、N+1が発生します。

データローダーを使ったコード

RecordPresenceLoader というデータローダーを新しく作成します。

RecordCountLoader との違いとしては引数として where を受け取れるようにしている点です。 GraphQL::Batch では ActiveRecord をベースにデータローダーを拡張していけるので、このような形でWHERE句を追加したり、JOIN句を追加していくことができます。

# app/graphql/loaders/record_presence_loader.rb

module Loaders
  class RecordPresenceLoader < GraphQL::Batch::Loader
    def initialize(model, column: model.primary_key, where: nil)
      super()
      @model = model
      @column = column.to_s
      @column_type = model.type_for_attribute(@column)
      @where = where
    end

    def load(key)
      super(@column_type.cast(key))
    end

    def perform(keys)
      query(keys).each { |record| fulfill(record.public_send(@column), record.present?) }
      keys.each { |key| fulfill(key, false) unless fulfilled?(key) }
    end

    private

      def query(keys)
        scope = @model
        scope = scope.where(@where) if @where
        scope.where(@column => keys)
      end
  end
end

そして RecordPresenceLoader をタイプから使用します。

# app/graphql/types/tweet_type.rb

module Types
  class TweetType < Types::BaseObject
    field :id, ID, null: false
    field :content, String, null: false
    field :favorite_count, Int, null: false

    def favorite_count
      Loaders::RecordCountLoader.for(Favorite, column: 'tweet_id', distinct_column: :tweet_id).load(object.id)
    end

    def is_favorited
      Loaders::RecordPresenceLoader.for(Favorite,
                                        column: 'tweet_id',
                                        where: {
                                          user_id: context[:user].id
                                        }).load(object.id)
    end
  end
end

これで自身がいいねしたかどうかの情報もN+1を起こさずに取得できるようになりました。

終わりに

今回の記事はデータローダーのライブラリの使い方という小ネタでしたが、普段GraphQLの実装をしていると、日本語で書かれている情報がまだまだ少ないと感じることが多いため、少しでも参考になればと思って書いてみました。

最後になりますが、プレックスではソフトウェアエンジニアフロントエンドエンジニアを募集しています。

少しでも興味を持っていただけた方は業務委託や副業からでも、ぜひご応募いただけると幸いです。

dev.plex.co.jp