“游标分页” 配置文件

简介

这是 配置文件 用于 JSON:API 规范的规范。

此配置文件的 URL 为 https://jsonapi.fullstack.org.cn/profiles/ethanresnick/cursor-pagination/

基于游标的分页(也称为键集分页)是一种 常用 分页策略,它避免了“偏移-限制”分页的许多弊端。

例如,使用偏移-限制分页,如果客户端分页时从先前页面删除了一个项目,则所有后续结果将向前移动一位。因此,当客户端请求下一页时,它将跳过一个结果,并且永远看不到该结果。相反,如果在客户端分页时将结果添加到结果列表中,则客户端可能会在不同的页面上多次看到相同的结果。基于游标的分页可以防止这两种情况。

基于游标的分页在大多数实现中也对大型数据集性能更好。

为了支持基于游标的分页,本规范定义了三个查询参数——page[size]page[before]page[after]——以及在响应主体中为客户端提供分页链接和游标的方法。

例如,此请求将获取游标 abcde 之后的下一个 100 个人

GET /people?page[size]=100&page[after]=abcde

page[after] 替换为 page[before] 将允许客户端向后分页。

或者,要查找游标 abcdefghij 之间的所有人(不包括两端),客户端可以请求

GET /people?page[after]=abcde&page[before]=fghij

其他组合也是可能的,这些参数将在下面详细介绍。

规范

概念

排序要求

分页仅适用于有序结果列表。除非基础数据发生变化,否则此顺序在请求之间不能更改,以确保结果不会随意在页面之间移动。

如果客户端的分页请求包含一个仅部分对结果进行排序的 ?sort 查询参数,则服务器必须应用额外的排序约束(与客户端请求的约束一致)以生成唯一的排序,如果它希望支持该数据的分页。

例如,假设客户端请求 GET /people?sort=age&page[size]=10。如果多个人年龄相同,则他们的相对排序没有定义(并且可能在请求之间变化),这使得分页变得不可能。因此,为了满足请求,服务器必须将所有带有 ?sort=age 的分页请求视为客户端实际上请求按年龄排序,然后按一些唯一字段或字段组合排序(例如,?sort=age,id)。

类似地,当正在分页的集合没有自然顺序或客户端请求的顺序(如关系中的资源标识符对象集)时,服务器必须分配一个顺序,如果它希望支持分页。

如果客户端请求以服务器无法有效分页的方式对结果进行排序,则服务器可以拒绝分页请求。在这种情况下,服务器必须根据 不支持排序错误 的规则拒绝请求。

游标

“游标”是一个字符串,由服务器使用它喜欢的任何方法创建,它将结果列表划分为落在游标之前的结果、落在游标之后的結果,以及可选地落在游标“上”的一个结果。

例如,想象一下正在分页的结果列表如下

[
  { "type": "examples", "id": "1" },
  { "type": "examples", "id": "5" },
  { "type": "examples", "id": "7" },
  { "type": "examples", "id": "8" },
  { "type": "examples", "id": "9" }
]

对于此列表,服务器可能会生成游标字符串 abcde 作为其对“id = 5”进行编码的方式。那么,使用该游标,第一个结果将落在游标之前,第二个结果将落在游标上,其他结果将落在游标之后。

结果列表可以在客户端的分页请求之间发生更改。例如,如果结果引用的资源被删除,则具有 "id": "5" 的结果可能会从结果集中删除。在这种情况下,游标 abcde 将不再落在任何一个结果上,但相同的结果仍然会在它之前和之后出现。

在极少数情况下,服务器可能会发现结果列表在客户端的分页请求之间发生变化是不可接受的。在这些情况下,服务器可以将唯一标识客户端或其会话的信息编码到游标中,并使用该标识符从时间上的单个“快照”返回一致的结果。

查询参数

page[size]

page[size] 参数指示客户端希望在响应中看到的結果数量。

如果提供了 page[size],则它必须是一个正整数。1 如果不满足此要求——例如,如果 page[size] 为负——则服务器必须根据 无效查询参数错误 的规则进行响应。

对于每个支持分页的端点,服务器可以定义一个最大结果数,它将在响应对该端点的分页请求时发送。这被称为“最大页面大小”。如果服务器没有为给定端点选择最大页面大小,则它隐含地为无穷大。

如果 page[size] 超过服务器定义的最大页面大小,则服务器必须根据 最大页面大小超出错误 的规则进行响应。

如果省略了 page[size],则服务器必须选择一个“默认页面大小”。此默认大小必须是一个介于 1 和最大页面大小之间的整数(包含两端)。

page[size] 值(如果省略了 page[size],则为默认页面大小)被称为“使用页面大小”。

在任何有效的分页请求上,返回的 分页项目 数量必须等于使用页面大小——前提是在结果列表中至少有这么多项目,并且满足 page[after] 和/或 page[before] 参数(如果有)的约束。

page[after]page[before]

page[after]page[before] 参数都是可选的,如果提供,则都以游标作为其值。如果其值不是有效的游标,则服务器必须根据 无效查询参数错误 的规则进行响应。

page[after] 参数通常由客户端发送以获取下一页,而 page[before] 用于获取上一页。

更正式地说,当提供 page[after] 时,返回的 分页数据 必须以结果列表中紧随游标的项目作为其第一个项目。(一个例外是,如果结果列表中没有落在游标之后的项目,则返回的分页数据必须是一个空数组。)

当提供 page[before] 时,分页数据 中返回的最后一个项目必须是最靠近游标但仍在游标之前的项目,在未分页的结果列表中。(类似于上面,如果结果列表中没有落在游标之前的项目,则返回的分页数据必须是一个空数组。)

例如,想象一下正在分页的结果列表再次是

[
  { "type": "examples", "id": "1" },
  { "type": "examples", "id": "5" },
  { "type": "examples", "id": "7" },
  { "type": "examples", "id": "8" },
  { "type": "examples", "id": "9" }
]

此外,想象一下游标 xxx 落在具有 "id": "9" 的条目上,而游标 abcde 仍然落在具有 "id": "5" 的条目上。

那么,例如,如果请求是

GET /example-data?page[after]=abcde&page[size]=2

响应将包含

{
  "links": {
    "prev": "/example-data?page[before]=yyy&page[size]=2",
    "next": "/example-data?page[after]=zzz&page[size]=2"
  },
  "data": [
    // the pagination item metadata is optional below.
    { "type": "examples", "id": "7", "meta": { "page": { "cursor": "yyy" } } },
    { "type": "examples", "id": "8", "meta": { "page": { "cursor": "zzz" } }  }
  ]
}

或者,如果请求是

GET /example-data?page[before]=xxx&page[size]=3

响应将包含

{
  "links": {
    "prev": "/example-data?page[before]=abcde&page[size]=3",
    "next": "/example-data?page[after]=zzz&page[size]=3"
  },
  "data": [
    // again, optional pagination item metadata is allowed for each item here.
    { "type": "examples", "id": "5" },
    { "type": "examples", "id": "7" },
    { "type": "examples", "id": "8" }
  ]
}

请注意,page[before]=xxx 导致响应的 分页数据 中的最后一个项目是具有 "id": "8" 的条目,而该项目上方分页数据中的项目数量由使用页面大小控制。

省略 page[after]page[before]

如果客户端的分页请求既不包含 page[after] 也不包含 page[before] 参数,则返回的 分页数据 必须以结果列表中的第一个项目开头。(如果结果列表为空,则分页数据必须是一个空数组。)

组合 page[after]page[before]

客户端可以在同一个请求上一起使用 page[after]page[before] 参数。这些被称为“范围分页请求”,因为客户端请求从 page[after] 游标之后立即开始并一直持续到 page[before] 游标的所有结果。

服务器不需要支持此类请求。如果服务器选择不支持这些请求,则它必须根据 范围分页不支持错误 的规则进行响应。

在范围分页请求中,服务器必须使用该端点的最大页面大小作为默认页面大小。换句话说,使用页面大小 将是 page[size] 参数的值或最大页面大小。

如果满足 page[after]page[before] 约束的结果数量超过了使用的页面大小,服务器 **必须** 返回与未提供 page[before] 参数时相同的 分页数据。但是,在这种情况下,服务器 **必须** 在 分页元数据 中添加 "rangeTruncated": true,以指示客户端分页数据不包含其请求的所有结果。

例如,假设我们上面的示例数据和游标,假设客户端请求

GET /example-data?page[after]=abcde&page[before]=xxx

然后,假设我们服务器的最大页面大小大于 1,响应将包含

{
  "links": {
    "prev": "/example-data?page[before]=yyy",
    "next": "/example-data?page[after]=zzz"
  },
  "data": [
    { "type": "examples", "id": "7" },
    { "type": "examples", "id": "8" }
  ]
}

但是,如果我们服务器的最大页面大小为 1,或者客户端在其请求中包含了 page[size]=1,则响应将包含

{
  "meta": {
    "page": { "rangeTruncated": true }
  },
  "links": {
    "prev": "/example-data?page[before]=yyy&page[size]=1",
    "next": "/example-data?page[after]=yyy&page[size]=1"
  },
  "data": [
    { "type": "examples", "id": "7" }
  ]
}

文档结构

术语

此配置文件使用以下术语来指代不同的文档元素

  1. 分页数据:JSON:API 响应文档中的一个数组,包含从要分页的完整结果列表中提取的结果。它始终是 data 键的值。当 主数据 被分页时,文档顶层 data 键的值是分页数据。当关系中的资源标识符对象被分页时,关系对象中 data 键的值是分页数据。

  2. 分页链接:与分页数据同级的 links 对象。

  3. 分页元数据:与分页数据(和分页链接)同级的 meta 对象的 page 成员

  4. 分页项:分页数据数组中的条目。

  5. 分页项元数据:分页数据项顶层的 meta 对象的 page 成员

为了演示这些术语,以下示例中标记了各种元素

GET /people?page[size]=1
{
  // "pagination links" (for the top-level `data`)
  "links": { },
  "meta": {
    // "pagination metadata"
    "page": {}
  },
  // "paginated data"
  "data": [
    // a "pagination item"
    {
      "type": "people",
      "id": "1",
      // "pagination item metadata" in `page`.
      "meta": { "page": {} },
      "attributes": {},
      "relationships": {
        "friends": {
          // "pagination links".
          // would be non-empty in practice to indicate that the server
          // has chosen to paginate this relationship, even though the
          // client hasn't explicitly asked, which is allowed.
          "links": {},
          // another instance of "paginated data"
          "data": [
            // a "pagination item", with (empty) "pagination item metadata".
            { "type": "people", "id": "3", "meta": { "page": { } } }
          ]
        }
      }
    }
  ]
}

page 元对象成员

此配置文件在每个 JSON:API 定义的 meta 对象中保留一个 page 成员。(每个 page 成员构成此配置文件定义的元素,因此它们可以被 别名化。)

当存在时,page 成员 **必须** 以对象作为其值;任何其他值都是 无法识别的。这些不同的 page 对象中识别的键/值在本规范中定义。

项目游标

服务器 **可以选择** 将一些或所有分页项发送回客户端,在 分页项的元数据 中包含一个 cursor 成员。如果存在,此成员 **必须** 包含一个游标,该游标(在响应时) “落在” 它返回的项上。客户端可以使用此游标从该项进行分页。

例如,响应可能包含

{
  // top-level links, meta, etc. omitted.
  // more people would likely be in the response as well.
  "data": [{
    "type": "people",
    "id": "3",
    "meta": {
      "page": { "cursor": "someOpaqueString" }
    }
    //...
  }]
}

有了这个响应,客户端可以使用 page[before]=someOpaqueStringpage[after]=someOpaqueString 从人员 3 向任一方向进行分页。

JSON:API 允许四种类型的分页链接:prev, next, first, 和 last

**建议** 服务器在计算这些链接成本较低时包含 firstlast 链接。

但是,服务器 **必须** 为响应中的每个分页数据实例包含一个 prev 和一个 next 链接。

如果请求不包含 page[before] 参数,服务器 **必须** 确定是否存在下一页,如果不存在,则返回 null 作为 next 链接。

如果请求不包含 page[after] 参数,服务器 **必须** 确定是否存在上一页,如果不存在,则返回 null 作为 prev 链接。

在所有其他情况下,服务器 **应该** 在可以廉价地确定当前响应是第一页还是最后一页时,将这些链接设置为 null

但是,如果服务器无法轻松确定是否存在先前的结果(在计算 prev 链接时)或后续结果(在计算 next 链接时),它 **可以** 使用一个 URI 作为这些链接,该 URI 返回一个空数组作为其分页数据。

例如,想象一个针对以下内容的请求

GET /example-data?page[before]=xyz

为了满足此请求,服务器可能会发出一个查询,该查询过滤完整的 example-data 结果列表,以仅查找在游标之前的记录。从这些查询结果中,服务器将不知道在游标之后是否存在其他结果,并且可能没有便宜的方法来查明。

因此,在这种情况下,服务器可以简单地返回一个 next URI,其中 page[after] 参数设置为响应的分页数据中最后一项的 项目游标

如果客户端去获取此链接,它将要么收到一个空数组作为分页数据,在这种情况下它知道已经到达末尾,要么它将获得后续结果。

注意:通常,服务器可能发现,当使用 page[after] 时,确定是否存在上一页的成本更高,而当使用 page[before] 时,确定是否存在下一页的成本更高。幸运的是,当使用 page[after] 时,客户端通常不关心上一页(只关心下一页),反之亦然,当使用 page[before] 时。因此,通过允许服务器返回一个可能最终对应于空页面的链接,服务器通常可以跳过永远不会需要的查询。

集合大小

分页元数据 **可以** 包含一个 total 成员,其中包含一个整数,指示正在分页的结果列表中的项目总数。

例如,对 GET /people?page[size]=2 的响应可能包含

{
  "meta": {
    "page": { "total": 200 }
  },
  // links omitted
  "data": [
    {
      "type": "people",
      // ...
    },
    {
      "type": "people",
      // ...
    }
  ]
}

分页元数据 **也可以** 包含一个 estimatedTotal 成员。如果存在,此成员的值 **必须** 是一个对象。该对象 **可以** 具有一个键,bestGuess。如果存在,bestGuess **必须** 包含一个整数,指示服务器对完整结果列表大小的最佳估计。

当计算确切的总和成本高昂时,服务器可能会选择使用 estimatedTotal 而不是 total

错误情况

不支持排序错误

服务器 **必须** 通过发送 400 Bad Request 来响应此错误。响应文档 **必须** 包含一个错误对象,该对象将 sort 参数识别为 错误的 source,并具有 type 链接

https://jsonapi.fullstack.org.cn/profiles/ethanresnick/cursor-pagination/unsupported-sort

最大页面大小超出错误

服务器 **必须** 通过发送 400 Bad Request 来响应此错误。响应文档 **必须** 包含一个错误对象,该对象

  • page[size] 识别为错误的 source
  • 在错误对象的 meta 对象中,将 page 元素的 maxSize 成员中提供最大页面大小作为整数;以及
  • 包括一个 type 链接

    https://jsonapi.fullstack.org.cn/profiles/ethanresnick/cursor-pagination/max-size-exceeded
    

如果此配置文件的 page 元素尚未被 别名化,则错误对象可能看起来像

{
  "status": "400",
  "meta": {
    "page": { "maxSize": 100 }
  },
  "title": "Page size requested is too large.",
  "detail": "You requested a size of 200, but 100 is the maximum.",
  "source": {
    "parameter": "page[size]"
  },
  "links": {
    "type": ["https://jsonapi.fullstack.org.cn/profiles/ethanresnick/cursor-pagination/max-size-exceeded"]
  }
}

无效参数值错误

服务器 **必须** 通过发送 400 Bad Request 来响应此错误,并在响应文档中包含一个错误对象,该对象在错误对象的 source 成员中识别出有问题的参数。

例如,服务器可能会发送

{
  "errors": [{
    "title": "Invalid Parameter.",
    "detail": "page[size] must be a positive integer; got 0",
    "source": { "parameter": "page[size]" },
    "status": "400"
  }]
}

范围分页不支持错误

服务器 **必须** 通过发送 400 Bad Request 来响应此错误。响应文档 **必须** 包含一个错误对象,该对象具有 type 链接

https://jsonapi.fullstack.org.cn/profiles/ethanresnick/cursor-pagination/range-pagination-not-supported

笔记

  1. 从技术上讲,URI 是一系列字符,因此查询参数的值(包括 page[size])始终是一个字符串。当文本说该值“必须是一个正整数”时,更准确地说,该值 **必须** 是与正则表达式 ^[0-9]+$ 匹配的字符序列,然后 **必须** 将其解释为一个十进制整数。

版本历史记录

查看此配置文件随时间的变化

联系编辑

Ethan Resnick
ethan.resnick@gmail.com
https://ethanresnick.com/
13104398032