一覧へ

GraphQLでのConnectionベースのページネーション

GraphQLではConnectionベースのページネーションが一般的だ思いますが、一度は「なんでこんな仕様なんだ、もっとシンプルにできるだろ」と思うものですよね。 この翻訳記事ではConnectionベースのページネーションについて深く掘り下げ、なぜこうする必要があるのか、どのようなメリットが得られるかを解説します。

(原文)


Relay入門シリーズ(全4記事)
Relay:あなたのために泥臭い仕事をしてくれるGraphQLクライアント
GraphQLでのConnectionベースのページネーション(本記事)
Relayを使って最小限の努力でページネーションをする
Nodeインターフェースの魔法

この連載は、Gabriel NordebornSean Groveが執筆しています。GabrielはスウェーデンのITコンサルタント会社Arizonのフロントエンド開発者でありパートナーでもあり、長い間Relayを利用してきました。SeanはサードパーティのAPIをGraphQLで統一するOneGraph.comの共同創業者です。

Connectionベースのページネーションはどのようにページネーションするかを構造化する方法で、シンプルなページネーションから少し高度なページネーションまで対応しています。このページネーションの方法は最近GraphQLの公式のベストプラクティスに昇格しました。この記事では、なぜこれがGraphQLでは良いことなのか、また、どのようなタイプのツールが使えるようになるのかに焦点を当ててみたいと思います。

Connectionベースのページネーションとは何か、なぜそれが有用なのかについては、多くのリソースがあります。この記事を読む前に、まずはGraphQLの公式サイトのページネーションのセクションを見てみることをお勧めします。

標準化が勝利をもたらす

Connectionベースのページネーションは、ページネーションのために標準化された取り決めを使用するということです。なぜそんなことを気にするのでしょうか? この記事でお見せするように、標準化された取り決めを使うことはツールやフレームワークをページネーションと深く統合できることを意味します。そしてそれは、あなたの開発者としての人生がずっと楽になることを意味します。また、ツールがヘビーな仕事をしてくれるので、より早くリリースできるということも意味します。

それは何で、何に適しているのか?

Connectionベースのページネーションは、既存のリストに任意の量のアイテムを追加し続けるようなUI、つまり無限スクロール型のページネーションに最適です。既存のリストにアイテムを追加し続けるのではなく、ページ全体のアイテムを個別に取得するページネーション1を実装したい場合は、別で実装した方が良いでしょう。

ここで重要なのは、Connectionベースのページネーションは、ページネーションのための取り決めを構造化する方法にすぎないということです。Connectionベースのページネーションを使用する場合、バックエンドでデータを取得する方法に影響があるかもしれませんが、GraphQLの世界でこれを使用する主な目的は、バックエンドを特定のページネーション戦略にコミットさせずに、ページネーションのための標準化された構造を提供することです。

ページネーションのための標準化された構造

それでは、Connectionベースのページネーションの構造の例を見てみましょう。あなたが好きだと思う映画を見つけるための検索機能を構築していると想像してみてください。私たちのバックエンドの検索エンジンは非常にスマートなので、あなたが見たいと思うものを評価する際に、さまざまな要素を考慮することができます。以下の定義は、任意の検索ワードでMovieのリストを取得し、ページネーションすることを表現しています。検索ワードは、アクション映画、または恋愛コメディのようなものである可能性があります。私たちの検索エンジンは、それをすべて理解できます!

気軽に下記の仕様に目を通してみてください。今は理解しようしなくて大丈夫です。記事を読み進める上で、大体の形が頭に入っていれば良いと思います。

type Movie {
  id: ID!
  name: String!
  releasedYear: Int!
  coverUrl: String!
}

# Movie型のedge
type MovieEdge {
  node: Movie # node。このEdgeの中ではMovieです
  cursor: String # このEdgeのカーソル
}

# 現在のページネーションのメタデータ
type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
  hasPreviousPage: Boolean!
  startCursor: String
}

# connection。ページネーションのrootの型です
type MovieConnection {
  pageInfo: PageInfo!
  edges: [MovieEdge]
}

type Query {
  # first、last、after、beforeを使ってページを前後させることができます
  movies(
    query: String!
    first: Int, 
    last: Int, 
    after: String, 
    before: String
  ): MovieConnection
}

本当に簡単なはずのものにしては構造が多いですよね?上記の構造を頭に入れておいて、ページネーションがどのように実装され、またプロジェクトでどのように成長するのか、典型的なシナリオを見てみましょう。

ページネーションの実装方法の一般的なシナリオ

ほとんどのアプリケーションでは、遅かれ早かれ何らかの形でのページネーションが必要となり、様々な方法で実装することができます。ここでは最も一般的でありきたりなページネーションをGraphQLで実装するとどのようになるか、シナリオを描いてみましょう。

Step 1. 可能な限りシンプルなページネーション

先ほどの映画の例をもとに、西部劇激しいアクションなど、任意の検索クエリで映画をページネートできる検索機能を設計します。

ほとんどの人は(当然ですが)基本的なことから始めます。つまり、「非常にシンプルなページネーションをしたいだけで、派手なことをする必要はない」ということです。通常は次のようになります。

type Query {
  searchMovies(
    query: String!
    limit: Int
    offset: Int
  ): [Movie!]
}

私たちは、検索クエリを使用してMovieの単純なリストをフィルタリングし、その検索クエリに最適にマッチする映画を見つけます。これはすべて問題なく、うまく機能しています。

Step 2. さらに検索結果があるかを知るにはどうすれば良いのか?

製品は進化していくので、取得できる映画が増えた場合にはUIに「もっと見る」ボタンを追加したいと思います。しかし、上の例ではリストを返しているだけなので、さらにロードするべき検索結果があるかどうかを知る方法がありません。

これも簡単に解決できますね! 例を拡張してみましょう。個々のMovieを変更したくないので(そこにhasNextPageプロパティを持っていても意味がないでしょう2)、searchMoviesがメタデータを置くことができる小さなラッパーオブジェクト(MovieSearchResult)を返す必要があります。

type MovieSearchResult {
  hasNext: Boolean
  items: [Movie!]
}

type Query {
  searchMovies(
    query: String!
    limit: Int
     offset: Int
  ): MovieSearchResult!
}

素晴らしい。これで必要なものは全て揃いましたね。問題は解決しました。

Step 3. オフセットを使用する場合

製品はさらに進化し、私たちは今、検索結果の中に含まれる特定のMovieからページネーションを継続できる一意のURLを生成する必要があります。

パッと思いつくやり方としては、次のようなURLを構築することかもしれません。 /search?limit=${limit}&offset=${offset}&query=${query} これでいいんじゃないでしょうか? 悲しいことに、そうではありません。

私たちがこの検索結果からある映画を見ているとして、その検索結果のリストのに加えられるような映画をデータベースに追加した場合どうなるのでしょうか? オフセットは目的の映画を指し示すことはありません。

一歩引いて考えてみましょう。人間がバックエンドAPIのメンテナと話しているように、私たちの意図を表現してみます。

私は「Frost 2」までの映画の検索結果をすべて持っています。「Frost 2」の次の10作品をお願いします。

私たちは人間でも分かるように意図を十分説明し、「ああ、あなたの検索クエリにマッチする結果にはいくつかの映画が追加されたり削除されたりしましたが、ここではFrost 2以降の10作品を表示しますね」と言われました。これがページネーションに求める動作です!

これを機能させるには、検索結果の特定のアイテムからページネーションできるようにする必要があります。そのため、当然のことながら結果の中の1つのアイテムを識別する方法が必要になります。ここに”カーソル”(cursor)と呼ばれる、そのための既存の概念があります。我々はまさにその概念を使用し(一流の芸術家は盗む、そういうことです)、そしてそれをサポートするために私たちの検索クエリを微調整します。

type MovieSearchResultWrapper {
  movie: Movie
  cursor: String
}

type MovieSearchResult {
  hasNext: Boolean
  items: [MovieSearchResultWrapper!]
}

type Query {
  searchMovies(
    query: String!
    limit: Int
    cursor: String
  ): MovieSearchResult!
}

これでoffsetではなくcursorで映画を検索できるようなり、結果ごとにcursorでアクセスできるようになります。これにより、ページネーションが安定します (現在のアイテムの前に追加されたアイテムは、以前のようにリストを混乱させることはありません)。これで次のようなURLを生成することができます。

/search?limit=${limit}&cursor=${cursor}&query=${query}

このURLは安定5しており、常に検索結果リストのアイテムから始まる検索結果を指すようになります(たとえその間にいくつかのアイテムが追加されたり削除されたりしていても)。素晴らしいですね!

Step 4. 検索結果の個々のアイテムに関連するメタデータ

新しい機能のリクエストが入ってきました。検索結果がどれだけあなたの好みにマッチしているか、また検索クエリにどれだけ関連性があるか、ということについて知っているため、当然ながらそれをユーザーに表示したいと考えています。

一方で、これは特定の検索クエリに対する特定の検索結果のアイテムに対してのみ有効なデータです。私たちの検索エンジンは、検索結果に対して効率的な方法でその情報を自動的に生成してくれます(結果の順序付けに既に使用している情報なので)。しかし、ルートのMovie型を拡張したくない場合(この拡張は本当に筋が悪いので3)、それぞれの結果の情報を置ける場所は論理的にどこでしょうか? さて、カーソルのニーズのために先ほどMovieSearchResultWrapperを導入したところですが、これは特定の検索に関連するメタデータだけを保持しますね。そこに置いてみましょう。

type MovieSearchResultWrapper {
  movie: Movie
  cursor: String
  relevanceFactor: Float
}

素晴らしい!これで素敵な数字を手に入れたので、派手なゲージメーターに変えることができ、みんながハッピーになることでしょう。

待てよ、Connectionベースのページネーションを自然と実装しただけでは?

ここで自然と作ってきたものを見て、Connectionベースのページネーションで作成した最初の構造と比較すると、この2つは事実上同じではないでしょうか? その通りです! 上記のモデルをConnection構造に当てはめてみましょう。

type Movie {
  id: ID!
  name: String!
  releasedYear: Int!
  coverUrl: String!
}

type MovieEdge {
  node: Movie # "node"はコレクション内のアイテムの一般的な名前です
  cursor: String # 検索結果のアイテムのカーソルはここに自然に収まります
  relevanceFactor: Float # この検索結果にのみ関連する追加のメタデータもここに収まります
}

# ページネーションをするために必要な全てのメタデータ
type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
  hasPreviousPage: Boolean!
  startCursor: String
}

# ページネーションのルート型。繰り返しますが、一般化/標準化された名前です
type MovieConnection {
  pageInfo: PageInfo!
  edges: [MovieEdge]
}

type Query {
  searchMovies(
    query: String!
    first: Int, 
    last: Int, 
    after: String, 
    before: String
  ): MovieConnection
}

そういうことなので、独自の命名法を使う代わりにページネーションをConnectionベースのモデルに簡単に適合させることができます。しかし、そんなことをして何のメリットがあるのでしょうか? もしかしたら、あなたはこの命名に反対しているのかもしれませんし、複雑すぎると思っているのかもしれませんし、当面はシンプルなページネーションだけが必要な段階にあるのかもしれません。なぜ気にするのでしょうか?

テンプレは意思決定をなくし、ときめき4をもらたす他のものに集中することができる

上記のスキーマを使用してテンプレート化できるようになったので、私たちは何か(好きな名詞を入れてください)のリストをページネートしたい時には、穴埋めを行うだけでできるようになりました。

# nounには好きな名詞を入れてください
type ${noun} {
  ${...フィールドを書く}
}

type ${noun}Edge {
  node: ${noun} # "node"はコレクション内のアイテムの一般的な名前です。つまりnounです。
  cursor: String # 検索結果のアイテムのカーソルはここに自然に収まります
  ${...nounのためのページネーションの他のメタデータ}
}

# ページネーションをするために必要な全てのメタデータ
# ここは同じままです!
type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
  hasPreviousPage: Boolean!
  startCursor: String
}

# ページネーションのルート型。繰り返しますが、一般化/標準化された名前です
type ${noun}Connection {
  pageInfo: PageInfo!
  edges: [${noun}Edge]
}

type Query {
  ${noun}s(
    query: String!
    first: Int, 
    last: Int, 
    after: String, 
    before: String
  ): ${noun}Connection
}

このようなテンプレートを持つことで、${noun}のリストのページネーションを簡単に作ることができます。

標準化 == ツールを使うことで人生が楽になる

もっと重要なのは、私たちが今まで築き上げてきたこれらの慣習が、私たちの開発者としての人生をシンプルにするためのツールの構築を簡単にしてくれるということです。ここでは、Connectionベースのページネーションがもたらすいくつかの利点と、ツールがあなたのためにできることをご紹介します。

未来を保証するページネーション Connectionベースのページネーションのために提供される構造は、ページネーションの際に必要になるかもしれないもの(カーソル、追加の結果に関するメタデータ、特定の検索におけるこの特定の結果アイテムに関するメタデータなど)を論理的に配置する場所を提供してくれるため、Connectionベースのページネーションを実装する際にAPIの将来性を保証することができます。最初に説明したような高度な機能を必要としない場合でも、構造がすでにあるため必要になったときに追加するのは簡単です。

一般化されたツール 一般化されたツールを書くことが簡単になります。例えば、任意の${noun}Connectionを受け取り、そのConnectionのノードのリストを返す関数などです。これにより、やや複雑なConnectionの構造を手動で扱う必要がなくなります。

また、バックエンドでツールを構築するのも簡単です。先と同じことが当てはまり、標準化されているため汎用的なツールを構築することができます。例えば、上で見たテンプレートを1つのmakeNounPaginator(...)という関数にすることができるので、バックエンドでページネーションを作るのも簡単です!

フレームワークにすべての作業を任せる しかし、フロントエンド開発者としては、標準化されていれば、フレームワークがその上に構築できるというのが一番の利点かもしれません。Relayはその素晴らしい例です。Relay のページネーションは、Connectionの仕様に従えば信じられないほど人間工学的で簡単です。Relayのページネーションとその優れた点について説明した記事をご紹介しますので、ぜひ読んでみてください。

まとめ

この記事を読むことで、Connectionベースのページネーションとは何か、そしてなぜそれが意味をなすのかについての感覚が得られたことを願っています。

また、上述したように、Relayでのページネーションに関する記事もぜひ読んでみてください。Relayはここで紹介した内容を活用して、ページネーションを行うための優れた開発者エクスペリエンスを実現しています。これはConnectionベースのページネーションのような標準化された構造が可能にする、まさに良い典型例です。

もしあなたがEggheadの会員で、この記事を 11 分間のビデオ形式でご覧になりたい方は、Nik Graf のPaginate Entries using the Connection Specification lessonをご覧ください!


  1. 訳注:ページ番号がついているようなUIのページネーションです。工夫が必要ですが、Connectionベースのページネーションを使っても実装できます 例) https://artsy.github.io/blog/2020/01/21/graphql-relay-windowed-pagination/

  2. 訳注:例えば10個ずつ映画を取得するとして、1個目や2個目のMoviehasNextPageがあっても使いようがありません。10件の結果結果自体に次のページがあるかどうかを示すフィールドが存在するべきです。

  3. 訳注:ここで付与したいのは検索の文脈でのみ使われる情報であり、Movie自体が持つ情報ではありません。仮にMovieを拡張すると、Movieを1つ取得する場合などでこのフィールドにどう結果を入れれば良いか分からなくなります。これが「これは特定の検索クエリに対する特定の検索結果のアイテムに対してのみ有効なデータ」ということです。

  4. 訳注:原文はsparks joy(tm)。こんまりメソッドのときめきのことね。 https://www.youtube.com/watch?v=c8eYPDmpyd4

  5. 訳注:現実世界では、このような安定的なリストを生成するためにはバックエンドで工夫が必要です。バックエンドで使うGraphQLフレームワークの多くは、DBに問い合わせる時のoffsetをカーソルに変換しているため安定性を備えていません。標準的な実装であるgraphql-relay-jsもそうなっており、もちろん「offsetに依存しているためにリストに変更がない場合しか動作しない」と明記されています https://github.com/graphql/graphql-relay-js/blob/8f4ed1ad35805ef233ad9fd1af33abb9c0cad35a/src/connection/arrayconnection.js#L17-L19 安定的なリストを作成するためにはidなどを使ってカーソルを生成する必要があります(ソートを行いながら)。ここでは詳細に記述しませんが、実装はかなり難しくなり、速度も低下する可能性があります。