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=10 | offset + limit | SQL ベースの多くの API |
| ページ番号(0 始まり) | /page?page=2&size=10 | last が true になるまで number + 1 | Spring Data |
| カーソル | /cursor?limit=10&cursor=… | has_more が false になるまで next_cursor | Stripe |
| キーセット | /keyset?after_id=20&limit=10 | next_after_id | 大きなテーブル、フィード |
| Link ヘッダー(1 始まり) | /link?page=3&per_page=10 | Link ヘッダーの rel="next" の URL | GitHub |
| HAL | /hal?page=2&size=10 | _links.next.href | Spring HATEOAS |
パスはすべて /api/paginationサインインして表示 の下にあります。ページサイズは 1〜100 で、既定は 10 です。
offset と limit
いちばん単純な方式です。offset 件を飛ばし、続く limit 件を返します。総数はボディと X-Total-Count にあります。
$ 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 始まりのページを前提に書かれた クライアントは、黙って最初のページを飛ばしてしまいます。
$ 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 が返ります。
$ 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."}/api/pagination/cursor?limit=3キーセット
カーソルに似ていますが、中身が読めます。最後に見た id より大きい id の項目を求めます。前のページで挿入や削除があっても 結果がずれないので、フィードや非常に大きなテーブルで使われます。
$ 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 ヘッダー
ボディは単純な配列で、ナビゲーションは Link ヘッダー(RFC 8288)にあり、next、prev、first、last の絶対 URL が入ります。最後を超えたページは 404 ではなく空の配列を返します。
$ 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 として解析しないクライアントもあります。
$ 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 で終わるので、飛ばしも重複もありません。
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 をたどらせたいとき。そうすれば、サーバーはクライアントを壊さずにページングの方式を変えられます。