Opt Technologies Magazine

オプトテクノロジーズ 公式Webマガジン

Clojure で GraphQL の DataLoader を実現する superlifter の紹介(後編)

alt

Clojure で GraphQL の DataLoader を実現するライブラリ、 superlifter の紹介 後半の今回は、前編で確認した N+1 問題を、DataLoaderによっていかに解決するかを紹介します。。

あいさつ

こんにちは! Opt Technologies の @atsfour です。 本記事は Clojure で GraphQL の DataLoader を実現する superlifter の紹介(前編)の続きになります。 初めて読まれる方は、まずは前編の方を読まれることをお勧めします。

本記事のために構築したサーバのサンプルコードはこちらに置いてあります。 詳細を知りたい方はご覧ください。

前回のおさらい

前回、 Person という型に対して friends という Person の配列が返ってくるような、 単純なネットワークのGraphQLスキーマとサーバを構築しました。

schema {
  query: Query
}

type Query {
  persons: [Person!]!
}

type Person {
  id: Int
  name: String
  friends: [Person!]!
}

そこに、2種類のリゾルバを設定し、挙動を確認しました。

素朴なリゾルバ

まず、シンプルに Person のIDから、その firends をシンプルに取得するリゾルバです。

(def resolver-map
  {:Query/persons (fn [_ _ _] (repository/list-persons))
   :Person/friends (fn [_ _ {:keys [id]}] (repository/fetch-friends-by-id id))})

このリゾルバは、 friendsfriends の friends の ... といった、 深い階層まで取得するようなクエリにも応えられる機能を持ちますが、典型的なN+1問題、つまり persons の取得1回と、 そのそれぞれに対してN回の friends の取得でDBへのクエリ回数がN+1回必要になることがわかりました。

ジョインしたリゾルバ

次に、 persons の取得時に friends をあらかじめジョインした状態で取得するリゾルバです。

(def joined-resolver-map
  {:Query/persons (fn [_ _ _] (repository/list-persons-with-friends))})

このリゾルバはDBへのクエリ回数は1回ですが、2階層以上の friends の取得ができない、という問題がありました。 あらかじめジョインする回数を2回にすれば、2階層は取れますが、GraphQLスキーマ定義通り、任意の階層分取得することはできません。

これらを同時に解決する方法として、DataLoaderがあります。

DataLoaderとは

DataLoaderの概要

Graphql.orgのサイト内では、 GraphQL Best Practices の一環として、 Server-side Batching & Caching という項目があります。

その内容を筆者なりに要約すると、次の3つになると思います。

  • GraphQLでは、各フィールド単位でその値を解決する目的に絞った関数を用意する
  • 素朴なGraphQLサービスはデータベースから繰り返しロードするような可能性がある
  • この問題は、大抵の場合は複数のリクエストを収集し1リクエストにまとめるバッチ手法により解決される

この、バッチ手法をサポートするツールが、DataLoaderです。

素朴なリゾルバのログの確認

ここで、最初の素朴なリゾルバで friends を取得した時のログを再確認してみます。

INFO  io.pedestal.http - {:msg "POST /graphql", :line 80}
INFO  io.pedestal.http.cors - {:msg "cors request processing", :origin "http://localhost:8888", :allowed true, :line 84}
INFO  lacinia-superlifter-sample.repository - Repository accessed. name: list-persons args: []
INFO  lacinia-superlifter-sample.repository - Repository accessed. name: fetch-friends-by-id args: [1]
INFO  lacinia-superlifter-sample.repository - Repository accessed. name: fetch-friends-by-id args: [2]
INFO  lacinia-superlifter-sample.repository - Repository accessed. name: fetch-friends-by-id args: [3]
INFO  lacinia-superlifter-sample.repository - Repository accessed. name: fetch-friends-by-id args: [4]
INFO  lacinia-superlifter-sample.repository - Repository accessed. name: fetch-friends-by-id args: [5]

fetch-friends-by-id (DBアクセスに相当する処理)が5回走っていますが、これらは元の person のID違いで内容としては 「あるIDに紐づく friends を取得する」という共通の処理です。

N+1問題へのもう一つの対応策

1回目ののリクエストでジョインするわけにはいかないですが、 2〜6回目のリクエストについては ID1〜5の friends を全部まとめて取るようにすれば、取得処理は1回にまとめられます。

ただし、 リゾルバはあくまで「各フィールド単位でその値を解決する単一の関数」なので、 たとえば「ID=1の friends のリゾルバ」は ID=1の friends だけを返さないといけないことになります。 つまり

  • 各リゾルバから飛んでくる取得リクエストを1リクエストにまとめる(バッチ化)
  • 取得後に各IDごとに振り分ける

ということが実現する必要があります。 これらをサポートする仕組みとしてDataLoaderがあり、そのClojureによる実装がsuperlifterということになります。

superlifterを利用してみる

superlifterの設定値

まずはsuperlifterの設定値を記述します。

src/clj/lacinia_superlifter_sample/graphql.clj

(def superlifter-args
  {:buckets {:default {:triggers {:queue-size {:threshold 5}
                                  :debounced {:interval 100}}}
             :friends {:triggers {:queue-size {:threshold 5}
                                  :debounced {:interval 100}}}}
   :urania-opts {:env {}}})

:buckets はバッチ処理をするためにリクエストを貯めておくバケットです。 friends の取得処理用のバケットと、一応デフォルトバケットを用意します。デフォルトの方は、バケットの指定がない時や見つからなかった時に使用されます。 今回は friends しかない簡単なサーバですが、通常はいろんなリクエストをたくさんのリゾルバで捌くことになるので、 同じデータを取得するリゾルバごとにバケットを分けます。

GraphQLサーバが並列処理をしている場合、データ取得のリクエストは各IDの取得リクエストがバラバラに飛んできます。 つまり、バッチ処理側ではリクエストが ある程度貯まるのを待ち 、その後それらをまとめたリクエスト発火するという挙動になります。 どのタイミングでリクエストを発火させるかの設定が :triggers です。 今回の場合は、「リクエストが5件貯まる」または「最初のリクエストから100msが経過する」と発火する、という設定です。 triggers の設定について、詳細はこちらをご覧ください。

:urania-opts:env には、superlifterが処理する際に必要になる環境情報を書き込みます。 今回はDB接続を行わないので空ですが、DBの接続情報などをここに記載します。

interceptorの設定

次に、サービス内でsuperlifterを利用できるようにするためのサーバ側の設定を記述します。 require[superlifter.lacinia] を追加した上で、以下のように service の設定を変えます。 (元の値については 前回 の記事をご参照ください。)

src/clj/lacinia_superlifter_sample/graphql.clj

(defmethod ig/init-key ::service
           [_ {:keys [schema options]}]
           (let [compiled-schema (schema/compile schema)
                 interceptors (into [(superlifter.lacinia/inject-superlifter superlifter-args)]
                                    (lacinia.pedestal2/default-interceptors compiled-schema (:app-context options)))]
                (lacinia.pedestal2/enable-graphiql
                  {:env (:env options)
                   ::http/routes (routes interceptors options)
                   ::http/allowed-origins (constantly true)
                   ::http/container-options {}})))

interceptors の先頭に、 (superlifter.lacinia/inject-superlifter superlifter-args) を置くだけです。

inject-superlifter 関数の実装は以下のようになっています。

(defn inject-superlifter [superlifter-args]
      (interceptor
        {:name ::inject-superlifter
         :enter (fn [ctx]
                    (assoc-in ctx [:request :superlifter] (s/start! superlifter-args)))
         :leave (fn [ctx]
                    (update-in ctx [:request :superlifter] s/stop!))}))

リクエスト処理開始時に superlifter-args を参照してsuperlifterを起動し、 contextrequest にそのインスタンスを置く、という動きになっています。 もし、superlifterにより複雑なことをさせる場合は、ここの設定を編集した inject 関数を自前で書くこともできます。

superlifter版のリゾルバの作成

実装

まず、実際の実装を見てみましょう。

requireに以下を追加して

[superlifter.api :refer [def-superfetcher]]
[superlifter.core :refer [enqueue!]]
[superlifter.lacinia :refer [with-superlifter]]

こんな感じで記述します。

(def-superfetcher FetchFriends [id]
                  (fn [many env]
                      (let [result (repository/fetch-friends-by-ids (map :id many))]
                           (map #(get result (:id %)) many))))

(def superlifter-resolver-map
  {:Query/persons (fn [_ _ _] (repository/list-persons))
   :Person/friends (fn [context _ {:keys [id]}]
                       (with-superlifter context
                                         (enqueue! (-> context :request :superlifter) :friends (->FetchFriends id))))})
リゾルバ実装の説明

まずはリゾルバの方を確認します。素朴なリゾルバ と比較して見てください。

Person/friends リゾルバの仕事は、IDに一致する friends の値を取得することです。

素朴なリゾルバではリポジトリに直接アクセスしますが、今回設定したリゾルバはsuperlifterへの enqueue! という処理を行います。 interceptorの方で解説した通り、 (-> context :request :superlifter) の部分にsuperlifterのインスタンスが入っており、 enqueue! は、superlifterの :friends バケットに予約を詰め込みます。

superfetcherは内部的に defrecord を使用しており、リゾルバの方で (->FetchFriends 1) などを渡すことにより、 「def-superfetcherで定義されたフェッチャーに対する予約」というインスタンスを id を引数で生成しているようなイメージです。 with-super-lifter は、 enqueue! の予約が戻って来るまで待ち、その結果をresolverの値として返す、というような動きをします。

superfetcher実装の説明

次に def-superfetcher で定義された FetchFriends という処理を見ていきます。

こちらは、予約がある程度溜まり trigger の条件を満たして発火した後の実行する処理です。 manyenv という2引数の関数になっています。

最初の引数の many->FetchFriends で予約の配列、 2つめの env の方は superlifterの設定値 で設定した urania-optsenv です。ここにDBの接続情報など システム全体で共有するような値を入れておくことで活用できます。(今回はデータはハードコーディングしているので使いません。)

この FetchFriends は、まとめて値を取得するだけでなく、 取得後IDごとに振り分ける という処理もする必要があります。 ここ定義した2変数関数の方で many の予約配列の順番に従って配列として値を返すと、superlifterはそれぞれの予約に振り分けてリゾルバに戻す処理をやってくれます。

superfetcherの引数と戻り値の確認

ここまで、説明だけだとわかりにくい部分もあると思いますので、実際に渡ってくる値を、printして確認するとこのような感じになります。

many の値(予約の配列)

({:id 1} {:id 2} {:id 3} {:id 4} {:id 5})

superfetcher内部の関数の戻り値

(({:id 2, :name "L"} {:id 4, :name "弥 海砂"})
 ({:id 1, :name "夜神 月"} {:id 3, :name "夜神 総一郎"} {:id 5, :name "ニア"})
 ({:id 1, :name "夜神 月"} {:id 2, :name "L"})
 ({:id 1, :name "夜神 月"} {:id 2, :name "L"} {:id 3, :name "夜神 総一郎"})
 ({:id 2, :name "L"}))

many1, 2, 3, 4, 5 という順序で予約が入っているので、その順に各IDに該当する friends を返すと superlifterの方で振り分けてくれる、という感じです。

「N+1問題」の解決を確認してみる

superlifter利用版リゾルバも、素朴なリゾルバと同様に1つ1つのレコードごとに値を取得するため、 ジョインしたリゾルバのようにネストしたリクエストに対応できないということはなく、 friendsfriends などのリクエストにもきちんとレスポンスを返せます。 (レスポンス自体の内容は前回と同様のため割愛します)

1階層クエリの場合

まずは1階層分のクエリを投げてみます。

query Persons {
 persons {
  id
  name
  friends {
   id
   name
  }
 }
}

superlifter利用版リゾルバのリポジトリアクセスのログをみてみましょう。

INFO  lacinia-superlifter-sample.repository - Repository accessed. name: list-persons args: []
INFO  superlifter.core - Fetching 5 muses from bucket :friends
INFO  lacinia-superlifter-sample.repository - Repository accessed. name: fetch-friends-by-ids args: [(1 2 3 4 5)]

persons の取得でリポジトリアクセスが1回、superlifterが5件分のリクエストを貯めた後で、 その5件をまとめて取得しているのがわかります。リポジトリのアクセスは計2回で済んでいます。

ネストしたクエリの場合

次に、ネストしたクエリを投げてみます

query Persons {
 persons {
  id
  name
  friends {
   id
   name
   friends {
    id
    name
   }
  }
 }
}
INFO  lacinia-superlifter-sample.repository - Repository accessed. name: list-persons args: []
INFO  superlifter.core - Fetching 5 muses from bucket :friends
INFO  lacinia-superlifter-sample.repository - Repository accessed. name: fetch-friends-by-ids args: [(1 2 3 4 5)]
INFO  superlifter.core - Fetching 5 muses from bucket :friends
INFO  lacinia-superlifter-sample.repository - Repository accessed. name: fetch-friends-by-ids args: [(5 3 1 4 2)]
INFO  superlifter.core - Fetching 5 muses from bucket :friends
INFO  lacinia-superlifter-sample.repository - Repository accessed. name: fetch-friends-by-ids args: [(3 2 1)]
INFO  superlifter.core - Fetching 1 muses from bucket :friends
INFO  lacinia-superlifter-sample.repository - Repository accessed. name: fetch-friends-by-ids args: [(2)]

trigger の設定で、5件貯まるとバッチ処理が走るようになっているので、2階層目のを取得するリクエストが分割されて計5回のリポジトリアクセスが発生しています。 最後から2番目のログは Fetching 5 muses とあるのに、IDの配列は [(3 2 1)] となったいますが、これは重複したIDの予約が入った結果です。

トリガーの調整

trigger の設定をより変更することで、バッチ処理の回数をコントロールできます。 たとえば、 trigger:queue-size100 まで上げると

INFO  lacinia-superlifter-sample.repository - Repository accessed. name: list-persons args: []
INFO  superlifter.core - Fetching 5 muses from bucket :friends
INFO  lacinia-superlifter-sample.repository - Repository accessed. name: fetch-friends-by-ids args: [(1 2 3 4 5)]
INFO  superlifter.core - Fetching 11 muses from bucket :friends
INFO  lacinia-superlifter-sample.repository - Repository accessed. name: fetch-friends-by-ids args: [(2 4 1 3 5)]

このように、2階層目のリクエスト11件を全部貯めてから処理が走り、計3回となります。

さらに、 friendsfriendsfriends まで取る3階層にするとこんな感じ。

INFO  lacinia-superlifter-sample.repository - Repository accessed. name: list-persons args: []
INFO  superlifter.core - Fetching 5 muses from bucket :friends
INFO  lacinia-superlifter-sample.repository - Repository accessed. name: fetch-friends-by-ids args: [(1 2 3 4 5)]
INFO  superlifter.core - Fetching 11 muses from bucket :friends
INFO  lacinia-superlifter-sample.repository - Repository accessed. name: fetch-friends-by-ids args: [(2 4 1 3 5)]
INFO  superlifter.core - Fetching 26 muses from bucket :friends
INFO  lacinia-superlifter-sample.repository - Repository accessed. name: fetch-friends-by-ids args: [(1 3 5 2 4)]

上位階層の結果が戻るまで下位階層のリゾルバは動けないので、1階層に1回の処理は必要になります。 それでも、素朴なリゾルバだと合計で43回のリポジトリアクセスが走っていたことを思えば、かなりの高速化になります。

現状では、すでに一度取得したものも再度取得しようとすることになるので、たとえばIDに対してすでに取得済みの値をキャッシュするなどの工夫を行うと、 さらに高速化を見込むことができます。

まとめ

前編で確認したN+1問題について、解決方法としてのDataLoaderの仕組みと、そのClojure実装であるsuperlifterの利用方法について説明しました。 リゾルバ関数の処理の「バッチ化」「振り分け」という仕事をDataLoaderが実行し、 その結果N+1問題が解消していることを確認しました。

筆者は現在担当している lacinia superlifter のプロジェクトに途中から参入したため、 「どう書いたら動くか?」はなんとなく把握できても、「なぜ、どういう仕組みで動くのか?」の理解には結構苦労しました。

そういった背景もあり、本記事は

  • GraphQLにおけるリゾルバの役割
  • DataLoaderの仕組み
  • superlifterの書き方とコードの意味

についてを、掘り下げて説明したつもりです。 Clojureという比較的ニッチな言語での実装ですが、読んでいただいた方の学習の役に立てば幸いです。

いつもの

Opt Technologies ではエンジニアを募集中です。カジュアル面談も可能ですので、下記リンク先よりお気軽にご応募ください。