首页 文章

API分页最佳实践

提问于
浏览
241

我希望通过我正在构建的分页API来处理一个奇怪的边缘情况 .

像许多API一样,这个API分散了很多结果 . 如果你查询/ foos,你将获得100个结果(即foo#1-100),以及指向/ foos?page = 2的链接,它应返回foo#101-200 .

不幸的是,如果在API使用者进行下一次查询之前从数据集中删除了foo#10,/ foos?page = 2将偏移100并返回foos#102-201 .

这对于试图吸引所有泡沫的API消费者来说是个问题 - 他们不会收到foo#101 .

处理这个问题的最佳做法是什么?我们希望尽可能轻量级(即避免处理API请求的会话) . 其他API的例子将不胜感激!

10 回答

  • 26

    你有几个问题 .

    首先,你有一个你引用的例子 .

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

    如果您没有快照原始数据集,那么这只是生活中的一个事实 .

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

    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
    

    然后发送整个套件 .

  • 23

    如果你有分页,你也可以通过一些键对数据进行排序 . 为什么不让API客户端在URL中包含先前返回的集合的最后一个元素的键,并在SQL查询中添加 WHERE 子句(或者等效的,如果您不使用SQL),这样它只返回那些元素密钥大于这个值?

  • 4

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

    当您查询/ 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"
        }
    
    }
    

    只是一个注释,只使用一个时间戳依赖于结果中的隐式'limit' . 您可能希望添加显式限制或使用 until 属性 .

    可以使用列表中的最后一个数据项动态确定时间戳 . 这似乎或多或少是Facebook在其_976678中分页的方式(向下滚动到底部以我上面给出的格式查看分页链接) .

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

  • 1

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

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

  • 14

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

    Approach 1: When server is not smart enough to handle object states.

    您可以将所有缓存的记录唯一ID发送到服务器,例如[“id1”,“id2”,“id3”,“id4”,“id5”,“id6”,“id7”,“id8”,“id9”, “id10”]和一个布尔参数,用于了解您是在请求新记录(拉动刷新)还是旧记录(加载更多) .

    您的服务器应负责返回新记录(通过pull刷新来加载更多记录或新记录)以及来自[“id1”,“id2”,“id3”,“id4”,“id5”,“id5”的已删除记录的ID ID6" , “ID7”, “ID8”, “ID9”, “ID10”] .

    Example:- 如果您要求加载更多,那么您的请求应如下所示: -

    {
            "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
    }
    

    Approach 2: When server is smart enough to handle object states according to date.

    您可以发送第一条记录的ID以及最后一条记录和上一个请求的纪元时间 . 这样,即使您有大量缓存记录,您的请求也总是很小

    Example:- 如果您要求加载更多,那么您的请求应如下所示: -

    {
            "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"]
    }
    

    拉动刷新: -

    enter image description here

    装载更多

    enter image description here

  • 17

    由于大多数使用API的系统通常会删除记录(Facebook,Twitter),因此可能很难找到最佳实践 . Facebook实际上说,由于在分页后进行过滤,每个"page"可能没有请求的结果数 . https://developers.facebook.com/blog/post/478/

    如果你真的需要适应这种边缘情况,你需要“记住”你离开的地方 . jandjorgensen建议只是关于点,但我会使用保证像主键一样独特的字段 . 您可能需要使用多个字段 .

    在Facebook的流程之后,您可以(并且应该)缓存已经请求的页面,并将其返回如果已删除的行请求已经请求的页面,则会对其进行过滤 .

  • 9

    分页通常是一种操作,为了防止计算机和人脑过载,你通常会给出一个子集 . 然而,不是认为我们没有得到整个列表,而是问问题是否重要可能更好?

    如果需要准确的实时滚动视图,那么请求/响应的REST API就不适合用于此目的 . 为此,您应该考虑使用WebSockets或HTML5 Server-Sent Events来让您的前端知道何时处理更改 .

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

    对于我的情况,我隐式指定一些API调用以允许获取整个信息(主要是参考表数据) . 您还可以保护这些API,以免损害您的系统 .

  • 3

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

    我对这一点进行了长时间的考虑,并最终得到了我将在下面描述的解决方案 . 这在复杂性方面是一个相当大的进步,但如果你确实采取了这一步骤,你将最终得到你真正追求的东西,这是未来请求的确定性结果 .

    您删除项目的示例只是冰山一角 . 如果您按_976699过滤但有人在请求之间更改项目颜色该怎么办?以可分页的方式可靠地获取所有项目是不可能的......除非......我们实施 revision history .

    我实现了它,实际上并不像我预期的那么困难 . 这是我做的:

    • 我创建了一个带有自动增量ID列的单个表 changelogs

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

    • 实体有一个 changeId 字段,它既是主键,也是更改日志的外键 .

    • 每当用户创建,更新或删除记录时,系统会在 changelogs 中插入新记录,抓取该ID并将其分配给该实体的 new 版本,然后将其插入到数据库中

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

    • 过滤器应用于最新记录

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

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

    • 因为只创建了新的更改,所以每个 changeId 表示创建更改时基础数据的唯一快照 .

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

    • 这也打开了令人兴奋的功能,例如回滚/恢复,同步客户端缓存等 . 任何受益于更改历史记录的功能 .

  • 156

    Option A: Keyset Pagination with a Timestamp

    为了避免您提到的偏移分页的缺点,您可以使用基于键的分页 . 通常,实体具有指示其创建或修改时间的时间戳 . 此时间戳可用于分页:只需将最后一个元素的时间戳作为下一个请求的查询参数传递 . 反过来,服务器使用时间戳作为过滤条件(例如 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"
        }
    }
    

    这样,你就不会错过任何元素 . 对于许多用例,这种方法应该足够好 . 但是,请记住以下几点:

    • 当单个页面的所有元素具有相同的时间戳时,您可能会遇到无限循环 .

    • 当具有相同时间戳的元素与两个页面重叠时,您可以多次向客户端传递多个元素 .

    您可以通过增加页面大小和使用毫秒精度的时间戳来降低这些缺点 .

    Option B: Extended Keyset Pagination with a Continuation Token

    要处理正常键集分页的上述缺点,可以向时间戳添加偏移量并使用所谓的"Continuation Token"或"Cursor" . 偏移量是元素相对于具有相同时间戳的第一个元素的位置 . 通常,令牌的格式类似于 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语言) . 免责声明:我是该帖子的作者,也是该图书馆的合着者 .

相关问题