giftee engineer blog

graphql-batch で最強の Loader を実装した

2022-10-27

graphql-batch を利用した N+1 解決に対する課題を、Loader の実装で解決した話

thumbnail

はじめに

こんにちは、ギフティでエンジニアをしている @memetics10 です。

ギフティでは最近、 web API として GraphQL を採用するケースが増えてきており、私自身も GraphQL の柔軟性や効率性を日々実感しています。

ですが、そんな利便性の高い GraphQL にも特有の課題があります。 それは N+1 クエリの問題です。

この問題に対する対策としては Facebook が提供する Dataloader を用いる方法が一般的かと思いますが、 ギフティでは graphql-ruby を用いて GraphQL API を実装することが多いため、Shopify の提供する graphql-batchや graphql-ruby の提供する Dataloaderで対処しています。

これらのライブラリは大変便利なものですが、ギフティでは先述の graphql-batch を利用する上で、とある課題に直面していました。 そこで今回は、ギフティが graphql-batch にどのような課題を抱いていたのか、そしてそれをどのように解決したのかについてお話しします。

GraphQL の N+1 について

まずは、GraphQL で発生する N+1 問題について簡単に説明します。

GraphQL の大きな特性は、クライアントがグラフ構造にそって柔軟にデータを取得できることです。 ですが、その柔軟さゆえに Rails の ActiveRecord::Associations::Preloader#preload のような先読み方式では N+1 を解決できません。

たとえば User has_one Profile というリレーションをを考えたとき、GraphQL query の投げ方によって N+1 クエリになるケースとならないケースが発生します。

以下に具体的を提示します。

まず、複数ユーザーを取得する際には、profile の解決で N+1 になります。

users {
  id
  profile {
    name
  }
}

実装で見ると Types::UserType#profile の部分が N+1 クエリの原因です。

module Types
  class UserType < Types::BaseObject
    field :profile, ProfileType, null: true

    # User の数だけ Types::UserType#profile が呼ばれ、 その度に DB read しにいく
    def profile
      object.profile
    end
  end
end

一方、profile を取得しない際には Types::UserType#profile が呼ばれず、 N+1 にはなりません。

users {
  id
}

また、単一ユーザーを取得する際にも Types::UserType#profile は1度しか呼ばれず、 N+1 にはなりません。

user {
  id
  profile {
    name
  }
}

現実世界ではこれ以外にもさまざまな取得パターンが考えられるため、サーバーは発行される SQL を事前に予測できず、以下のような先読み方式では N+1 に対処することができません。

以下の例では articles や profile を取得しない場合に余計にテーブルを読んでしまうことになり、非効率になってしまいます。

module Types
  class QueryType < Types::BaseObject
    field :users, [UserType], null: false

      def users
        User.all.preload([:articles, :profile])
      end
    end
  end
end

これが、GraphQL 特有の N+1 問題です。

graphql-batch について

graphql-ruby を利用している場合、graphql-batch を用いて GraphQL の N+1 問題を解決することができます。 (Dataloader を利用する方法もありますが、今回は説明を省略します。)

この gem は DB read を field 解決の再帰処理が終了するまで遅延させる後読み方式をとっており、 field 解決時に key を溜めていき、再帰終了時に溜まった keys をもとにまとめて SQL を発行する仕組みになっています。 このコンセプトを batching と呼ぶそうです。 データを実際に取得する処理の実行遅延は promise.rb によって実現されているらしいのですが、詳細な原理まではわかりませんでした。

graphql-batch を利用すると、以下のような実装で N+1 問題を解決できます。

module Types
  class UserType < Types::BaseObject
    field :profile, ProfileType, null: true

    # graphql query の形によっては再帰的に複数回呼び出されるが、即座に SQL を発行せず Promise を返す
    # load メソッドで key だけを溜め続ける
    def profile
      # object.profile だと N+1 になるので以下のようにする
      ProfileLoader.for.load(object.id)
    end
  end
end

class ProfileLoader < GraphQL::Batch::Loader
  # 再帰終了時に1度だけ呼ばれ、load によって溜められた keys をもとにSQL を発行する
  def perform(keys)
    # GraphQL::Batch::Loader#fullfill が Promise を解決する
    Profile.where(user_ids: keys).each{|profile| fulfill(profile.id, profile)}
  end
end

Loader の実装

さて、冒頭でも述べた通り、graphql-batch は GraphQL::Batch::Loader というクラスを提供してくれていますが、 利用者が N+1 を解決するためには、このクラスを継承した Loader を自前で実装する必要があります。

たとえば、 GraphQL::Batch::Loader#perform を見ると、Loader クラスは perform の実装が必須となっていることがわかります。

module GraphQL::Batch
  class Loader
    # Must override to load the keys and call #fulfill for each key
    def perform(keys)
      raise NotImplementedError
    end
  end
end

GraphQL::Batch::Loader を利用した Loader を自前で実装できる、という自由度が与えられている一方で、 ギフティのプロダクトでは、「どのような粒度で Loader クラスを実装すべきか?」という点で課題感を持っていました。

つまり、「あらゆる field で汎用的かつ共通で使える Loader を実装すべきか、field ごとに具体個別の Loader を実装すべきか」という点です。 この両者はトレードオフの関係性であり、どちらも完璧な選択ではない、というのが当時の答えでした。 以下で両者の具体的な違いを記載します。

あらゆる field で汎用的かつ共通で使える Loader

この Loader は、 実質的に graphql-batch でサンプル実装が用意されている AssocationLoader を指しています。

引数に関連を渡せるので、あらゆる field でこの Loader を利用することができます。

module Types
  class UserType < Types::BaseObject
    field :profile, ProfileType, null: true
    field :articles, [ArticleType], null: false

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

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

具体個別の Loader

一方、field ごとに個別に Loader を実装する方法も考えられます。

各 Loader の実装は GraphQL::Batch::Loader が規定する範囲で自由です。 たとえば「graphql-batch について」の章で書いた ProfileLoader は具体個別の Loader の実装例となります。

module Types
  class UserType < Types::BaseObject
    field :profile, ProfileType, null: true
    field :articles, [ArticleType], null: false

    def profile
      # ProfileLoader の実装は自由
      ProfileLoader.for.load(object)
    end

    def articles
      # ArticlesLoader の実装は自由
      ArticlesLoader.for.load(object)
    end
  end
end

ギフティでは、前者のあらゆる field で汎用的かつ共通で使える Loader(=AssociationLoader)を利用していました。 開発当初は汎用的な Loader がうまく機能していましたが、GraphQL 周辺のコードが 増加していくにつれて、とある問題に直面しました。

それは、以下のコードのように GraphQL Object Type で callback によるネストや if による分岐が頻出してしまう、ということでした。

※ 実際のプロダクションコードではないので、GraphQL Schema の設計は参考にしないでください

class UserType < Types::BaseObject
  field :gender, GenderType, null: true

  def gender
    :AssociationLoader.for(User, :profile).load(object).then do |profile|
      if profile
        :AssociationLoader.for(Profile, :gender).load(profile).then do
          object.gender
        end
      end
    end
  end
end

この問題に対しての解決策として、具体個別の Loader を利用することが最適とは思えませんでした。

たしかに GraphQL Object Type でのネストや分岐を減らすことはできそうですが、 Loader の実装が個人に委ねられるため、単に実装のコストが高まるだけでなく、 Loader にうっかりビジネスロジックを含めてしまったりすることも考えられ、保守性の低下のリスクがあったからです。

これが先述した、あらゆる field で汎用的かつ共通で使える Loader と field ごとの具体個別な Loader のトレードオフの関係です。

解決策

このトレードオフを解決するために、ギフティでは AssociationLoader.for に、symbol だけでなく array や hash で関連を渡せるよう、 AssociationLoader の実装を変更しました。つまり、AssociationLoader をより汎用的かつ柔軟に利用できるようにした、ということです。 Rails を利用している方であれば、「ActiveRecord::Associations::Preloader#preload と同じような使い方ができる」と言えばわかりやすいかもしれません。

この Loader を利用すると、先述のネストや分岐を含んだコードは以下のように書き換えることができます。

class UserType < Types::BaseObject
  field :gender, GenderType, null: true

  def gender
    AssociationLoader.for(User, {profile: :gender})
      .then { object.gender }
  end
end

大まかな実装は以下のようになっています。

  • AssociationLoader

    • GraphQL::Batch::Loader を継承し、load や perform を実装
    • 以下に記載する {Hash | Array | Symbol} AssociationResolver を呼び出して関連を解決した上で、perform で ActiveRecord::Associations::Preloader#preload を呼ぶ
  • AssociationLoader::SymbolAssociationResolver

    • symbol で渡された関連を読み取り、関連が正しいかのバリデーションをしたり、load 済みかなどを判定したりする
    • ex. AssociationLoader.for(User, :profile)
  • AssociationLoader::HashAssociationResolver

    • hash で渡された関連を読み取り、関連が正しいかのバリデーションをしたり、load 済みかなどを判定したりする
    • ex. AssociationLoader.for(User, {profile: :gender})
  • AssociationLoader::ArrayAssociationResolver

    • array で渡された関連の要素を HashAssociationResolver または SymbolAssociationResolver に渡す Container layer
    • ex. AssociationLoader.for(User, [{profile: :gender}, {articles: :comments}])

このようにして、汎用的な Loader を利用しながら、GraphQL Object Type での ネストや分岐を防ぐことができるようになりました!

最後に

以上、graphql-batch の利用における課題感と、その解決策についてお話しさせていただきました。

GraphQL はベストプラクティスがあまりないため、よりよい方法を皆で模索しながら開発することが大切だと思っています。 今回の記事も、読んでくださった GraphQL ユーザーの方々のご参考になれば幸いです。

また、私たちと一緒にギフティで GraphQL を使った開発がしたい!と思ってくださった方がいましたら、ぜひ一度カジュアル面談にご参加ください!

最後まで読んでいただきありがとうございました!