我喜欢一些一些帮助处理一个奇怪的边缘情况与分页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的示例将非常感谢!


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

当你查询/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中包含之前返回的集合的最后一个元素的键,并在SQL查询中添加一个WHERE子句(或者其他等价的东西,如果您不使用SQL),以便它只返回那些键大于此值的元素呢?


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

如果你真的需要适应这种边缘情况,你需要“记住”你停止的地方。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"]
}

拉到刷新:-

加载更多


我认为目前你的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,使其不会损害您的系统。


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

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

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

I created a single table changelogs with an auto-increment ID column My entities have an id field, but this is not the primary key The entities have a changeId field which is both the primary key as well as a foreign key to changelogs. Whenever a user creates, updates or deletes a record, the system inserts a new record in changelogs, grabs the id and assigns it to a new version of the entity, which it then inserts in the DB My queries select the maximum changeId (grouped by id) and self-join that to get the most recent versions of all records. Filters are applied to the most recent records A state field keeps track of whether an item is deleted The max changeId is returned to the client and added as a query parameter in subsequent requests Because only new changes are created, every single changeId represents a unique snapshot of the underlying data at the moment the change was created. This means that you can cache the results of requests that have the parameter changeId in them forever. The results will never expire because they will never change. This also opens up exciting feature such as rollback / revert, synching client cache etc. Any features that benefit from change history.


选项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

这在很大程度上取决于你处理的数据集有多大。小型数据集确实可以有效地进行偏移分页,但大型实时数据集确实需要游标分页。 找到了一篇精彩的文章,关于Slack如何随着数据集的增加而进化其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的可能值是:first, last, next, previous。但是通过使用Link头,可能无法指定total_count(元素的总数)。


参考API分页设计,我们可以通过游标来设计分页API

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

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

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

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

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

优势:

通过游标可以提高数据库的查询性能 处理好时,新内容插入到db查询

劣势:

使用无状态API生成前一个页面链接是不可能的