8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

初めての Relay Connection によるページネーション実装

Last updated at Posted at 2023-11-30

前期に初めて Relay Connection を使ったページネーション実装を行いました。
実装要件の制約もあり、その良さを最大限に生かした実装にはならなかったものの、カーソルベースのページネーションの考え方や Connection の仕様など、個人的に新しく学ぶことができました。
実際の実装イメージともに、それらの学びをまとめとして共有したいと思います。

前提

ページネーションの実装方式

そもそもページネーションの実装にはいくつか方法があり、代表してオフセットベースとカーソルベースがよく取り上げられます。
それぞれ得意とすることが異なるため、適材適所で使い分ける必要があります。

オフセットベースページネーション (offset-based pagination)

SQL の OFFSET/LIMIT 句を使ったページネーション方式です。
データの先頭から数えて offset で指定した数だけスキップし、その次の要素から limit で指定した数の分だけデータを取得します。

スクリーンショット 2023-11-24 8.48.51.png

実例として、Relay の GitHub にある pull request の2ページの URL を見てみます。
ベージ番号が並ぶ UI で、ページ番号に応じて page クエリに与えられる数字が変化します。

スクリーンショット 2023-11-21 9.19.29.png

2ページ目では、?page=2 というクエリが指定されているのが分かります。

https://github.com/facebook/relay/pulls?page=2&q=is%3Apr+is%3Aopen

このように、一般的にはページ番号を指定して任意のページに遷移させる必要がある場合はオフセットベースのページネーションが使われることが多いです。
ただし、カーソルベースに比べて実装が比較的簡単である一方、指定のページに到達するまでの全行を数える必要があるため、offset の値が大きくなるほどデータ通信のパフォーマンスに懸念があるとされます。

また、データ更新時にデータの重複や不足が起きる可能性があるため、頻繁にデータ更新が行われる場合は少々注意する必要があります。
1ページ目を表示している最中に先頭にデータが追加されてしまった場合のことを考えてみます。
2ページ目に遷移した際に、先程1ページ目の一番最後に表示されていたデータが2ページ目の先頭に表示されることになります。

スクリーンショット 2023-11-24 8.51.02.png

同様に、1ページ目を表示している最中に先頭のデータが削除された場合は、2ページ目を表示した際にデータがひとつ前にズレてデータが飛んだように見えてしまいます。

カーソルベースページネーション (cursor-based pagination)

カーソルとは、その要素を一意に表す ID で、その要素の場所を表すポインタ、つまり住所のようなイメージです。
GraphQL では、必要な値を base64 エンコードした文字列をカーソルとして使用することを推奨しています。
※ カーソルベースというアイデア自体は一般的な考え方であり GraphQL に閉じるものではありません。

As a reminder that the cursors are opaque and that their format should not be relied upon, we suggest base64 encoding them.

指定したカーソルに対して、その次の要素(前の要素)をいくつ取得するか、という考え方で実装されます。
Relay では、Forward paginationBackward pagination に対応しています。

スクリーンショット 2023-11-24 8.52.39.png

カーソルベースが採用されている例として、再度 Relay の GitHub のページを挙げてみます。
コミット一覧ページの URL を見ると、オフセットベースの例とは異なり、ページ番号ではなく、ページの前後を指定できるページネーション UI となっています。

スクリーンショット 2023-11-21 9.37.45.png

一番最初のページから、Older ボタンを1回押下したときの URL に、?after=ab92df525948d0d445101c97c489ce8c3087a990+34 というようなクエリが付与されるのが分かります。

https://github.com/facebook/relay/commits/main?after=ab92df525948d0d445101c97c489ce8c3087a990+34&branch=main&qualified_name=refs%2Fheads%2Fmain

この ab92df525948d0d445101c97c489ce8c3087a990 は最新のコミット番号で、末尾の +34 でそのコミットから +34 番目のコミット = 1ページ目に表示する最後のコミットの位置を示しているようです。
実際のコードを確認したわけではないものの、おそらくこれをカーソルとして、カーソルベースのページネーションを実現しているのではないかと思います。

上記の例のように、カーソルベースは前後のページ移動や、もっと見る、無限スクロールといったパターンで使用されます。
また、カーソルの位置を検索で特定できるためオフセットベースのページネーションのように全行数える必要がなく、データ量が多くなった場合でもパフォーマンスが落ちにくいとされています。
一意なカーソルによって表示するデータの位置を特定するため、データの追加、削除にも強い方式であると言えるでしょう。

Connection の仕様について

GraphQL においては一般的にカーソルページネーションが最も強力なページネーション方式であるとされ、どのように実装すべきかがベストプラクティスとして紹介されています。

Relay はこれを Connection パターンとして標準化して提供しています。

例えば、ユーザー一覧をページ分割で表示することを考えた場合、GraphQL schema は以下のように定義できます。

extend type Query {
  users(
    first: Int
    after: String
    last: Int
    before: String
  ): UserConnection!
}

interface Node {
  id: ID!
}

type UsersConnection {
  edges: [UserEdge]
  pageInfo: PageInfo!
  totalCount: Int!
}

type UserEdge {
  cursor: String!
  node: User
}

type User implements Node {
  id: ID!
  name: String!
  age: Int!
}

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

一覧の最初の10件を取得したい場合、クエリの呼び出し側は以下のようなイメージです。

import { graphql } from "react-relay"

const query = graphql`
  query usersQuery {
    users(first: 10) {
      ... on UsersConnection {
        edges {
          node {
            name
            age
          }
        }
        pageInfo {
          hasNextPage
          hasPreviousPage
        }
        totalCount
      }
    }
  }
`

after が undefined の場合は1人目から取得することになります。
2ページ目(11人目〜20人目)を取得する場合は、variables に after を追加し、10人目のユーザーの cursor を指定します。

実装

Relay Connection と BE API 実装の分離

私の作成したページネーションを使用する画面では、表示要件として以下の点を満たす必要がありました。

  • 「前の10件」「1」「2」「3」「次の10件」といった、ページ番号指定可能なページネーションであること
  • 画面を更新した際に、現在開いているページを保持すること

FE と BE の間に BFF を立て、 FE - BFF 間の通信には GraphQL、BFF - BE 間の通信には REST API を採用しているのですが、今回 BE API はオフセットベースで実装されることとなりました。
ページ番号方式の UI を再現する場合、API が現在表示しているページの末尾のカーソル(endCursor)だけを返しても、次のページ以外への遷移ができません。
そのため、全ページ用の endCursor を返すとなると、DB から要素を全件 SELECT しなければなりませんでした。
また、API ごとにカーソルについて検討する手間が生じることもあり、手軽に実装できるオフセットベースが採用されたのです。

GET /users?offset=20&limit=10

しかし FE としては Relay を使いながら BE の仕様に合わせて Connection を使わないのはもったいないという気持ちがあり、カーソルベースの実装を採用しました。
前述したとおり、カーソルベースの方がデータの増減に強いという側面があります。
また、後にページャーの形式が「もっと見る」などに変更になった時に対応しやすい他、採用しているライブラリが公式に提供する機能を活用できるメリットが大きいと考えたためです。

では FE 側は実際にどのよう実装しているかというと、GraphQL のリゾルバで cursoroffset & limit を相互に変換できるような util 関数をいくつか作成して導入しています。
graphql-relay から cursorToOffset()offsetToCursor() といった関数が提供されており、Relay Connection はオフセットベースの API にも対応可能です。

このような方法が用意されていることで、FE と BE の実装を切り離して考えられ、それぞれが最適な方法を選択できるのは良い点だと感じました。

なお、GraphQL shema としても、PageInfo 型だけでは足りない情報は、独自に共通で使える schema を定義して補っています。

type UserConnection {
  edges: [UserEdge]
  pageInfo: PageInfo!
+ paginationInfo: [PaginationInfo!]!
  totalCount: Int!
}

# 各ページへのリンク生成等、ページャー実装に必要な項目を返す
+ type PaginationInfo {
+   after: String # 各ページ末尾の User のカーソル
+   currentPage: Boolean! # 現在のページかどうか
+   pageIndex: Int! # ページ番号
+ }

上記を踏まえて、実際のクエリは以下のような形になりました。

import { graphql } from "react-relay"

const query = graphql`
  query usersQuery(
    $after: String
    $first: Int
  ) {
    users(
      after: $after
      first: $first
    ) {
      ... on UsersConnection {
        edges {
          node {
            name
            age
          }
        }
        paginationInfo {
          after
          isCurrent
          pageIndex
        }
        totalCount
      }
    }
  }
`

ページ番号をカーソルに変換する

前述したとおり、ページ番号形式の UI をカーソルベースで実現することになりました。

Relay のチュートリアルでは usePaginationFragment() を用いて refetch する方法が紹介されています。
しかしその方法では求められる UI の実現が難しかったため、URL のクエリにページ番号を保持して、そのページ番号をカーソルに変換する方法をとりました。

/users?page=2

まず、ページ番号と1ページごとの表示アイテム数を渡すと、そのページ番号に対応する offset を返す関数を定義します。

/**
 * @example
 * convertPageToOffset({ page: 2, itemsPerPage: 10 })
 * → 10
 */
export const convertPageToOffset = ({
  page,
  itemsPerPage,
}: {
  page: number;
  itemsPerPage: number;
}) => {
  if (page <= 0 || itemsPerPage <= 0) {
    throw Error("page と itemsPerPage には 0 より大きい数を設定してください");
  }

  return (page - 1) * itemsPerPage;
};

それをもとに、ページの getInitialProps の中で、page クエリを一度 offset に変換し、それを offsetToCursor() を用いてさらに cursor に変換しました。
※ 諸事情により、本プロジェクトでは Next.js で非推奨とされる getInitialProps() を使用しています。

import { z } from "zod";
import { offsetToCursor } from "graphql-relay";

const ITEMS_PER_PAGE = 10;

Page.getInitialProps = async (ctx) => {
  // normalizeQueryString() は URL からクエリを取得し、{ [key: string]: string | undefined } 型にして返す自前関数
  const { page } = normalizeQueryString(ctx.query);

  // page に 0 やマイナスの数値、その他不正な値を指定した場合は 404
  // 2ページしかないのに3ページ目を指定した場合は 0 件表示
  if (page) {
    const pageSchema = z.number().positive();
    const { success: isValidPage } = pageSchema.safeParse(Number(page));
    if (!isValidPage) {
      return {
        errorCode: 404,
      };
    }
  }

  // page を offset に変換する
  const offset = convertPageToOffset({
    page: page ? Number(page) : 1, // 1 ページ目はクエリがつかない
    itemsPerPage: ITEMS_PER_PAGE,
  });

  // offset を cursor に変換する
  // offset が 0 の場合は1ページ目のため、cursor は undefined となる
  const cursor = offset ? offsetToCursor(offset - 1) : undefined

  // 以下省略
};

page クエリに意図的に 0 やマイナス、その他数字に変換できない任意の文字列といった不正な値を入力されてしまった場合は 404 ページが表示されるようにしています。
バリデーションライブラリとして zod を使用していたため、zod の safeParse() を使って不正な page を判別しました。

なお、当初サービスの要件として URL のクエリパラメータやその他エラー要件が考慮されていなかったため、FE 側からこのように実装したい旨を伝え、修正いただきました。

実装当初は、カーソルをそのまま URL に含めようとしていました。

users/cursor=ABC…

しかし、GraphQL を使っているかどうかに関わらず、URL は同じになるようにするべき(GraphQL 固有の値を URL に含めるのは避けるべき)という指摘をいただき、記事に記載の方法に修正しました。

感想

当初はオフセットベースのページネーションしか知らなかったのですが、カーソルベースと、各ページネーション方式の特性を知りました。
そして、Relay Connection を用いた実装方法を学びました。
特に今回は BE がオフセットベース実装だったのに対し、FE がカーソルベースを採用したこともあり、ページネーション一つとっても、色々やり方を考える必要があるのだなという学びがありました。

また、文中にも記載しましたが、採用しているライブラリ(Relay)が公式に提供する機能を活用することで、実装の選択肢が増えると感じています。
今回は使用しませんでしたが、例えば Mutation(データ更新)に対応して Connection のキャッシュを更新できる @appendEdge@prependEdge といったディレクティブが用意されています。

プロジェクトではリアルタイム性が求められるサービスの開発を行っているため、今のところキャッシュを OFF にしていたり、リストに対して更新をかけたい場面がないため使用しているところはありませんが、将来的に要件に変更が生じた場合に、こういった機能を追加活用できることもメリットの1つだと感じました。

8
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?