ChatGPT解决这个技术问题 Extra ChatGPT

API 分页最佳实践

我想要一些帮助来处理我正在构建的分页 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 的示例将不胜感激!

刚刚编辑了问题 - 问题是 foo #101 不会出现在结果中,并且 API 使用者试图提取所有 foo 会错过一个。
我一直面临同样的问题并寻找解决方案。 AFAIK,如果每个页面都执行一个新查询,那么确实没有可靠的保证机制来实现这一点。我能想到的唯一解决方案是保持活动会话,并将结果集保留在服务器端,而不是为每个页面执行新查询,只需获取下一个缓存的记录集。
看看 twitter 如何实现这一目标dev.twitter.com/rest/public/timelines
@java_geek since_id 参数如何更新?在 twitter 网页中,他们似乎正在使用相同的 since_id 值发出两个请求。我想知道它什么时候会更新,以便如果添加了新的推文,它们可以被解释?
@Petar since_id 参数需要由 API 的使用者更新。如果您看到,那里的示例是指处理推文的客户端

r
ramblinjan

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

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

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


不能保证时间戳是唯一的。也就是说,可以使用相同的时间戳创建多个资源。所以这种方法的缺点是下一页可能会重复当前页面的最后(几个?)条目。
@prmatta 实际上,取决于数据库实现 a timestamp is guaranteed to be unique
@jandjorgensen 从您的链接:“时间戳数据类型只是一个递增的数字,不保留日期或时间。......在 SQL Server 2008 及更高版本中,时间戳类型已重命名为 rowversion,大概是为了更好地反映其目的和价值。”因此,这里没有证据表明时间戳(实际上包含时间值的时间戳)是唯一的。
@jandjorgensen 我喜欢你的建议,但你不需要资源链接中的某种信息,所以我们知道我们是上一个还是下一个?诸如:“previous”:“api.example.com/foo?before=TIMESTAMP”“next”:“api.example.com/foo?since=TIMESTAMP2”我们也将使用我们的序列ID而不是时间戳。你觉得这有什么问题吗?
另一个类似的选项是使用 RFC 5988(第 5 节)中指定的 Link 标头字段:tools.ietf.org/html/rfc5988#page-6
k
kamilk

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


这不是一个坏建议,但是仅仅因为您按值排序并不意味着它是“键”,即唯一。
确切地。例如,在我的例子中,排序字段恰好是一个日期,而且它远非唯一。
W
Will Hartung

你有几个问题。

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

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

如果您没有对原始数据集进行快照,那么这只是生活中的事实。

您可以让用户制作显式快照:

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

只需发送整个套件。


(默认排序的 foos 是按创建日期,所以行插入不是问题。)
实际上,仅捕获文档键是不够的。这样,当用户请求它们时,您必须按 ID 查询完整对象,但它们可能不再存在。
M
Mohd Iftekhar Qurashi

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

方法 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


B
Brent Baisley

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

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

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


这不是一个可接受的解决方案。这非常耗费时间和内存。所有已删除的数据以及请求的数据都需要保存在内存中,如果同一用户不再请求任何条目,则可能根本不会使用这些数据。
我不同意。仅保留唯一 ID 根本不会使用太多内存。您不会无限期地保留数据,只是为了“会话”。使用 memcache 很容易,只需设置过期时间(即 10 分钟)。
内存比网络/CPU速度便宜。因此,如果创建页面非常昂贵(就网络而言或 CPU 密集型),那么缓存结果是一种有效的方法@DeepakGarg
p
phauer

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


A
Archimedes Trajano

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

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

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

就我而言,我隐含地指定了一些 API 调用以允许获取整个信息(主要是参考表数据)。您还可以保护这些 API,使其不会损害您的系统。


C
Community

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

很大程度上取决于您正在处理的数据集有多大。小型数据集在偏移分页上确实有效,但大型实时数据集确实需要光标分页。找到一篇精彩的文章,关于 Slack 如何随着数据集的增加而演变其 api 的分页,解释了每个阶段的正面和负面:https://slack.engineering/evolving-api-pagination-at-slack-1c1f644f8e12


m
mickeymoon

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

我同意。而不是按记录号查询(这不可靠),您应该按 ID 查询。将您的查询(x,m)更改为“返回最多 m 个按 ID 排序的记录,ID > x”,然后您可以简单地将 x 设置为上一个查询结果中的最大 id。
没错,要么按 id 排序,要么如果你有一些具体的业务领域要排序,比如 creation_date 等。
S
Stijn de Witt

我对此进行了长期而艰苦的思考,最终得出了我将在下面描述的解决方案。这是复杂性上的一大步,但如果你确实做了这一步,你最终会得到你真正追求的东西,这是未来请求的确定性结果。

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

我已经实现了它,它实际上没有我预期的那么困难。这是我所做的:

我创建了一个带有自动递增 ID 列的表更改日志

我的实体有一个 id 字段,但这不是主键

这些实体有一个 changeId 字段,它既是主键,也是变更日志的外键。

每当用户创建、更新或删除记录时,系统都会在更改日志中插入新记录,获取 id 并将其分配给实体的新版本,然后将其插入数据库

我的查询选择最大 changeId(按 id 分组)并自行加入以获取所有记录的最新版本。

过滤器应用于最近的记录

状态字段跟踪项目是否被删除

最大changeId返回给客户端,在后续请求中作为查询参数添加

因为只创建了新的更改,所以每个 changeId 都代表了创建更改时底层数据的唯一快照。

这意味着您可以永久缓存具有参数 changeId 的请求的结果。结果永远不会过期,因为它们永远不会改变。

这也开启了令人兴奋的功能,例如回滚/还原、同步客户端缓存等。任何受益于更改历史的功能。


我很困惑。这如何解决您提到的用例? (缓存中的随机字段更改并且您想要使缓存无效)
对于您自己进行的任何更改,您只需查看响应即可。服务器将提供一个新的 changeId,您可以在下一个请求中使用它。对于其他更改(由其他人进行),您可以每隔一段时间轮询一次最新的 changeId,如果它高于您自己的,您就知道有未完成的更改。或者您设置了一些通知系统(长轮询、服务器推送、websockets),当有未完成的更改时提醒客户端。
C
Community

RESTFul API 中分页的另一个选项是使用引入的链接标头 here。例如 Github use it 如下:

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(元素总数)。


这是完美的解决方案,应该是可以接受的答案。
z
zangw

参考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 无法生成上一页链接


关注公众号,不定期副业成功案例分享
关注公众号

不定期副业成功案例分享

领先一步获取最新的外包任务吗?

立即订阅