ESM アジャイル事業部 開発者ブログ

永和システムマネジメント アジャイル事業部の開発者ブログです。

graphql-batch でバックエンドへのクエリを減らす

こんにちは、hibariya です。最近 ミートアップ が開催されるなど、GraphQL が静かに注目を集めていますね。GraphQL は Web API で使えるクエリ言語です。GraphQL 自体は特定のデータベースに依存しないため、RDBMS を使ったアプリケーションで採用することも可能です。PostgreSQL を使う Idobata でも GraphQL の Public API を公開しました。GraphQL 自体がどういうものかについては、graphql.org や以下の資料が参考になるのではないかと思います。

GraphQL でサーバ側を実装するときに起こりがちな問題として、クライアントから投げられるクエリによっては RDBMS への問合せが大量に発生してしまうというものがあります。今回は RubyRails でこのような問題に対処する方法について書きます。

チャットアプリの例を使いたいと思います。次の例は、ログインしているユーザ (viewer) が閲覧できる発言 (message) の一覧と各発言が投稿されたチャットルーム (room) の名前を取得する GraphQL のクエリです。それぞれ、一対一に対応する Active Record のモデルがあるとします。

query {
  viewer {
    # a message belongs to a room
    messages(first: 10) {
      body
      room { name } # message.room による問合せが最大10回
    } } }

特に工夫をしなければ、room { name } を取得する SQL は message の数だけ DB に投げられてしまいます。Ruby で表現すると次のようなイメージです。

Message.readable_by(current_user).limit(10).map {|message|
  # message.room が10回
  {body: message.body, room: {name: message.room.name}}
}

次の例は、あるチャットルーム (room) のメンバー (member) 一覧を取得する例です。

query {
  viewer {
    # a chat room has many members
    rooms(first: 10) {
      name
      members { name } # room.members による問合せが10回
    } } }

この場合も members { name } を取得するSQLが room の数だけ発行されてしまいます。

Room.readable_by(current_user).limit(10).map {|room|
  # room.members が10回
  {name: room.name, members: room.members.map {|member| {name: member.name} }
}

このような問題の解決方法のひとつとして、graphql.org の Best Practices では複数の問合せをひとつにまとめる Batching を挙げています。これの Ruby での実装としては graphql-batch という gem があります。graphql-batch は特定の DB やライブラリに依存しておらず、問い合わせをする部分も自分で実装できるため、比較的導入しやすい gem です。さっそく使ってみましょう。

graphql-batch を使ってみる

graphql-batch を使うと「ひとつひとつの問合せをいったん Promise として保留にしておき、あとでまとめて問合せ」られるようになります。この「まとめて問合せ」を行なう処理は Loader というかたちでアプリケーション側に実装します。

class RecordLoader < GraphQL::Batch::Loader
  def initialize(model_class)
    @model_class = model_class
  end

  def perform(ids)
    @model_class.where(id: ids).each do |record|
      fulfill record.id, record
    end

    ids.each do |id|
      fulfill id, nil unless fulfilled?(id)
    end
  end
end

RecordLoader#perform(ids) が実際に問合せを行なう部分です。これを使って、message が投稿された room (message.room) を取得するための resolver を次のように書けます。

MessageType = GraphQL::ObjectType.define do
  field :body, !String
  field :room, !RoomType do
    resolve -> (message, args, ctx) {
      # message.room の代わりに:
      RecordLoader.for(Room).load(message.room_id) # => a Promise
    }
  end
end

RecordLoader.for(Room).load(…) は実際の関連 (room) ではなく Promise を返します。ここではいったん Promise を返しておき、必要になった時点でまとめて問合せて fulfill するというわけです。

RecordLoader#for は引数ごとに RecordLoader インスタンスをひとつだけ作って返します。例えば RecordLoader.for(Room)RecordLoader.for(Message) では違う Loader オブジェクトが返りますが、同じ引数で2回呼んだときは前回と同じものが返ってきます。RecordLoader#new の引数は、問合せをどういった単位でまとめたいかによって決まると言えるでしょう。

RecordLoader#load には、ロードする対象を一意に特定するためのキーを渡します。この例では Active Record のプライマリキー (id) を渡しています。ここで渡した id は各 Promise を解決するタイミングで RecordLoader#perform に ids としてまとめて渡されます。このようにして、元々は複数の message.room だったものを Room.where(id: ids) というひとつの問合せにできます。

Loader の応用例

Loader の実装としては、上記の RecordLoader の他に ActiveRecord::Associations::Preloader と組み合わせるというやり方も考えられます。これによって Active Record の eager loading の仕組みを利用できます。

class HasManyAssociationLoader < GraphQL::Batch::Loader
  def initialize(_model_class, association)
    @association = association
  end

  def perform(owners)
    ActiveRecord::Associations::Preloader.new.preload owners, @association

    owners.each do |owner|
      fulfill owner, owner.public_send(@association).to_a
    end
  end
end

この Loader を has_many な関連である room.members の読み込みに使うことで、複数の room.members をひとつの Preload に置き換えられます。

RoomType = GraphQL::ObjectType.define do
  field :name, !String
  field :members, [!UserType] do
    resolve -> (room, args, ctx) {
      # room.members の代わりに:
      HasManyAssociationLoader.for(Room, :members).load(room) # => a Promise
    }
  end
end

注意点として、この方法は関連を一気に読み込んでしまうため、数の多い has many な関連の一部だけがほしいような場合には不要なレコードを大量に読み込んでしまう可能性があります。

また、冒頭でも述べましたが graphql-batch 自体は特定の DB やライブラリに依存していないので Active Record 以外にも応用できます。例えば、Rails のキャッシュを fetch ではなく fetch_multi でまとめて取得する Loader をつくり、バックエンドとのラウンドトリップを抑えるという使い方もできるでしょう。

おわりに

主に graphql-batch を使ってバックエンドへの問合せを減らす方法について、基本的なものではありますが、いくつか例を紹介しました。Ruby で GraphQL を触りはじめた人たちの参考になれば幸いです。

今回のような N+1 Query を解決する際のよくあるやり方としては、Rails アプリであれば Active Record の includeseager_load などを用いた、いわゆる Eager Loading を用いることだと思います。同じことを GraphQL で行なう gem としては graphql-query-resolver があります。この gem は ActiveRecord::Associations::Preloader を使って、クエリに含まれる関連をまとめて問合せてくれるようです。ただ、試しに少し使ってみたところ、数の多い has_many な関連を扱う場合やクエリのカスタマイズをしたい場合にはすこし工夫が必要そうな印象でした。もしも新たな情報が得られたら、またどこかで共有できればと思います。