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=10offset + limit许多基于 SQL 的 API
页码(从 0 开始)/page?page=2&size=10number + 1,直到 last 为 trueSpring Data
游标/cursor?limit=10&cursor=…next_cursor,直到 has_more 为 falseStripe
keyset/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 开始,这正是要测试的 bug:按从 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
这个请求使用的是面向已登录成员的端点。登录创建免费账户

keyset

和游标类似,但可读:请求 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}}
这个示例使用的是面向已登录成员的端点。登录创建免费账户

获取所有页

大多数分页 bug 都出现在客户端遍历整个列表时:提前一页停下、在最后一页无限循环,或者从错误的页码开始计数。这个脚本把全部 100 条数据获取两遍,一遍用游标,一遍用 Link 响应头。两次都以 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 页、数据变化缓慢的场景。两次请求之间插入或删除一行,会让后面所有页面错位,导致某个条目被跳过或出现两次。
  • 游标或 keyset:适合信息流、无限滚动和导出,这些场景下有人翻页时数据还在变化。无法直接跳到第 40 页。
  • Link 响应头或 HAL:当你希望客户端跟随服务器给出的 URL,而不是自己拼 URL 时使用。这样服务器可以更换分页方案而不破坏客户端。