API分页最佳实践

我喜欢一些一些帮助处理一个奇怪的边缘情况与分页API我正在建设。

与许多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的示例将非常感谢!

236579 次浏览

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

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

一个问题可能是,如果您添加了一个数据项,但根据您的描述,听起来它们将被添加到最后(如果没有,请告诉我,我将看看是否可以改进这一点)。

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

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

如果你真的需要适应这种边缘情况,你需要“记住”你停止的地方。jandjorgensen的建议是正确的,但我将使用保证唯一的字段,如主键。您可能需要使用多个字段。

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

你有几个问题。

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

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

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

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

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:当服务器不够智能,无法处理对象状态时。

您可以将所有缓存记录的唯一id发送到服务器,例如["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,那么你的请求字符串将太长,像这样:-

{
"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,以及返回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"]
}

拉到刷新:-

enter image description here

加载更多

enter image description here

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

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

如果需要一个精确的实时滚动视图,本质上是请求/响应的REST api并不适合这个目的。为此,你应该考虑WebSockets或HTML5 Server-Sent Events,让你的前端知道何时处理更改。

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

对于我的例子,我隐式地指定了一些API调用来允许获取全部信息(主要是引用表数据)。您还可以保护这些api,使其不会损害您的系统。

我对此进行了长时间的思考,最终得出了下面我将描述的解决方案。这在复杂性上是一个相当大的进步,但如果你确实迈出了这一步,你最终会得到你真正想要的,这是未来请求的确定性结果。

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

我已经实现了它,实际上它比我想象的要简单。以下是我所做的:

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

选项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语言)。免责声明:我是这篇文章的作者和图书馆的合著者。

再补充一下Kamilk的回答:https://www.stackoverflow.com/a/13905589

这在很大程度上取决于你处理的数据集有多大。小型数据集确实可以在抵消分页上有效工作,但大型实时数据集确实需要光标分页。

找到了一篇关于松弛如何随着数据集的增加而进化其api的分页的精彩文章:https://slack.engineering/evolving-api-pagination-at-slack-1c1f644f8e12

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"

rel的可能值是:第一个,最后一个,下一个,上一个。但是通过使用Link头文件,可能无法指定total_count(元素总数)。

参考API分页设计,我们可以通过光标设计分页api

他们有一个概念,叫做游标,它是一个指向行的指针。你可以对数据库说"在那之后返回100行"对于数据库来说,这要容易得多,因为很有可能通过带索引的字段来标识行。这样你就不需要获取和跳过这些行了,你可以直接跳过它们。 一个例子:< / p >
  GET /api/products
{"items": [...100 products],
"cursor": "qWe"}

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

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

实现方面有许多选项。通常,您有一些排序标准,例如,产品id。在这种情况下,你将用一些可逆算法(比如hashids)来编码你的产品id。在接收到一个带有游标的请求时,你解码它并生成一个类似WHERE id > :cursor LIMIT 100的查询。

优势:

  • db的查询性能可以通过cursor来提高
  • 处理好时,新内容插入到db查询

劣势:

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