🥳

graphql-batchのloaderをコード量1/9で使う方法

2021/12/17に公開

はじめに

僕が勤めている会社(株式会社マイベスト)では、Railsを使いながらGraphQL APIを実装しています。
ライブラリとしてgraphql-rubyを使っています。
(この記事の内容は、graphql-rubyが前提になります)
https://github.com/rmosolgo/graphql-ruby

そして、N+1対策のため、graphql-batchも使っています。
https://github.com/Shopify/graphql-batch

今回はgraphql-batchを使った実装で、コード量を1/9に減らすことができた話をします。

graphql-batchとは

graphql-batchとはShopifyが開発しているgemで、graphql-rubyを使ったGraphQL APIにおいて、N+1を防ぐように関連データをロードできるようにするためのものです。

graphql-batchについての詳細はココでは割愛します。
ググってもらえれば色々と情報は出てくると思うので、そちらを参考にしてみてください 🙏

標準的な実装

graphql-batchを使うと、ObjectTypeの中身は以下のようなコードになります。
(gemのexampleに書いているAssociationLoaderを使う想定です)
https://github.com/Shopify/graphql-batch/blob/master/examples/association_loader.rb

module ObjectTypes
  class ProductType < BaseObject
    field :shops, [ObjectTypes::ShopType], null: false

    def shops
      Loaders::AssociationLoader.for(object.class, :shops).load(object)
    end
  end
end

・・・関連テーブルのfiled全てに↑のようなボイラープレートを書かないといけないのは手間ですよね。

今回修正した結果

そこで、graphql-rubyの仕組みを利用して、fieldのオプションを指定するだけでloaderを使えるようにしました。
修正後のコードがこちら

field :products, [ObjectTypes::ProductType], null: false, load: true

loaderに関係する箇所はload: trueだけなので、かなりスッキリしました。
文字数で言うと(スペース抜きで)81文字が9文字になっているので、9分の1になりました!

実装方法

方針

先ほど見た、標準的な実装例のresolverメソッドをよく見ると、shopsさえ指定できれば、他は共通でいけそうです

def shops
  Loaders::AssociationLoader.for(object.class, :shops).load(object)
end

shops(=field名)を使って共通のresolverを書けるようにしていきましょう。

field拡張

まずは、graphql-rubyのドキュメントに則ってfieldを拡張します
loadという引数を追加してbooleanを渡せるようにします
https://graphql-ruby.org/type_definitions/extensions.html#customizing-fields

class Types::LoadableField < GraphQL::Schema::Field
  def initialize(*args, load: false, **kwargs, &block)
    super(*args, **kwargs, &block)
  end
end

resolverメソッド

拡張したfieldの中で、loadがtrueならload_assocというメソッドを呼び出すようにresolver指定します
load_assocは後ほど定義します

class Types::LoadableField < GraphQL::Schema::Field
  def initialize(*args, load: false, **kwargs, &block)
+   if load
+     super(*args, resolver_method: :load_assoc, extras: [:graphql_name], **kwargs, &block)
+   else
      super(*args, **kwargs, &block)
+   end
  end
end

肝心のresolverメソッドであるload_assocはBaseObjectのなかに実装します

module ObjectTypes
  class BaseObject < GraphQL::Schema::Object
    field_class Loaders::LoadableField

    def load_assoc(graphql_name:)
      Loaders::AssociationLoader.for(object.class, graphql_name.underscore).load(object)
    end
  end
end
  • graphql_nameはfieldの名前がキャメルケースで取得できるものなので、スネークケースに変換しています

これで完成!
意外とあっさり実装できました。
(実はこの実装にたどり着くまで紆余曲折有りました…)
後は、loaderを使って読み込みたいfieldにload: trueを追加していくだけです。

まとめ

  • graphql-batchのloaderは、ボイラープレートの量が少し気になります
  • graphql-rubyのfield拡張とextrasを使って、fieldのオプションだけでloaderを使うことができるようになる方法を紹介しました
  • コード量を1/9に減らすことができたので、スッキリしました 🎉

Discussion