API 分页示例:offset、页码、游标、keyset、Link 响应头和 HAL
同一份数据的六种分页方式,每种都附有真实响应和遍历所有页的循环。
示例最后一次针对 API 运行于
API 把长列表拆分成页,常见的方式有六种。offset 和 limit 跳过一定数量的条目。页码和每页条数按页计数,Spring Data 就是这样做的。游标是一个不透明的令牌,Stripe 用的就是它;keyset 分页则请求最后一个 id 之后的条目。有些 API 像 GitHub 那样把下一页的 URL 放在 Link 响应头里,HAL 则把它放在超媒体链接中。https://api.testingapis.com/api/pagination登录后查看 用这六种方式提供同样的 100 条数据,方便你并排对比响应,并针对每一种测试客户端。 它免费,不需要密钥。下面逐一介绍每种方式,并附上真实响应。
六种方式一览
| 方式 | 请求 | 下一页从哪里来 | 使用者 |
|---|---|---|---|
| offset / limit | /offset?offset=20&limit=10 | offset + limit | 许多基于 SQL 的 API |
| 页码(从 0 开始) | /page?page=2&size=10 | number + 1,直到 last 为 true | Spring Data |
| 游标 | /cursor?limit=10&cursor=… | next_cursor,直到 has_more 为 false | Stripe |
| keyset | /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 开始,这正是要测试的 bug:按从 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=3keyset
和游标类似,但可读:请求 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}}获取所有页
大多数分页 bug 都出现在客户端遍历整个列表时:提前一页停下、在最后一页无限循环,或者从错误的页码开始计数。这个脚本把全部 100 条数据获取两遍,一遍用游标,一遍用 Link 响应头。两次都以 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 页、数据变化缓慢的场景。两次请求之间插入或删除一行,会让后面所有页面错位,导致某个条目被跳过或出现两次。
- 游标或 keyset:适合信息流、无限滚动和导出,这些场景下有人翻页时数据还在变化。无法直接跳到第 40 页。
- Link 响应头或 HAL:当你希望客户端跟随服务器给出的 URL,而不是自己拼 URL 时使用。这样服务器可以更换分页方案而不破坏客户端。