wawoon blog

Relay Cursor Connectionsの仕様と実装方法について

Relay Cursor Connectionsとはなにか

https://facebook.github.io/relay/graphql/connections.htm

Relay Cursor Connectionsは、Facebookが提供しているReact向けのGraphQLクライアントライブラリであるRelayでサポートしているページネーションのためのルールです。

具体的な例

仮にpostsという記事を取得するためのGraphQL APIを考えてみます。

サンプルのGraphQL定義

interface Node {
  id: ID!
}

type Query {
  posts(first: Int, after: String, last: Int, before: String): PostConnection!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

type Post implements Node {
  id: ID!
  title: String!
  body: String!
}

type PostEdge {
  node: Post!
  cursor: String!
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
}

どのようなクエリを実行できるのか

上記の定義がある場合、以下のようなクエリが発行することができます。

query {
  posts(first: 10) {
    edges {
      node {
        id
        title
        body
      }
    }
    pageInfo {
      hasNextPage
      hasPreviousPage
      startCursor
      endCursor
    }
  }
}

ここでpostsにfirst: 10という値を指定していますが、これは 先頭から10件 取得することを意味します。

仮にここで返ってきた値が10件だったとします。 このときに返ってきた値が

{
 "posts": {
   "edges": [
     {
       "node": { "id": "post:1", "title": "title", "body": "body" },
       "cursor": "post:1"
     },
     {
       "node": { "id": "post:2", "title": "title", "body": "body" },
       "cursor": "post:2"
     },
     (中略)
     {
       "node": { "id": "post:9", "title": "title", "body": "body" },
       "cursor": "post:1"
     },
     {
       "node": { "id": "post:10", "title": "title", "body": "body" },
       "cursor": "post:2"
     }
   ],
   "pageInfo": {
     "hasNextPage": true,
     "hasPreviousPage": false,
     "startCursor": "post:1",
     "endCursor": "post:10"
   }
 }
}

としましょう。この場合、次の10件を取得するとき、最後に取得したcursorの値を利用して、更に、 "post:10" のレコードより後の10件を取得すことができます。

query {
  posts(first: 10, after: "post:10") {
    edges {
      node {
        id
        title
        body
      }
    }
    pageInfo {
      hasNextPage
      hasPreviousPage
      startCursor
      endCursor
    }
  }
}

これまで見てきたのが前方へのページネーションであったわけで、大抵のアプリケーションはこれで十分です。

しかしながら、リアルタイムのアクティビティフィードで新着のものがより上位に表示されるタイプのタイムライン(たとえばTwitter)であれば、ある取得済みの時間区間の投稿群から、更に古いデータ、または、更に新しいデータを取得する必要があります。つまり、前方へのページネーション、または後方へのページネーションの双方が必要になります。

このような場合、これまで利用したfirst, afterを使った前方へのpaginationのほかに、last, beforeというargumentを利用することで、後方へのpaginationを実行できます。

Relay Cursor Connectionsへ対応することのメリット

offsetページネーションよりも整合的である

cursor以外の方法でのポピュラーなページネーションの方式としてoffsetベースのものがあります。ようするに、__何件スキップ__して、そこから何件取得するのかを指定するという方式です。

cursorベースのページネーションをoffsetベースのページネーションの本質的な違いは、

  • cursorベースの場合は実レコードのIDからの位置を指定している
  • offsetベースはクエリ時点での先頭からの位置を指定している

という点です。

offsetベースでは、前回と今回のクエリ間でレコードの追加・削除などが行われた場合に重複コンテンツが取得されてしまったり、表示すべきレコードを表示できなくなります。

事前にクエリ実行時のコスト計算をすることができる

GraphQL APIを外部公開する場合、受付するクエリに制限を掛ける必要があります。GraphQLは任意のクエリで実行することができるため、サーバーのリソースを一部の悪意のあるクエリで枯渇させないため、事前にクエリの実行コスト(complexity)を計算し、その値に基づいて受け入れの可否を判断する必要があります。

この際に複数レコードを返すフィールドにpaginationなしで全件返すような実装をしていると、上記のクエリ計算でコストを計算することができません。そのため、コネクションを利用してクエリ内に何件がほしいのか明示させることで、complexityが計算できることを保証できるようにするという戦略が取れます。

なお、Arrayで全件返す、または、Connectionを採用する場合のどちらを採用するのか、という点については議論がありそうです。

DBアクセスを伴うような場合はconnectionを、伴わない場合についてはArrayを採用するべきであるという立場(@vvakame さんに伺いました)、固定の件数しかないことがわかっている場合はDBアクセスがある場合でもArrayを利用してOKという意見もあります(私の意見)

実装しておくと使えるようになる機能がある

cursorベースはoffsetベースのページネーションよりも優れているということのほか、既製のフロントエンドライブラリにおいても実装すべきメリットがあります。

Relay

端的にまとめると、mutationの実行時にRelayが自動的にクライアントキャッシュを更新をしてくれます。 わかりづらいですがこれは非常に便利な機能です。

Apollo

現状、Apolloの場合はmutation実行後のconnectionのクライアントキャッシュ更新についてはサポートしていないみたいです

どのように実装するのか

仕様はここにあるのですが、たぶんこの仕様を愚直にやると、かなり大変です https://facebook.github.io/relay/graphql/connections.htm#sec-Pagination-algorithm

仕様書にあるEdgesの実装アイデア

簡単に書くと、アルゴリズムは

  • 基本的に、レコードをすべて取得
  • もしもafterや、beforeがついていたら、そのcursorの値をに相当するレコードの前や、後を削除
  • edgesから指定されているfirstや、lastの個数分取得して返す

というものなんですが、実際のDBでこの仕様を反映しようとするといくつか考えることがあります。

  • DBから全件取得することになっている(仕様だと)
    • ややパフォーマンスが不安
    • レコード数が増えたとき大変そう(1000万行以上あったらどうする?)
  • DBから一部のみ取得するにしても、オーダーされる値がuniqueでない場合は困る
    • cursorの値がそのDBからの取得結果に含まれることが保証されない
      • たとえば、同じ値が100件あり、部分取得しているレコードが50件だった
      • cursorに対応するのが50件目だった

DBから全件取得するか、しないか問題はかなり困る問題です。 このあたりについてはメリット・デメリットがあるので比較検討して実装方式を選ぶということになると思います。

全件取得する場合

  • メリット
    • 実装が楽
    • orderされる値がuniqueじゃない場合でも正確にpaginationできる
    • バッチ化しやすい
  • デメリット
    • 行数が多い場合のパフォーマンス

部分取得する場合

  • メリット
    • DBからの取得件数が一定
  • デメリット
    • DB取得をバッチ化しづらい
    • orderされる値がuniqueではない場合に結果が不正確になりうる

現在の自分のプロジェクトでは、DBからの取得件数を絞った場合で実装をしています。 この場合オーダーを掛けるカラムがuniqueでないと問題になるため、作成時間順でオーダーを掛けるような場合でも、created_atではなく、idでオーダーをかけるように工夫をしています。

(補足)取得件数を制限した場合の実装方法

オーダーされるキーがuniqueであると保証している前提では以下のようにconnectionを実装することができます。 たとえば、postを slug varchar(255)のアルファベット降順で取得する場合. first:10, after: "post:10" とします

1. cursorを取得

cursorは基本的にnodeのidであることを想定しています。 もしもafter, beforeが指定されていたらDBアクセスする

select * from posts where id = 10

2. allEdgesを取得

ここで__first, またはlastの値を+1した値で取得__します。 多く取得することで、次のページが存在するかどうかを判定することができます。

select * from posts where posts.slug > 取得したslug limit 11

3. allEdgesからEdgesを取得

allEdgesからfirst, または last件数分取得する。

4. EdgesからpageInfoを組み立て

firstCursor, lastCursorをedgesの値を取得 forward paginationの場合は、hasNextPageを allEdgesの長さから判定。 hasPreviousPageはfalse。 backward paginationの場合は、hasPreviousPageを allEdgesの長さから判定。 hasNextPageはfalse。

注意すべきポイント

first, lastがどちらも指定されていない場合

もしもfirst, lastが指定されていない場合には、全件取得をさせないためにerrorを投げるか、first: 10のようにフォールバックする必要があります。

※現在の自分のプロジェクトではfirst: 10にフォールバックしているのですが、これが原因で発生するバグが多数あるので、実際にはエラーを返すようにするべきだと現在は考えています。 特に、GraphQLの開発プロジェクトへの経験が浅いメンバーがいると、firstの指定を忘れるケースが増え、開発環境では十分なデータ量がないので気づかないが、リリース後にデータ量が増えて本来表示させるべきデータがないという事態になります。

PageInfoのフィールドについて

https://facebook.github.io/relay/graphql/connections.htm#sec-undefined.PageInfo.Fields

hasNextPage, hasPreviousPageについては両方共、not nullにする必要があります。 またこれらを計算する必要があるのは、

hasNextPage: firstがついているとき hasPreviousPage: lastがついているとき

のみです。 それ以外のときは常にfalseを返していても仕様違反ではありません。

宣伝

GraphQLについて質問できるSlackを作ったので、詳細議論したいことがあればこちらにご参加ください! https://graphql-users-jp.slack.com/ 招待リンク