API分页最佳实践

354

我需要一些帮助来处理我正在构建的分页API中的一个奇怪的边缘情况。

像许多API一样,这个API对大型结果进行分页。 如果您查询/foos,则会获得100个结果(即foo#1-100)和链接/foos?page = 2,它应返回foo#101-200。

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

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

如何最好地处理这种情况?我们希望尽可能轻量级(即避免处理API请求的会话)。 非常感谢其他API的示例!


2
我一直在面对这个问题并寻找解决方案。据我所知,如果每个页面都执行新的查询,那么真正没有可靠的机制来完成这个任务。我能想到的唯一解决方案是保持一个活动会话,并将结果集保存在服务器端,而不是为每个页面执行新的查询,只需获取下一个缓存的记录集即可。 - Jerry Dodge
哦,我刚看到你问题的那一部分,你想避免那种情况。 - Jerry Dodge
36
看看 Twitter 是如何实现这一点的:https://dev.twitter.com/rest/public/timelines - java_geek
1
@java_geek,since_id参数是如何更新的?在Twitter网页中,它似乎使用相同的值进行两个请求。我想知道它何时会更新,以便如果添加了新的推文,它们可以被计算在内? - Petar
1
@Petar,since_id参数需要由API的使用者进行更新。如果您看到,那个例子是指客户端处理推文。 - java_geek
显示剩余2条评论
13个回答

199

我不确定你的数据是如何处理的,所以这个方法可能行也可能不行,但你考虑过使用时间戳字段进行分页吗?

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

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


49
时间戳并不保证唯一性,也就是说,可以使用相同的时间戳创建多个资源。因此,这种方法的缺点是下一页可能会重复当前页的最后几个条目。 - rouble
4
根据数据库的实现方式,时间戳(timestamp)有保证是唯一的。 - ramblinjan
3
根据@jandjorgensen链接提供的信息:“时间戳数据类型只是一个递增的数字,不保留日期或时间。...在SQL server 2008及以后版本中,时间戳类型已更名为rowversion,可能更好地反映了其目的和价值。”因此,这里没有证据表明时间戳(实际包含时间值的时间戳)是唯一的。 - Nolan Amy
3
我喜欢你的提议,但是在资源链接中需要一些信息,这样我们才能知道是向前还是向后。可以像这样写: “previous”:“http://api.example.com/foo?before=TIMESTAMP” “next”:“http://api.example.com/foo?since=TIMESTAMP2”我们还将使用我们的序列 ID 而不是时间戳。您认为这有问题吗? - Chris W.
6
另一种类似的选择是使用RFC 5988(第5节)中指定的Link头字段:http://tools.ietf.org/html/rfc5988#page-6。 - Anthony F
显示剩余7条评论

33

如果您使用了分页功能,您还需要按照某个关键字对数据进行排序。为什么不让 API 客户端在 URL 中包含先前返回的集合中最后一个元素的键,并在 SQL 查询中添加 WHERE 子句(或者类似等价物,如果您没有使用 SQL),以便只返回键大于此值的那些元素呢?


7
这不是一个坏的建议,但仅仅通过某个数值进行排序并不意味着它是一个“键”,即唯一的。 - Chris Peacock
没错。例如在我的情况下,排序字段恰好是一个日期,并且远非唯一。 - Sat Thiru

29

你有几个问题。

首先,你提到的那个例子是一个问题。

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

如果你没有对原始数据集进行快照,则这只是生活的一个事实。

你可以让用户做出明确的快照:

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
1
实际上,仅捕获文档键是不够的。这样,当用户请求它们时,您将不得不按ID查询完整的对象,但可能它们已经不存在了。 - Scadge

21

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

方法一:当服务器不能够处理对象状态时

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

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

示例: 如果您正在请求“加载更多”,则您的请求应如下所示:

{
        "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条,那么你的请求字符串会变得过长,就像这样:

但是在这种情况下,如果您有大量本地缓存记录(比如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
}
您的服务器需要负责返回在最后一次请求时间之后被删除的记录的ID,以及在“id1”和“id10”之间的最后一次请求时间之后更新的记录。

您的服务器需要负责返回在最后一次请求时间之后被删除的记录的ID,以及在“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"]
}

下拉刷新:

enter image description here

加载更多

enter image description here


15

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

如果你真的需要适应这种极端情况,你需要“记住”你离开的位置。Jandjorgensen的建议非常准确,但我会使用保证唯一的字段,比如主键。你可能需要使用多个字段。

沿用Facebook的方法,你可以(而且应该)缓存已经请求过的页面,并且如果他们再次请求同样的页面,就只返回那些已经被过滤掉已删除行的页面。


3
这不是一个可接受的解决方案。它需要大量的时间和内存。所有已删除的数据以及请求的数据都需要保存在内存中,如果同一用户不再请求任何条目,则可能根本不会使用。 - Deepak Garg
3
我不同意。仅保留唯一标识符并不会占用太多内存。您不需要无限期地保留数据,只需在"会话"期间保留即可。使用memcache轻松实现,只需设置过期时间(例如10分钟)。 - Brent Baisley
1
内存比网络/CPU速度便宜。因此,如果创建页面非常昂贵(从网络或CPU的角度来看),那么缓存结果是一种有效的方法。@DeepakGarg - U Avalos

13

选项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:带有续订令牌的扩展键集分页

为了处理普通键集分页的缺点,您可以向时间戳添加偏移量,并使用所谓的“续订令牌”或“游标”。偏移量是元素相对于具有相同时间戳的第一个元素的位置。通常,令牌的格式为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分页”。该方法的缺点是实现复杂,因为必须考虑许多特殊情况。这就是为什么像continuation-token这样的库会很方便(如果您正在使用Java或JVM语言)。免责声明:我是该帖子的作者和该库的共同作者。


9

分页通常属于"用户"操作,为防止计算机和人脑过载,一般只返回子集。但是,与其认为我们没有得到整个列表,不如问一下是否重要?

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

现在如果有一个需要获取数据快照的情况,我会提供一个API调用,一次性提供所有数据而不进行分页。当然,如果你有一个大型数据集,你需要一些可以流式输出而不会暂时加载到内存中的东西。

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


9

1
Slack这篇文章很棒,非常清楚地解释了如何根据你的使用情况和数据集大小来设计分页。谢谢! - undefined

4
我认为您的API目前正如其应该的那样进行响应。页面上显示的前100个记录是您正在维护的对象的整体顺序。您的解释表明,您使用某种排序ID来定义分页对象的顺序。
现在,如果您希望第二页始终从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。 - John Henckel
无论如何,要么按照ID排序,要么如果您有一些具体的业务字段需要排序,例如creation_date等。 - mickeymoon

4

在RESTful API中,另一种分页选项是使用这里介绍的链接头(Link header)。例如,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"
rel 可能的取值包括: first, last, next, previous。但是通过使用 Link 头部,可能无法指定 total_count(元素总数)。

这是完美的解决方案,应该是一个可接受的答案。 - Sathiamoorthy

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接