我喜欢使用我正在构建的分页API处理一个奇怪的边缘案例。
与许多API一样,这个API分散了很多结果。如果您查询/ foos,您将得到100个结果(即foo#1-100),以及指向/ foos?page = 2的链接,该链接应返回foo#101-200。
不幸的是,如果在API使用者进行下一次查询之前从数据集中删除了foo#10,/ foos?page = 2将偏移100并返回#102-201。
对于试图吸引所有人的API消费者而言,这是一个问题 - 他们不会收到foo#101。
处理此问题的最佳做法是什么?我们希望尽可能轻量级(即避免处理API请求的会话)。非常感谢来自其他API的示例!
答案 0 :(得分:163)
我不完全确定您的数据是如何处理的,所以这可能会也可能不会起作用,但您是否考虑过使用时间戳字段进行分页?
当你查询/ 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中分页的方式(向下滚动到底部,以我上面给出的格式查看分页链接)。
如果你添加一个数据项,可能会有一个问题,但是根据你的描述,听起来它们会被添加到最后(如果没有,请告诉我,我会看看我是否可以对此进行改进)。< / p>
答案 1 :(得分:27)
你有几个问题。
首先,您有一个引用的例子。
如果插入行,您也会遇到类似问题,但在这种情况下,用户会获得重复数据(可以说比丢失数据更容易管理,但仍然是个问题)。
如果您没有快照原始数据集,那么这只是生活中的一个事实。
您可以让用户制作一个明确的快照:
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
然后发送整个工具包。
答案 2 :(得分:24)
如果你有分页,你也可以用一些键对数据进行排序。为什么不让API客户端在URL中包含先前返回的集合的最后一个元素的键,并在SQL查询中添加WHERE
子句(或等效的,如果您不使用SQL),以便它返回只有那些键大于这个值的元素?
答案 3 :(得分:17)
根据您的服务器端逻辑,可能有两种方法。
方法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"]
}
拉动刷新: -
加载更多
答案 4 :(得分:14)
可能很难找到最佳实践,因为大多数使用API的系统都不适合这种情况,因为它是一个极端优势,或者它们通常不会删除记录(Facebook,Twitter)。 Facebook实际上说,由于分页后进行过滤,每个“页面”可能没有请求的结果数量。 https://developers.facebook.com/blog/post/478/
如果你真的需要适应这种边缘情况,你需要“记住”你离开的地方。 jandjorgensen建议只是关于点,但我会使用保证像主键一样独特的字段。您可能需要使用多个字段。
遵循Facebook的流程,您可以(并且应该)缓存已经请求的页面,如果他们请求已经请求的页面,则返回已删除的已删除行的页面。
答案 5 :(得分:9)
分页通常是&#34;用户&#34;操作和防止计算机和人类大脑过载通常给你一个子集。但是,不要以为我们没有获得整个列表,而是问这有关系吗?
如果需要准确的实时滚动视图,那么请求/响应的REST API就不适合此目的。为此,您应该考虑使用WebSockets或HTML5 Server-Sent Events来让您的前端知道何时处理更改。
现在,如果有需要来获取数据的快照,我只会提供一个API调用,它在一个请求中提供所有数据而不进行分页。请注意,如果您有大量数据集,则需要一些可以对输出进行流式传输但不会暂时将其加载到内存中的内容。
对于我的情况,我隐式指定一些API调用以允许获取整个信息(主要是参考表数据)。您还可以保护这些API,以免损害您的系统。
答案 6 :(得分: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)
答案 7 :(得分:4)
选项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:使用连续令牌扩展键集分页
为了处理普通键集分页的上述缺点,您可以在时间戳中添加一个偏移量,并使用所谓的&#34; Continuation Token&#34;或&#34;光标&#34;。偏移量是元素相对于具有相同时间戳的第一个元素的位置。通常,令牌的格式类似于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"
}
}
令牌&#34; 1512757072_2&#34;指向页面的最后一个元素并指出&#34;客户端已经获得了带有时间戳1512757072&#34;的第二个元素。这样,服务器知道继续的位置。
请注意,您必须处理两个请求之间元素发生变化的情况。这通常通过向令牌添加校验和来完成。此校验和是使用此时间戳计算所有元素的ID。因此,我们最终会使用这样的令牌格式:Timestamp_Offset_Checksum
。
有关此方法的详细信息,请查看博客文章&#34; Web API Pagination with Continuation Tokens&#34;。这种方法的缺点是棘手的实现,因为必须考虑许多极端情况。这就是为什么像continuation-token这样的库可以派上用场的原因(如果您使用的是Java / JVM语言)。免责声明:我是该帖子的作者,也是该图书馆的合着者。
答案 8 :(得分:3)
我对这一点进行了长时间的考虑,最终得到了我将在下面描述的解决方案。这在复杂性方面是一个相当大的进步,但如果你确实采取了这一步骤,你将最终得到你真正追求的东西,这是未来请求的确定性结果。
您删除项目的示例只是冰山一角。如果您按color=blue
过滤但有人在请求之间更改项目颜色,该怎么办?以可分页的方式可靠地获取所有项目不可能 ...除非......我们实施修订历史。
我已经实现了它,实际上并不像我预期的那么困难。这是我做的:
changelogs
id
字段,但这不是主键changeId
字段,它既是主键,也是更改日志的外键。changelogs
中插入新记录,抓取该ID并将其分配给该实体的新版本,然后插入DB changeId
代表创建更改时基础数据的唯一快照。 changeId
的请求的结果。结果永远不会过期,因为它们永远不会改变。答案 9 :(得分:3)
只需添加Kamilk的答案:https://www.stackoverflow.com/a/13905589
很大程度上取决于您正在处理的数据集的大小。小数据集可以有效地处理偏移分页,但是大型实时数据集确实需要光标分页。
发现了一篇关于 Slack 如何演变其api的分页的精彩文章,因为数据集增加了解释每个阶段的正面和负面:https://slack.engineering/evolving-api-pagination-at-slack-1c1f644f8e12
答案 10 :(得分:2)
参考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 > :cursor LIMIT 100
的查询。
优势:
cursor
提高db的查询性能缺点:
previous page
链接答案 11 :(得分:0)