Передовий досвід створення сторінок 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, які намагаються витягнути всі колонтитули - вони не отримають foo # 101.

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


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

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

31
Погляньте, як щебетати досягають цього dev.twitter.com/rest/public/timlines
java_geek

1
@java_geek Як оновлений параметр since_id? На веб-сторінці twitter здається, що вони роблять обидва запити з однаковим значенням для 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 (прокрутіть униз донизу, щоб побачити посилання на сторінки у форматі, який я надав вище).

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


30
Не гарантується, що часові позначки будуть унікальними. Тобто, кілька ресурсів можна створити з однією і тією ж міткою часу. Тож такий підхід має і менший бік, що наступна сторінка може повторити останні (кілька?) Записів з поточної сторінки.
рубль

4
@prmatta Насправді, залежно від впровадження бази даних, часова марка гарантується унікальною .
ramblinjan

2
@jandjorgensen З Вашого посилання: "Тип даних часових міток є лише збільшенням числа і не зберігає дату чи час. ... На SQL сервері 2008 і пізніше тип часової позначки було перейменовано на rowversion , імовірно, щоб краще відобразити його мета та значення ». Тому тут немає жодних доказів того, що часові позначки (ті, які насправді містять значення часу) є унікальними.
Нолан Емі

3
@jandjorgensen Мені подобається ваша пропозиція, але чи не потрібна вам якась інформація у посиланнях на ресурси, тож ми знаємо, чи переходимо ми до попереднього чи наступного? Sth на зразок: "попередній": " api.example.com/foo?before=TIMESTAMP " "наступний": " api.example.com/foo?since=TIMESTAMP2 " Ми також використовуватимемо свої ідентифікатори послідовностей замість позначки часу. Чи бачите ви з цим якісь проблеми?
longliveenduro

5
Інший подібний варіант - використовувати поле заголовка Посилання, вказане в 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

і просто відправити весь комплект.



Насправді, захоплення лише ключів документів недостатньо. Таким чином, вам доведеться запитувати всі об’єкти за ідентифікатором, коли користувач їх запитує, але можливо, їх більше не існує.
Скад

27

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


4
Це не погана пропозиція, однак лише тому, що сортування за значенням не означає, що це "ключ", тобто унікальний.
Кріс Павич

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

19

Залежно від логіки вашого сервера може бути два підходи.

Підхід 1: Коли сервер недостатньо розумний для обробки станів об'єктів.

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

Ваш сервер повинен відповідати за повернення нових записів (завантажувати більше записів або нових записів за допомогою "pull to refresh"), а також ідентифікаторів видалених записів з ["id1", "id2", "id3", "id4", "id5", " 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: Коли сервер достатньо розумний для обробки станів об'єктів відповідно до дати.

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

Приклад: - Якщо ви вимагаєте завантажити більше, ваш запит повинен виглядати приблизно так: -

{
        "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 хвилин).
Brent Baisley

пам'ять дешевша, ніж швидкість мережі / процесора. Тож якщо створення сторінки дуже дороге (з точки зору мережі або інтенсивного процесора), то кешування результатів є правильним підходом @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 за допомогою маркерів продовження ". Недоліком такого підходу є хитра реалізація, оскільки існує багато важливих випадків, які потрібно враховувати. Ось чому такі бібліотеки, як маркер продовження, можуть бути зручними (якщо ви використовуєте мову 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 на максимальний ідентифікатор з попереднього результату запиту.
Джон Генкель

Правда, або сортуйте за ідентифікаторами, або якщо у вас є якесь конкретне бізнес-поле для сортування на зразок
create_date

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, захоплює ідентифікатор і призначає його новій версії об'єкта, яку він потім вставляє в БД
  • Мої запити вибирають максимальний змінний ідентифікатор (згрупований за id) та самостійно приєднуються до цього, щоб отримати найсвіжіші версії всіх записів.
  • Фільтри застосовуються до останніх записів
  • Поле стану відстежує, чи вилучений елемент
  • Максимальна змінаId повертається клієнту та додається як параметр запиту в наступних запитах
  • Оскільки створюються лише нові зміни, кожен момент changeIdпредставляє унікальний знімок базових даних у момент створення змін.
  • Це означає, що ви можете кешувати результати запитів, які мають параметр changeIdв них назавжди. Результати ніколи не закінчуються, оскільки вони ніколи не зміняться.
  • Це також відкриває захоплюючі функції, такі як відкат / повернення, синхронізація кеш-пам'яті клієнта тощо. Будь-які функції, які користуються історією змін.

я збентежений. Як це вирішити згаданий вами випадок використання? (Випадкове поле змінюється в кеші, і ви хочете визначити недійсний кеш)
U Avalos,

На будь-які зміни, які ви вносите самі, ви просто дивитесь на відповідь. Сервер надасть новий changeId, і ви використовуєте це у своєму наступному запиті. Щодо інших змін (внесених іншими людьми), ви будь-коли опитуєте останню змінуId, і якщо вона перевищує вашу власну, ви знаєте, що є непогашені зміни. Або ви налаштуєте якусь систему сповіщень (тривале опитування. Натискання сервера, веб-розетки), яка сповіщає клієнта про виникнення непогашених змін.
Штійн де Вітт

0

Інший варіант Пагинації в API RESTFul - це використання заголовка Посилання, введеного тут . Наприклад, 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: перший, останній, наступний, попередній . Але за допомогою Linkзаголовка неможливо вказати total_count (загальна кількість елементів).

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.