API ページネーションの例:offset、ページ、カーソル、キーセット、Link ヘッダー、HAL

同じデータに対する 6 つのページネーション方式を、それぞれ実際のレスポンスと全ページを取得するループ付きで。

例を API に対して最後に実行した日:

API が長い一覧をページに分ける方法は、よく使われるもので 6 つあります。offset と limit は指定した数だけ項目を飛ばします。 ページ番号とサイズは、Spring Data のようにページを数えます。カーソルは Stripe が使うような不透明なトークンで、キーセットは 最後の id より後の項目を求めます。GitHub のように次の URL を Link ヘッダーに入れる API もあれば、HAL のようにハイパーメディアのリンクに入れるものもあります。https://api.testingapis.com/api/paginationサインインして表示 は同じ 100 件を 6 つの方式すべてで提供しているので、レスポンスを並べて比べ、それぞれに対してクライアントをテストできます。無料で キーも不要です。以下、方式ごとに実際のレスポンスを載せています。

6 つの方式のひと目でわかる比較

方式リクエスト次のページの求め方採用例
offset / limit/offset?offset=20&limit=10offset + limitSQL ベースの多くの API
ページ番号(0 始まり)/page?page=2&size=10last が true になるまで number + 1Spring Data
カーソル/cursor?limit=10&cursor=…has_more が false になるまで next_cursorStripe
キーセット/keyset?after_id=20&limit=10next_after_id大きなテーブル、フィード
Link ヘッダー(1 始まり)/link?page=3&per_page=10Link ヘッダーの rel="next" の URLGitHub
HAL/hal?page=2&size=10_links.next.hrefSpring HATEOAS

パスはすべて /api/paginationサインインして表示 の下にあります。ページサイズは 1〜100 で、既定は 10 です。

offset と limit

いちばん単純な方式です。offset 件を飛ばし、続く limit 件を返します。総数はボディと X-Total-Count にあります。

Terminal
$ curl -i "https://api.testingapis.com/api/pagination/offset?offset=0&limit=2"
HTTP/1.1 200 OK
x-total-count: 100

{"data":[{"id":1,"name":"Smart Headset",...},{"id":2,"name":"Compact Speaker",...}],
 "pagination":{"offset":0,"limit":2,"total":100,"hasMore":true}}
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成

ページ番号とサイズ

Spring Data のエンベロープです。ページは 0 から始まり、ここがテストすべきバグです。1 始まりのページを前提に書かれた クライアントは、黙って最初のページを飛ばしてしまいます。

Terminal
$ curl "https://api.testingapis.com/api/pagination/page?page=0&size=2"

{"content":[{"id":1,...},{"id":2,...}],
 "pageable":{"pageNumber":0,"pageSize":2,"offset":0,"paged":true},
 "totalElements":100,"totalPages":50,"number":0,"size":2,"numberOfElements":2,
 "first":true,"last":false,"empty":false}
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成

カーソル

レスポンスには next_cursor が含まれます。次のページはこれをそのまま送り返して取得し、has_more が false になったら止めます。カーソルの中身はたまたま base64 でエンコードした JSON ですが、クライアントは不透明なものとして扱う 必要があります。書き換えたカーソルには 400 が返ります。

Terminal
$ curl "https://api.testingapis.com/api/pagination/cursor?limit=2"

{"object":"list","url":"/api/pagination/cursor",
 "data":[{"id":1,...},{"id":2,...}],
 "has_more":true,"next_cursor":"eyJsYXN0SWQiOjJ9"}

$ curl "https://api.testingapis.com/api/pagination/cursor?cursor=garbage"
HTTP/1.1 400 Bad Request

{"status":400,"error":"Bad Request","message":"Invalid cursor. Pass the 'next_cursor' value from a previous response unchanged."}
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成
GET/api/pagination/cursor?limit=3
このリクエストはサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成

キーセット

カーソルに似ていますが、中身が読めます。最後に見た id より大きい id の項目を求めます。前のページで挿入や削除があっても 結果がずれないので、フィードや非常に大きなテーブルで使われます。

Terminal
$ curl "https://api.testingapis.com/api/pagination/keyset?after_id=10&limit=2"

{"data":[{"id":11,"name":"Smart Lamp",...},{"id":12,"name":"Compact Headset",...}],
 "has_more":true,"next_after_id":12}
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成

ボディは単純な配列で、ナビゲーションは Link ヘッダー(RFC 8288)にあり、next、prev、first、last の絶対 URL が入ります。最後を超えたページは 404 ではなく空の配列を返します。

Terminal
$ curl -i "https://api.testingapis.com/api/pagination/link?page=2&per_page=2"
HTTP/1.1 200 OK
link: <https://api.testingapis.com/api/pagination/link?page=3&per_page=2>; rel="next",
      <https://api.testingapis.com/api/pagination/link?page=50&per_page=2>; rel="last",
      <https://api.testingapis.com/api/pagination/link?page=1&per_page=2>; rel="first",
      <https://api.testingapis.com/api/pagination/link?page=1&per_page=2>; rel="prev"
x-total-count: 100

[{"id":3,"name":"Deluxe Blender",...},{"id":4,"name":"Eco Bottle",...}]
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成

HAL

項目は _embedded.items の下にあり、それぞれ自分の self リンクを持ちます。ページのリンクは _links の下です。コンテンツタイプは application/hal+json で、指定しないと JSON として解析しないクライアントもあります。

Terminal
$ curl "https://api.testingapis.com/api/pagination/hal?page=0&size=2"

{"_embedded":{"items":[
   {"id":1,...,"_links":{"self":{"href":"/api/pagination/items/1"}}},
   {"id":2,...,"_links":{"self":{"href":"/api/pagination/items/2"}}}]},
 "_links":{"self":{"href":"/api/pagination/hal?page=0&size=2"},
           "first":{"href":"/api/pagination/hal?page=0&size=2"},
           "next":{"href":"/api/pagination/hal?page=1&size=2"},
           "last":{"href":"/api/pagination/hal?page=49&size=2"}},
 "page":{"size":2,"totalElements":100,"totalPages":50,"number":0}}
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成

すべてのページを取得する

ページネーションのバグのほとんどは、クライアントが一覧全体をたどるときに表に出ます。1 ページ手前で止まる、最後のページで 無限ループする、間違った番号からページを数える、といったものです。このスクリプトは 100 件すべてをカーソルと Link ヘッダーで 2 回取得します。どちらも id 100 で終わるので、飛ばしも重複もありません。

paginate.mjs
const BASE = "https://api.testingapis.com/api/pagination";

// Cursor: follow next_cursor until has_more is false.
async function allByCursor(limit = 25) {
  const items = [];
  let cursor;
  do {
    const url = new URL(`${BASE}/cursor`);
    url.searchParams.set("limit", limit);
    if (cursor) url.searchParams.set("cursor", cursor);
    const page = await (await fetch(url)).json();
    items.push(...page.data);
    cursor = page.has_more ? page.next_cursor : undefined;
  } while (cursor);
  return items;
}

// Link header: follow rel="next" until there is none.
async function allByLinkHeader(perPage = 25) {
  const items = [];
  let url = `${BASE}/link?per_page=${perPage}`;
  while (url) {
    const res = await fetch(url);
    items.push(...(await res.json()));
    const next = res.headers.get("link")?.match(/<([^>]+)>;\s*rel="next"/);
    url = next?.[1];
  }
  return items;
}

const byCursor = await allByCursor();
const byLink = await allByLinkHeader();
console.log("cursor:", byCursor.length, "items, last id", byCursor.at(-1).id);
console.log("link:  ", byLink.length, "items, last id", byLink.at(-1).id);
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成
出力
cursor: 100 items, last id 100
link:   100 items, last id 100

どの方式を選ぶか

  • offset かページ番号:ユーザーが N ページ目に飛び、データの変化がゆっくりなとき。2 回のリクエストの間に行が挿入・削除されると後ろのページがすべてずれるので、項目が飛ばされたり 2 回表示されたりします。
  • カーソルかキーセット:フィード、無限スクロール、エクスポートのように、誰かがページをめくっている間にも データが変わるとき。40 ページ目に直接飛ぶことはできません。
  • Link ヘッダーか HAL:クライアントに自分で URL を組み立てさせず、サーバーが渡す URL をたどらせたいとき。そうすれば、サーバーはクライアントを壊さずにページングの方式を変えられます。