【问题标题】:API pagination best practicesAPI 分页最佳实践
【发布时间】:2012-12-02 01:50:00
【问题描述】:

我希望有人帮助我处理我正在构建的分页 API 处理一个奇怪的边缘情况。

与许多 API 一样,这个 API 对大型结果进行分页。如果您查询 /foos,您将获得 100 个结果(即 foo #1-100),以及一个指向 /foos?page=2 的链接,该链接应返回 foo #101-200。

不幸的是,如果 foo #10 在 API 使用者进行下一次查询之前从数据集中删除,/foos?page=2 将偏移 100 并返回 foos #102-201。

这对于试图拉取所有 foo 的 API 使用者来说是个问题 - 他们不会收到 foo #101。

处理此问题的最佳做法是什么?我们希望使其尽可能轻量级(即避免处理 API 请求的会话)。来自其他 API 的示例将不胜感激!

【问题讨论】:

  • 刚刚编辑了问题 - 问题是 foo #101 不会出现在结果中,并且 API 使用者试图提取所有 foo 会错过一个。
  • 我一直面临同样的问题并正在寻找解决方案。 AFAIK,如果每个页面都执行一个新查询,那么确实没有可靠的保证机制来实现这一点。我能想到的唯一解决方案是保持活动会话,并将结果集保留在服务器端,而不是为每个页面执行新查询,只需获取下一个缓存的记录集。
  • 看看twitter是如何实现这一点的dev.twitter.com/rest/public/timelines
  • @java_geek since_id 参数如何更新?在 twitter 网页中,他们似乎正在使用相同的 since_id 值发出两个请求。我想知道它什么时候会更新,以便如果添加了新的推文,它们可以被考虑到?
  • @Petar since_id 参数需要由 API 的使用者更新。如果您看到,那里的示例指的是客户端处理推文

标签: rest pagination api-design


【解决方案1】:

我不完全确定您的数据是如何处理的,所以这可能有效,也可能无效,但您是否考虑过使用时间戳字段进行分页?

当您查询 /foos 时,您会得到 100 个结果。然后您的 API 应该返回类似这样的内容(假设为 JSON,但如果它需要 XML,则可以遵循相同的原则):

{
    "data" : [
        {  data item 1 with all relevant fields    },
        {  data item 2   },
        ...
        {  data item 100 }
    ],
    "paging":  {
        "previous":  "http://api.example.com/foo?since=TIMESTAMP1" 
        "next":  "http://api.example.com/foo?since=TIMESTAMP2"
    }

}

请注意,仅使用一个时间戳依赖于结果中的隐式“限制”。您可能想要添加显式限制或同时使用 until 属性。

时间戳可以使用列表中的最后一个数据项动态确定。这似乎或多或少是 Facebook 在其Graph API 中的分页方式(向下滚动到底部以查看我上面给出的格式的分页链接)。

如果您添加了一个数据项,可能会出现一个问题,但根据您的描述,它们似乎会被添加到末尾(如果没有,请告诉我,我会看看是否可以改进)。

【讨论】:

  • 时间戳不能保证是唯一的。也就是说,可以使用相同的时间戳创建多个资源。所以这种方法的缺点是下一页可能会重复当前页面的最后(几个?)条目。
  • @prmatta 其实取决于数据库实现a timestamp is guaranteed to be unique
  • @jandjorgensen 从您的链接:“时间戳数据类型只是一个递增的数字,不保留日期或时间。...在 SQL Server 2008 及更高版本中,timestamp 类型已重命名为rowversion,大概是为了更好地体现其目的和价值。”所以这里没有证据表明时间戳(实际上包含时间值的时间戳)是唯一的。
  • @jandjorgensen 我喜欢您的建议,但您不需要资源链接中的某种信息,以便我们知道我们是上一个还是下一个?诸如:“previous”:“api.example.com/foo?before=TIMESTAMP”“next”:“api.example.com/foo?since=TIMESTAMP2”我们也将使用我们的序列ID而不是时间戳。你觉得这有什么问题吗?
  • 另一个类似的选项是使用 RFC 5988(第 5 节)中指定的 Link 标头字段:tools.ietf.org/html/rfc5988#page-6
【解决方案2】:

如果您有分页,您还可以按某个键对数据进行排序。为什么不让 API 客户端在 URL 中包含先前返回的集合的最后一个元素的键,并将 WHERE 子句添加到您的 SQL 查询(或等效的东西,如果您不使用 SQL)以便它只返回那些键大于此值的元素?

【讨论】:

  • 这是一个不错的建议,但是仅仅因为您按值排序并不意味着它是“键”,即唯一的。
  • 没错。例如在我的例子中,排序字段恰好是一个日期,它远非唯一。
【解决方案3】:

可能很难找到最佳实践,因为大多数具有 API 的系统不适应这种情况,因为这是一种极端的优势,或者它们通常不会删除记录(Facebook、Twitter)。 Facebook 实际上表示,由于分页后进行的过滤,每个“页面”可能没有请求的结果数量。 https://developers.facebook.com/blog/post/478/

如果您真的需要适应这种极端情况,您需要“记住”您离开的地方。 jandjorgensen 的建议几乎是正确的,但我会使用一个像主键一样保证是唯一的字段。您可能需要使用多个字段。

按照 Facebook 的流程,您可以(并且应该)缓存已请求的页面,如果他们请求已请求的页面,则仅返回已删除的行过滤的页面。

【讨论】:

  • 这不是一个可接受的解决方案。这相当耗费时间和内存。所有已删除的数据以及请求的数据都需要保存在内存中,如果同一用户不再请求任何条目,则可能根本不会使用这些数据。
  • 我不同意。仅保留唯一 ID 根本不会使用太多内存。您不会无限期地保留数据,只是为了“会话”。使用 memcache 很容易,只需设置过期时间(即 10 分钟)。
  • 内存比网络/CPU速度便宜。因此,如果创建页面非常昂贵(就网络而言或 CPU 密集型),那么缓存结果是一种有效的方法@DeepakGarg
【解决方案4】:

你有几个问题。

首先,你有你引用的例子。

如果插入行,您也会遇到类似的问题,但在这种情况下,用户会得到重复数据(可以说比丢失数据更容易管理,但仍然是个问题)。

如果您没有对原始数据集进行快照,那么这只是生活中的事实。

您可以让用户制作显式快照:

POST /createquery
filter.firstName=Bob&filter.lastName=Eubanks

哪些结果:

HTTP/1.1 301 Here's your query
Location: http://www.example.org/query/12345

然后你可以整天翻页,因为它现在是静态的。这可以相当轻量级,因为您可以只捕获实际的文档键而不是整行。

如果用例只是您的用户想要(并且需要)所有数据,那么您可以简单地将其提供给他们:

GET /query/12345?all=true

然后发送整个套件。

【讨论】:

  • (默认排序的 foos 是按创建日期,所以行插入不是问题。)
  • 实际上,仅捕获文档键是不够的。这样,当用户请求它们时,您必须按 ID 查询完整对象,但它们可能不再存在。
【解决方案5】:

根据您的服务器端逻辑,可能有两种方法。

方法 1:当服务器不够智能以处理对象状态时。

您可以将所有缓存记录的唯一 ID 发送到服务器,例如 ["id1","id2","id3","id4","id5","id6","id7","id8"," id9","id10"] 和一个布尔参数,用于了解您是在请求新记录(拉动刷新)还是旧记录(加载更多)。

您的服务器应负责返回新记录(通过拉取刷新加载更多记录或新记录)以及从 ["id1","id2","id3","id4","id5 删除记录的 ID ","id6","id7","id8","id9","id10"].

示例:- 如果您请求负载更多,那么您的请求应如下所示:-

{
        "isRefresh" : false,
        "cached" : ["id1","id2","id3","id4","id5","id6","id7","id8","id9","id10"]
}

现在假设您正在请求旧记录(加载更多)并假设“id2”记录已由某人更新,并且“id5”和“id8”记录已从服务器中删除,那么您的服务器响应应如下所示:-

{
        "records" : [
{"id" :"id2","more_key":"updated_value"},
{"id" :"id11","more_key":"more_value"},
{"id" :"id12","more_key":"more_value"},
{"id" :"id13","more_key":"more_value"},
{"id" :"id14","more_key":"more_value"},
{"id" :"id15","more_key":"more_value"},
{"id" :"id16","more_key":"more_value"},
{"id" :"id17","more_key":"more_value"},
{"id" :"id18","more_key":"more_value"},
{"id" :"id19","more_key":"more_value"},
{"id" :"id20","more_key":"more_value"}],
        "deleted" : ["id5","id8"]
}

但是在这种情况下,如果你有很多本地缓存记录假设 500,那么你的请求字符串会像这样太长:-

{
        "isRefresh" : false,
        "cached" : ["id1","id2","id3","id4","id5","id6","id7","id8","id9","id10",………,"id500"]//Too long request
}

方法 2:当服务器足够智能以根据日期处理对象状态时。

您可以发送第一条记录的 id 和最后一条记录以及上一个请求纪元时间。这样即使你有大量的缓存记录,你的请求也总是很小

示例:- 如果您请求负载更多,那么您的请求应如下所示:-

{
        "isRefresh" : false,
        "firstId" : "id1",
        "lastId" : "id10",
        "last_request_time" : 1421748005
}

您的服务器负责返回在 last_request_time 之后被删除的记录的 id 以及在 "id1" 和 "id10" 之间的 last_request_time 之后返回更新的记录。

{
        "records" : [
{"id" :"id2","more_key":"updated_value"},
{"id" :"id11","more_key":"more_value"},
{"id" :"id12","more_key":"more_value"},
{"id" :"id13","more_key":"more_value"},
{"id" :"id14","more_key":"more_value"},
{"id" :"id15","more_key":"more_value"},
{"id" :"id16","more_key":"more_value"},
{"id" :"id17","more_key":"more_value"},
{"id" :"id18","more_key":"more_value"},
{"id" :"id19","more_key":"more_value"},
{"id" :"id20","more_key":"more_value"}],
        "deleted" : ["id5","id8"]
}

下拉刷新:-

加载更多

【讨论】:

    【解决方案6】:

    我认为目前您的 api 实际上正在以应有的方式响应。页面上的前 100 条记录,按您要维护的对象的总体顺序。您的解释表明您正在使用某种排序 ID 来定义分页对象的顺序。

    现在,如果您希望第 2 页始终从 101 开始并以 200 结束,那么您必须将页面上的条目数设为可变,因为它们可能会被删除。

    您应该执行以下伪代码:

    page_max = 100
    def get_page_results(page_no) :
    
        start = (page_no - 1) * page_max + 1
        end = page_no * page_max
    
        return fetch_results_by_id_between(start, end)
    

    【讨论】:

    • 我同意。而不是按记录号查询(这不可靠),您应该按 ID 查询。将您的查询(x,m)更改为“返回最多 m 个按 ID 排序的记录,其中 ID > x”,然后您可以简单地将 x 设置为上一个查询结果中的最大 id。
    • 是的,可以按 id 排序,或者如果您有一些具体的业务领域要排序,例如 creation_date 等。
    【解决方案7】:

    分页通常是“用户”操作,为了防止计算机和人脑过载,您通常会给出一个子集。然而,与其认为我们没有得到完整的列表,不如问这有关系吗?

    如果需要准确的实时滚动视图,本质上是请求/响应的 REST API 不太适合此目的。为此,您应该考虑使用 WebSockets 或 HTML5 服务器发送事件,让您的前端在处理更改时知道。

    现在,如果需要获取数据的快照,我只需提供一个 API 调用,在一个请求中提供所有数据,而无需分页。请注意,如果您有一个大型数据集,您将需要一些可以在不临时将其加载到内存的情况下对输出进行流式传输的东西。

    就我而言,我隐含地指定了一些 API 调用以允许获取全部信息(主要是参考表数据)。您还可以保护这些 API,使其不会损害您的系统。

    【讨论】:

      【解决方案8】:

      我为此考虑了很久,最终得出了我将在下面描述的解决方案。这是复杂性上的一个相当大的进步,但如果你确实做了这一步,你最终会得到你真正追求的东西,这是未来请求的确定性结果。

      您的项目被删除的例子只是冰山一角。如果您通过color=blue 进行过滤,但有人在请求之间更改了项目颜色怎么办?以分页方式可靠地获取所有项目不可能...除非...我们实施修订历史记录

      我已经实现了它,它实际上没有我预期的那么困难。这是我所做的:

      • 我创建了一个表 changelogs 并带有一个自动递增的 ID 列
      • 我的实体有一个 id 字段,但这不是主键
      • 实体有一个 changeId 字段,它既是变更日志的主键,也是外键。
      • 每当用户创建、更新或删除记录时,系统都会在 changelogs 中插入一条新记录,获取 id 并将其分配给实体的 版本,然后将其插入在数据库中
      • 我的查询选择最大 changeId(按 id 分组)并自行加入以获取所有记录的最新版本。
      • 过滤器应用于最近的记录
      • 状态字段跟踪项目是否被删除
      • 最大changeId返回给客户端,在后续请求中作为查询参数添加
      • 因为只创建新的更改,所以每个changeId 都代表创建更改时基础数据的唯一快照。
      • 这意味着您可以永久缓存具有参数changeId 的请求的结果。结果永远不会过期,因为它们永远不会改变。
      • 这也开启了令人兴奋的功能,例如回滚/还原、同步客户端缓存等。任何受益于更改历史记录的功能。

      【讨论】:

      • 我很困惑。这如何解决您提到的用例? (缓存中的随机字段发生变化,您想使缓存无效)
      • 对于您自己进行的任何更改,您只需查看响应即可。服务器将提供一个新的 changeId,您可以在下一个请求中使用它。对于其他更改(由其他人进行),您可以每隔一段时间轮询一次最新的 changeId,如果它高于您自己的,您就知道有未完成的更改。或者你设置一些通知系统(长轮询、服务器推送、websockets),当有未完成的更改时提醒客户端。
      【解决方案9】:

      选项 A:带时间戳的键集分页

      为了避免您提到的偏移分页的缺点,您可以使用基于键集的分页。通常,实体有一个时间戳,说明它们的创建或修改时间。此时间戳可用于分页:只需将最后一个元素的时间戳作为下一个请求的查询参数传递即可。反过来,服务器使用时间戳作为过滤条件(例如WHERE modificationDate >= receivedTimestampParameter

      {
          "elements": [
              {"data": "data", "modificationDate": 1512757070}
              {"data": "data", "modificationDate": 1512757071}
              {"data": "data", "modificationDate": 1512757072}
          ],
          "pagination": {
              "lastModificationDate": 1512757072,
              "nextPage": "https://domain.de/api/elements?modifiedSince=1512757072"
          }
      }
      

      这样,您不会错过任何元素。这种方法对于许多用例来说应该足够好了。但是,请记住以下几点:

      • 当单个页面的所有元素都具有相同的时间戳时,您可能会遇到无限循环。
      • 当具有相同时间戳的元素重叠两个页面时,您可能会多次向客户端交付许多元素。

      您可以通过增加页面大小和使用毫秒精度的时间戳来减少这些缺点。

      选项 B:使用延续令牌的扩展键集分页

      要处理上述常规键集分页的缺点,您可以向时间戳添加偏移量并使用所谓的“Continuation Token”或“Cursor”。偏移量是元素相对于具有相同时间戳的第一个元素的位置。通常,令牌的格式类似于Timestamp_Offset。它在响应中传递给客户端,并且可以提交回服务器以检索下一页。

      {
          "elements": [
              {"data": "data", "modificationDate": 1512757070}
              {"data": "data", "modificationDate": 1512757072}
              {"data": "data", "modificationDate": 1512757072}
          ],
          "pagination": {
              "continuationToken": "1512757072_2",
              "nextPage": "https://domain.de/api/elements?continuationToken=1512757072_2"
          }
      }
      

      标记“1512757072_2”指向页面的最后一个元素,并声明“客户端已经获得了时间戳为 1512757072 的第二个元素”。这样,服务器就知道从哪里继续。

      请注意,您必须处理元素在两个请求之间发生更改的情况。这通常通过向令牌添加校验和来完成。此校验和是根据具有此时间戳的所有元素的 ID 计算的。所以我们最终得到了这样的令牌格式:Timestamp_Offset_Checksum

      有关此方法的更多信息,请查看博文“Web API Pagination with Continuation Tokens”。这种方法的一个缺点是实现起来很棘手,因为必须考虑许多极端情况。这就是为什么像continuation-token 这样的库可以派上用场(如果您使用的是Java/JVM 语言)。免责声明:我是帖子的作者,也是图书馆的合著者。

      【讨论】:

        【解决方案10】:

        Kamilk 补充一下这个答案:https://www.stackoverflow.com/a/13905589

        很大程度上取决于您处理的数据集有多大。小型数据集在偏移分页上确实有效,但大型实时数据集确实需要光标分页。

        发现了一篇精彩的文章,介绍了随着数据集的增加,Slack 如何演变其 api 的分页,解释了每个阶段的正面和负面:https://slack.engineering/evolving-api-pagination-at-slack-1c1f644f8e12

        【讨论】:

          【解决方案11】:

          RESTFul API 中分页的另一个选项是使用引入的链接标头here。例如 Github use it 如下:

          Link: <https://api.github.com/user/repos?page=3&per_page=100>; rel="next",
            <https://api.github.com/user/repos?page=50&per_page=100>; rel="last"
          

          rel 的可能值为:first、last、next、previous。但是通过使用Link 标头,可能无法指定total_count(元素总数)。

          【讨论】:

            【解决方案12】:

            参考API Pagination Design,我们可以通过cursor

            设计分页api

            他们有这个概念,称为游标——它是指向行的指针。所以你可以对数据库说“在那之后返回我 100 行”。而且数据库更容易做到这一点,因为您很有可能通过带有索引的字段来识别行。突然之间,您无需获取和跳过这些行,您将直接跳过它们。 一个例子:

              GET /api/products
              {"items": [...100 products],
               "cursor": "qWe"}
            

            API 返回一个(不透明的)字符串,然后您可以使用它来检索下一页:

            GET /api/products?cursor=qWe
            {"items": [...100 products],
             "cursor": "qWr"}
            

            在实施方面有很多选择。通常,您有一些订购标准,例如产品 ID。在这种情况下,您将使用一些可逆算法(例如hashids)对您的产品 ID 进行编码。在接收到带有光标的请求时,您对其进行解码并生成类似WHERE id &gt; :cursor LIMIT 100 的查询。

            优势:

            • 通过cursor可以提高db的查询性能
            • 在查询时将新内容插入数据库时​​处理好

            缺点:

            • 不可能使用无状态 API 生成 previous page 链接

            【讨论】:

              猜你喜欢
              • 1970-01-01
              • 1970-01-01
              • 2012-11-24
              • 1970-01-01
              • 2016-03-23
              • 2013-10-23
              • 1970-01-01
              • 1970-01-01
              相关资源
              最近更新 更多