Лучшие практики API-пагинации


288

Я хотел бы получить некоторую помощь в обработке странного крайнего случая с разбитым на страницы API, который я создаю.

Как и многие API, этот разбивает на большие результаты. Если вы запросите / foos, вы получите 100 результатов (т.е. foo # 1-100) и ссылку на / foos? Page = 2, которая должна вернуть foo # 101-200.

К сожалению, если foo # 10 будет удален из набора данных до того, как потребитель API сделает следующий запрос, / foos? Page = 2 сместится на 100 и вернет foos # 102-201.

Это проблема для пользователей API, которые пытаются вытащить все foos - они не получат foo # 101.

Как лучше всего справляться с этим? Мы хотели бы сделать его как можно более легким (т.е. избегать обработки сессий для запросов API). Примеры из других API будут с благодарностью!


1
в чем тут проблема? Мне кажется, что в любом случае, пользователь получит 100 предметов.
НАРКОЗ

2
Я сталкиваюсь с этой же проблемой и ищу решение. AFAIK, на самом деле не существует надежного гарантированного механизма для этого, если каждая страница выполняет новый запрос. Единственное решение, которое я могу придумать, - это поддерживать активный сеанс и сохранять набор результатов на стороне сервера, а не выполнять новые запросы для каждой страницы, просто захватить следующий кэшированный набор записей.
Джерри Додж

31
Посмотрите, как твиттеры
java_geek

1
@java_geek Как обновляется параметр since_id? На веб-странице в Твиттере кажется, что они делают оба запроса с одинаковым значением для since_id. Интересно, когда он будет обновлен, так что если будут добавлены новые твиты, они могут быть учтены?
Петар

1
@Petar Параметр since_id должен быть обновлен пользователем API. Если вы видите, пример там относится к клиентам, обрабатывающим твиты
java_geek

Ответы:


176

Я не совсем уверен, как обрабатываются ваши данные, так что это может работать или не работать, но рассматривали ли вы разбиение на страницы с полем метки времени?

Когда вы запрашиваете / 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 разбивает на страницы в своем API API (прокрутите вниз до нижней части, чтобы увидеть ссылки на страницы в формате, который я дал выше).

Одной из проблем может быть, если вы добавляете элемент данных, но на основании вашего описания кажется, что они будут добавлены в конец (если нет, дайте мне знать, и я посмотрю, смогу ли я улучшить это).


30
Отметки времени не гарантируются быть уникальными. То есть несколько ресурсов могут быть созданы с одной и той же отметкой времени. Таким образом, у этого подхода есть и обратная сторона: следующая страница может повторять последние (несколько?) Записей с текущей страницы.
рубль

4
@prmatta На самом деле, в зависимости от реализации базы данных метка времени гарантированно будет уникальной .
Ramblinjan

3
@jandjorgensen По вашей ссылке: «Тип данных временной метки является просто инкрементным числом и не сохраняет дату или время. ... В SQL Server 2008 и более поздних версиях временной метки был переименован в инверсию строки , предположительно, чтобы лучше отражать ее цель и ценность. " Так что здесь нет доказательств того, что метки времени (те, которые на самом деле содержат значение времени) являются уникальными.
Нолан Эми

3
@jandjorgensen Мне нравится ваше предложение, но разве вам не нужна какая-то информация в ссылках на ресурсы, поэтому мы знаем, пойдем ли мы предыдущим или следующим? Sth, как: «предыдущий»: « api.example.com/foo?before=TIMESTAMP » «следующий»: « api.example.com/foo?since=TIMESTAMP2 » Мы также использовали бы наши идентификаторы последовательности вместо метки времени. Вы видите какие-либо проблемы с этим?
longliveenduro

5
Другой подобный вариант - использовать поле заголовка Link, указанное в RFC 5988 (раздел 5): tools.ietf.org/html/rfc5988#page-6
Anthony F

28

У вас есть несколько проблем.

Во-первых, у вас есть пример, который вы привели.

У вас также есть похожая проблема, если строки вставлены, но в этом случае пользователь получает дублирующиеся данные (возможно, легче управлять, чем отсутствующие данные, но все же проблема).

Если вы не делаете снимок исходного набора данных, это просто факт жизни.

Пользователь может сделать явный снимок:

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
(Сортировка foos по умолчанию производится по дате создания, поэтому вставка строк не является проблемой.)
2arrs2ells

На самом деле, захвата только ключей документов недостаточно. Таким образом, вам придется запрашивать полные объекты по идентификатору, когда пользователь запрашивает их, но, возможно, они больше не существуют.
Scadge

27

Если у вас есть нумерация страниц, вы также сортируете данные по некоторому ключу. Почему бы не позволить API-клиентам включить ключ последнего элемента ранее возвращенной коллекции в URL-адрес и добавить WHEREв ваш запрос SQL предложение (или что-то эквивалентное, если вы не используете SQL), чтобы он возвращал только те элементы, для которых ключ больше, чем это значение?


4
Это неплохое предложение, однако то, что вы сортируете по значению, не означает, что это «ключ», то есть уникальный.
Крис Пикок

Именно. Например, в моем случае поле сортировки является датой, и оно далеко не уникально.
Сб Тиру

19

Там может быть два подхода в зависимости от вашей логики на стороне сервера.

Подход 1. Когда сервер недостаточно умен для обработки состояний объекта.

Вы можете отправить все кэшированные записи уникальных идентификаторов на сервер, например ["id1", "id2", "id3", "id4", "id5", "id6", "id7", "id8", "id9", «id10»] и логический параметр, чтобы узнать, запрашиваете ли вы новые записи (pull для обновления) или старые записи (загрузить больше).

Ваш сервер должен отвечать за возвращение новых записей (загружать больше записей или новых записей с помощью функции pull для обновления), а также идентификаторов удаленных записей из ["id1", "id2", "id3", "id4", "id5", " ID6" , "ИД7", "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: Когда сервер достаточно умен, чтобы обрабатывать состояния объекта в соответствии с датой.

Вы можете отправить идентификатор первой записи, последней записи и времени начала предыдущего запроса. Таким образом, ваш запрос всегда мал, даже если у вас большое количество кэшированных записей.

Пример: - Если вы запрашиваете нагрузку больше, ваш запрос должен выглядеть примерно так:

{
        "isRefresh" : false,
        "firstId" : "id1",
        "lastId" : "id10",
        "last_request_time" : 1421748005
}

Ваш сервер отвечает за возвращение идентификаторов удаленных записей, которые были удалены после last_request_time, а также за возврат обновленной записи после 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"]
}

Потяните, чтобы обновить: -

введите описание изображения здесь

Загрузи больше

введите описание изображения здесь


14

Это может быть сложно найти лучшие практики, так как большинство систем с API-интерфейсами не подходят для этого сценария, потому что это крайний край, или они обычно не удаляют записи (Facebook, Twitter). Facebook фактически говорит, что на каждой «странице» может отсутствовать количество запрошенных результатов из-за фильтрации, выполненной после нумерации страниц. https://developers.facebook.com/blog/post/478/

Если вам действительно нужно учесть этот крайний случай, вам нужно «вспомнить», где вы остановились. Предложение jandjorgensen почти точно, но я бы использовал поле, гарантированно уникальное, как первичный ключ. Вам может понадобиться использовать более одного поля.

Следуя потоку Facebook, вы можете (и должны) кэшировать уже запрошенные страницы и просто возвращать страницы с отфильтрованными удаленными строками, если они запрашивают уже запрошенную страницу.


2
Это не приемлемое решение. Это занимает много времени и памяти. Все удаленные данные вместе с запрошенными данными должны храниться в памяти, которая может вообще не использоваться, если один и тот же пользователь не запрашивает больше записей.
Дипак Гарг

3
Я не согласен. Простое хранение уникальных идентификаторов совсем не занимает много памяти. Вы не должны хранить данные бесконечно, только для «сеанса». Это легко с memcache, просто установите срок действия (например, 10 минут).
Брент Бэйсли

память дешевле, чем скорость сети / процессора. Так что если создание страницы очень дорого (с точки зрения сети или нагрузки на процессор), то результаты кэширования - это правильный подход @DeepakGarg
U Avalos

9

Пагинация - это, как правило, «пользовательская» операция, и для предотвращения перегрузки как на компьютерах, так и на человеческом мозге вы обычно задаете подмножество. Однако, вместо того, чтобы думать, что мы не получаем весь список, лучше спросить, имеет ли это значение?

Если требуется точное представление с прокруткой в ​​реальном времени, API-интерфейсы REST, которые по своей природе являются запросом / ответом, не подходят для этой цели. Для этого вы должны рассмотреть WebSockets или HTML5 Server-Sent Events, чтобы ваш интерфейс знал об изменениях.

Теперь, если необходимо получить моментальный снимок данных, я бы просто предоставил вызов API, который предоставляет все данные в одном запросе без разбивки на страницы. Имейте в виду, вам нужно что-то, что будет выполнять потоковую передачу выходных данных без временной загрузки в память, если у вас большой набор данных.

В моем случае я неявно определяю некоторые вызовы API, чтобы получить всю информацию (в первую очередь данные справочной таблицы). Вы также можете защитить эти API, чтобы они не повредили вашу систему.


8

Вариант 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». Таким образом, сервер знает, где продолжить.

Пожалуйста, учтите, что вы должны обрабатывать случаи, когда элементы менялись между двумя запросами. Обычно это делается путем добавления контрольной суммы к токену. Эта контрольная сумма рассчитывается по идентификаторам всех элементов с этой отметкой времени. Таким образом , мы в конечном итоге с символической формой , как это: Timestamp_Offset_Checksum.

Дополнительную информацию об этом подходе можно найти в блоге « Разбиение на страницы веб-API с токенами продолжения ». Недостатком этого подхода является сложная реализация, поскольку необходимо учитывать множество угловых случаев. Вот почему такие библиотеки, как extension-token, могут быть полезны (если вы используете язык Java / JVM). Отказ от ответственности: я автор поста и соавтор библиотеки.


4

Я думаю, что в настоящее время ваши API действительно реагируют так, как должны. Первые 100 записей на странице в общем порядке объектов, которые вы ведете. Ваше объяснение говорит о том, что вы используете некие порядковые идентификаторы, чтобы определить порядок ваших объектов для нумерации страниц.

Теперь, если вы хотите, чтобы страница 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)

1
Я согласен. вместо запроса по номеру записи (что не является надежным), вы должны запросить по идентификатору. Измените ваш запрос (x, m), чтобы он означал «возвращать до m записей, отсортированных по идентификатору, с идентификатором> x», тогда вы можете просто установить x на максимальный идентификатор из результата предыдущего запроса.
Джон Хенкель

Правда, либо сортируйте по идентификаторам, либо если у вас есть конкретные бизнес-поля для сортировки, такие как creation_date и т. Д.
mickeymoon

4

Просто чтобы добавить к этому ответу Камилка: https://www.stackoverflow.com/a/13905589

Зависит от того, насколько большой набор данных вы работаете. Небольшие наборы данных эффективно работают с нумерацией смещений, но большие наборы данных в реальном времени требуют нумерации курсоров.

Нашел замечательную статью о том, как Slack эволюционировал пагинацию своего API, так как там увеличивались наборы данных, объясняя положительные и отрицательные стороны на каждом этапе: https://slack.engineering/evolving-api-pagination-at-slack-1c1f644f8e12


3

Я долго думал над этим и, наконец, нашел решение, которое опишу ниже. Это довольно большой шаг по сложности, но если вы сделаете этот шаг, вы получите то, что вам действительно нужно, то есть детерминированные результаты для будущих запросов.

Ваш пример удаления элемента - это только верхушка айсберга. Что делать, если вы фильтруете, color=blueно кто-то меняет цвета элементов между запросами? Надежная выборка всех элементов постраничным способом невозможна ... если ... мы не внедрим историю изменений .

Я реализовал это, и это на самом деле менее сложно, чем я ожидал. Вот что я сделал:

  • Я создал одну таблицу changelogsс колонкой идентификатора с автоинкрементом
  • У моих сущностей есть idполе, но это не первичный ключ
  • У сущностей есть changeIdполе, которое является как первичным ключом, так и внешним ключом для журналов изменений.
  • Всякий раз, когда пользователь создает, обновляет или удаляет запись, система вставляет в changelogsнее новую запись , захватывает идентификатор и назначает его новой версии сущности, которую затем вставляет в БД.
  • В моих запросах выбирается максимальное значение changeId (сгруппированное по идентификатору) и выполняется самостоятельное объединение, чтобы получить самые последние версии всех записей.
  • Фильтры применяются к самым последним записям
  • Поле состояния отслеживает, был ли удален элемент
  • Максимальный идентификатор изменения возвращается клиенту и добавляется в качестве параметра запроса в последующих запросах.
  • Поскольку создаются только новые изменения, каждый из них changeIdпредставляет собой уникальный снимок базовых данных на момент создания изменения.
  • Это означает, что вы можете кэшировать результаты запросов, в которых есть параметр, changeIdнавсегда. Результаты никогда не истекают, потому что они никогда не изменятся.
  • Это также открывает захватывающие функции, такие как откат / возврат, синхронизация клиентского кэша и т. Д. Любые функции, которые выигрывают от истории изменений.

я запутался. Как это решить вариант использования, который вы упомянули? (Случайное поле изменяется в кеше, и вы хотите сделать кеш недействительным)
U Avalos

Для любых изменений, которые вы делаете сами, вы просто смотрите на ответ. Сервер предоставит новый changeId, и вы будете использовать его в своем следующем запросе. Что касается других изменений (сделанных другими людьми), то вы либо время от времени опрашиваете последний идентификатор изменения, и, если он выше, чем ваш, вы знаете, что есть выдающиеся изменения. Или вы настраиваете какую-то систему уведомлений (длинный опрос. Отправка на сервер, веб-сокеты), которая предупреждает клиента об ожидающих изменениях.
Стейн де Витт

0

Другой вариант разбивки на страницы в API RESTFul - использовать представленный здесь заголовок 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 (общее количество элементов).

Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.