API分页最佳做法


288

我希望获得一些帮助,以我正在构建的分页API处理奇怪的边缘情况。

像许多API一样,这一分页会产生很大的结果。如果查询/ foos,将得到100个结果(即foo#1-100),以及指向/ foos?page = 2的链接,该链接应返回foo#101-200。

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

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

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


1
这是什么问题?在我看来,无论哪种方式,用户都将获得100件商品。
NARKOZ

2
我一直在面对同样的问题,正在寻找解决方案。AFAIK,如果每个页面都执行一个新查询,则实际上没有可靠的保证机制可以完成此操作。我能想到的唯一解决方案是保持活动会话,并将结果集保留在服务器端,而不是为每个页面执行新查询,而只是获取下一个缓存的记录集。
2014年

31
看看Twitter如何实现这个dev.twitter.com/rest/public/timelines
java_geek 2014年

1
@java_geek since_id参数如何更新?在Twitter网页上,似乎他们都以since_id的相同值发出两个请求。我不知道它何时会更新,以便如果添加了新的tweet,它们可以被考虑?
Petar 2015年

1
@Petar since_id参数需要由API的使用者更新。如果您看到的话,该示例涉及客户端处理推文的情况
java_geek

Answers:


175

我不确定要如何处理您的数据,因此这可能行得通,也可能行不通,但是您是否考虑过使用时间戳字段分页?

当查询/ 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中进行分页的方式(向下滚动至底部以我上面给出的格式查看分页链接)。

一个问题可能是是否添加了数据项,但是根据您的描述,听起来好像它们将添加到末尾(如果没有,请告诉我,我会看看是否可以对此进行改进)。


29
时间戳不能保证唯一。也就是说,可以使用相同的时间戳创建多个资源。因此,这种方法的缺点是,下一页可能会重复当前页的最后一个(很少?)条目。
卢布

4
@prmatta实际上,根据数据库的实现,可以保证时间戳是唯一的
ramblinjan 2014年

2
@jandjorgensen从您的链接:“ timestamp数据类型只是一个递增的数字,并且不保留日期或时间。...在SQL Server 2008和更高版本中,timestamp类型已重命名为rowversion,大概是为了更好地反映其类型。目的和价值。” 因此,这里没有证据表明时间戳记(实际上包含时间值的时间戳记)是唯一的。
Nolan Amy

3
@jandjorgensen我喜欢您的建议,但是您是否不需要资源链接中的某种信息,所以我们知道我们上一步还是下一步?如:“ previous”:“ api.example.com/foo?before=TIMESTAMP ”“ next”:“ api.example.com/foo?since=TIMESTAMP2 ”我们也将使用序列ID代替时间戳。您看到任何问题吗?
longliveenduro 2014年

5
另一个类似的选择是使用RFC 5988(第5节)中指定的链接头字段:tools.ietf.org/html/rfc5988#page-6
Anthony F

28

你有几个问题。

首先,您有引用的示例。

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

如果您不对原始数据集进行快照,那么这就是事实。

您可以让用户创建一个显式快照:

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

并寄出整个套件。


1
(默认的foos排序按创建日期排序,因此行插入不是问题。)
2arrs2ells 2012年

实际上,仅捕获文档密钥是不够的。这样,当用户请求完整对象时,您必须通过ID查询它们,但是可能它们不再存在。
Scadge '18年

27

如果您有分页,还可以通过一些键对数据进行排序。为什么不让API客户端在URL中包含先前返回的集合的最后一个元素的键,并WHERE在SQL查询中添加一个子句(如果不使用SQL,则添加一个子句),以便它仅返回那些密钥是否大于此值?


4
这不是一个坏建议,但是仅仅因为您按值排序并不意味着它是一个“键”,即唯一。
克里斯·孔雀

究竟。例如,在我的情况下,排序字段恰好是一个日期,而且远非唯一。
星期六星期六

19

可能有两种方法,具体取决于您的服务器端逻辑。

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

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

您的服务器应负责返回新记录(通过拉动刷新来加载更多记录或新记录)以及从[“ id1”,“ id2”,“ id3”,“ id4”,“ id5”,“ 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,最后一个记录的ID和上一个请求的纪元时间。这样,即使您有大量缓存的记录,您的请求也总是很小

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

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

您的服务器负责返回在last_request_time之后删除的已删除记录的ID,以及在last_request_time之后在“ id1”和“ id10”之间返回更新的记录。

{
        "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"]
}

拉动刷新:-

在此处输入图片说明

装载更多

在此处输入图片说明


14

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

如果您确实需要容纳这种情况,则需要“记住”您上次中断的地方。jandjorgensen的建议就在现场,但是我将使用保证像主键一样唯一的字段。您可能需要使用多个字段。

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


2
这不是可接受的解决方案。这是相当多的时间和内存消耗。所有删除的数据以及请求的数据将需要保留在内存中,如果同一用户不再请求更多条目,则可能根本不会使用该内存。
Deepak Garg

3
我不同意。仅保留唯一ID根本不会占用太多内存。您不只是为了“会话”就无限期地保留数据。使用内存缓存很容易,只需设置过期时间(即10分钟)即可。
布伦特·贝斯利

内存比网络/ CPU速度便宜。因此,如果创建页面非常昂贵(就网络而言还是CPU密集型),则缓存结果是一种有效的方法@DeepakGarg
U Avalos

9

分页通常是一种“用户”操作,为防止计算机和人脑过载,通常会提供一个子集。但是,与其认为我们没有得到全部清单,不如问这是否重要?

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

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

就我而言,我隐式指定一些API调用以允许获取全部信息(主要是参考表数据)。您也可以保护这些API,以免损害您的系统。


8

选项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

有关此方法的更多信息,请查看博客文章“ 带有Continuation Tokens的Web API分页 ”。这种方法的缺点是难以实现,因为必须考虑许多极端情况。这就是为什么像continuation-token这样的库很方便的原因(如果您使用的是Java / JVM语言)。免责声明:我是该帖子的作者,也是该库的合著者。


4

我认为目前您的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)

1
我同意。而不是按记录号查询(不可靠),而应按ID查询。将您的查询(x,m)更改为意味着“最多返回m个按ID排序的记录,ID> x”,然后您可以简单地将x设置为上一个查询结果的最大id。
约翰·亨克尔

是的,无论是对ID进行排序,还是如果您有一些具体的业务领域都可以对诸如creation_date等进行排序
。– mickeymoon

4

只是为了添加Kamilk的答案:https ://www.stackoverflow.com/a/13905589

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

发现了一篇关于Slack如何演变api的分页的精彩文章,随着那里的数据集的增加,每个阶段都说明了积极和消极的地方:https : //slack.engineering/evolving-api-pagination-at-slack-1c1f644f8e12


3

我经过很长的努力思考,最后得出了下面将要描述的解决方案。这是复杂性上的一大进步,但是如果您做到这一点,您将得到真正的结果,这是将来请求的确定性结果。

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

我已经实现了它,实际上它比我预期的要难。这是我所做的:

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

我糊涂了。这如何解决您提到的用例?(缓存中的随机字段发生变化,您想使缓存无效)
U Avalos

对于您自己进行的任何更改,您只需查看响应即可。服务器将提供一个新的changeId,您将在下一个请求中使用它。对于其他更改(由其他人进行),您可以不时轮询最新的changeId,并且如果它高于您自己的更改,则知道存在显着的更改。或者,您设置了一些通知系统(长时间轮询,服务器推送,WebSocket),这些通知系统会在发生重大更改时向客户端发出警报。
Stijn de Witt

0

RESTFul API中分页的另一种方法是使用此处介绍的Link标头。例如,Github 使用它的方式如下:

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"

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

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.