我想要一些帮助来处理我正在构建的分页 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 的示例将不胜感激!
我不完全确定您的数据是如何处理的,所以这可能有效也可能无效,但是您是否考虑过使用时间戳字段进行分页?
当您查询 /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 中分页的方式(向下滚动到底部以查看我上面给出的格式的分页链接)。
一个问题可能是您添加了一个数据项,但根据您的描述,听起来它们会被添加到最后(如果没有,请告诉我,我会看看是否可以改进)。
如果您有分页,您还可以按某个键对数据进行排序。为什么不让 API 客户端在 URL 中包含先前返回的集合的最后一个元素的键,并将 WHERE
子句添加到您的 SQL 查询(或等效的东西,如果您不使用 SQL)以便它只返回那些键大于此值的元素?
你有几个问题。
首先,你有你引用的例子。
如果插入行,您也会遇到类似的问题,但在这种情况下,用户会得到重复的数据(可以说比丢失数据更容易管理,但仍然是一个问题)。
如果您没有对原始数据集进行快照,那么这只是生活中的事实。
您可以让用户制作显式快照:
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"," 的已删除记录的 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"]
}
拉动刷新:-
https://i.stack.imgur.com/AEGIh.jpg
装载更多
https://i.stack.imgur.com/0YD1y.jpg
可能很难找到最佳实践,因为大多数具有 API 的系统不适应这种情况,因为这是一种极端的优势,或者它们通常不会删除记录(Facebook、Twitter)。 Facebook 实际上表示,由于分页后进行的过滤,每个“页面”可能没有请求的结果数量。 https://developers.facebook.com/blog/post/478/
如果你真的需要适应这种边缘情况,你需要“记住”你离开的地方。 jandjorgensen 的建议几乎是正确的,但我会使用一个像主键一样保证是唯一的字段。您可能需要使用多个字段。
按照 Facebook 的流程,您可以(并且应该)缓存已经请求的页面,如果他们请求他们已经请求的页面,则只返回那些过滤了已删除行的页面。
选项 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 Pagination with Continuation Tokens”。这种方法的一个缺点是实现起来很棘手,因为必须考虑许多极端情况。这就是为什么像 continuation-token 这样的库会很方便(如果您使用的是 Java/JVM 语言)。免责声明:我是这篇文章的作者和图书馆的合著者。
分页通常是“用户”操作,为了防止计算机和人脑过载,您通常会给出一个子集。然而,与其认为我们没有得到完整的清单,不如问问这有关系吗?
如果需要准确的实时滚动视图,本质上是请求/响应的 REST API 不太适合此目的。为此,您应该考虑使用 WebSockets 或 HTML5 服务器发送事件,让您的前端在处理更改时知道。
现在,如果需要获取数据的快照,我只需提供一个 API 调用,在一个请求中提供所有数据,而无需分页。请注意,如果您有一个大型数据集,您将需要一些可以在不临时将其加载到内存中的情况下对输出进行流式传输的东西。
就我而言,我隐含地指定了一些 API 调用以允许获取整个信息(主要是参考表数据)。您还可以保护这些 API,使其不会损害您的系统。
只是为了补充 Kamilk 的答案:https://www.stackoverflow.com/a/13905589
很大程度上取决于您正在处理的数据集有多大。小型数据集在偏移分页上确实有效,但大型实时数据集确实需要光标分页。找到一篇精彩的文章,关于 Slack 如何随着数据集的增加而演变其 api 的分页,解释了每个阶段的正面和负面:https://slack.engineering/evolving-api-pagination-at-slack-1c1f644f8e12
我认为目前您的 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)
我对此进行了长期而艰苦的思考,最终得出了我将在下面描述的解决方案。这是复杂性上的一大步,但如果你确实做了这一步,你最终会得到你真正追求的东西,这是未来请求的确定性结果。
您删除项目的示例只是冰山一角。如果您按 color=blue
过滤,但有人在请求之间更改了项目颜色怎么办?以分页方式可靠地获取所有项目不可能...除非...我们实现修订历史记录。
我已经实现了它,它实际上没有我预期的那么困难。这是我所做的:
我创建了一个带有自动递增 ID 列的表更改日志
我的实体有一个 id 字段,但这不是主键
这些实体有一个 changeId 字段,它既是主键,也是变更日志的外键。
每当用户创建、更新或删除记录时,系统都会在更改日志中插入新记录,获取 id 并将其分配给实体的新版本,然后将其插入数据库
我的查询选择最大 changeId(按 id 分组)并自行加入以获取所有记录的最新版本。
过滤器应用于最近的记录
状态字段跟踪项目是否被删除
最大changeId返回给客户端,在后续请求中作为查询参数添加
因为只创建了新的更改,所以每个 changeId 都代表了创建更改时底层数据的唯一快照。
这意味着您可以永久缓存具有参数 changeId 的请求的结果。结果永远不会过期,因为它们永远不会改变。
这也开启了令人兴奋的功能,例如回滚/还原、同步客户端缓存等。任何受益于更改历史的功能。
参考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。
优势:
通过游标可以提高db的查询性能
在查询时将新内容插入数据库时处理得当
坏处:
使用无状态 API 无法生成上一页链接