Використання та реалізація server-side pagination. Частина 2
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
Всім привіт. Я Сергій Моренець, розробник, викладач, спікер і технічний письменник, хочу поділитися з вами своїм досвідом роботи з такою цікавою темою, як посторінковий висновок даних у застосунках, що використовують ORM -технології. У першій частині цієї статті я розповів про offset-based pagination, про те, як його використовувати в REST API і реалізувати за допомогою JPA або Spring Data JPA. Цей підхід має свої особливості та недоліки, тому я б хотів познайомити вас з альтернативними способами реалізації посторінкового виведення даних.
Ну і за останні роки у нас накопичилося достатньо досвіду роботи з таким підходом, і ми розглядаємо ці технології на тренінгах з Spring MVC та Spring Data. Сподіваюся, що ця стаття буде корисною для всіх, хто займається роботою з базами даних та ORM-системами.
Cursor-based pagination
Думаю, що багатьом знайома така конструкція, як курсори бази даних. По суті, це іменований набір записів, отриманих після виконання SQL-запиту з можливістю пересуватися по цьому набору (в обох напрямках). Ось зразковий код на PL/pgSQL, який у Postgres оголошує курсор, а потім пересувається по ньому::
DECLARE cur_orders cursor for select * from ORDERS order by createdAt;
OPEN cur_orders;
FETCH cur_orders into id, product_id, amount;
MOVE cur_orders;
MOVE RELATIVE 5 cur_orders;
Таким чином, нам потрібно лише один раз виконати SQL-запит, а потім пересуватися по ньому за певним зміщенням. Правда, для цього потрібно оголосити курсор як scrollable за допомогою опції SCROLL. Виглядає привабливо, але при уважному вивченні можна помітити деякі мінуси:
- При закритті транзакції курсори автоматично закриваються (і стають недоступними). Таким чином, нам потрібно тримати поточну транзакцію відкритою, поки ще відкрите HTTP-з’єднання. Цього можна уникнути опцією WITH HOLD, але тоді сервер БД повинен матеріалізувати поточний набір даних (зберегти на диску).
- Даний підхід не дуже добре масштабуємо, оскільки чим більше одночасно запитів, тим більше ресурсів і пам’яті потрібно для зберігання таких наборів даних.
- Такий варіант не підходить прихильникам stateless API, тому що нам доводиться зберігати в пам’яті результати попереднього запиту. Якщо у нас використовується load balancer, то потрібно обов’язково підключати клієнта до того сервера, де зберігається його набір даних.
- Не всі СУБД підтримують курсори (наприклад, H2 або Apache Derby), тому таке рішення важко зробити універсальним.
Такий підхід годиться для невеликих наборів даних і невеликій кількості користувачів. Але ідея курсорів непогана, якщо під курсором розуміти покажчик на якийсь елемент у результуючому наборі даних, який однозначно ідентифікує цей елемент, тобто, власне, його унікальний ідентифікатор. Що це може бути за елемент?
Уявимо, що клієнт хоче отримати замовлення та виконує запит:
GET /orders?limit=20
Це призводить до створення такого SQL:
select * from ORDERS rows fetch first 20 rows only
Сервер поверне перші 20 записів. І клієнту для запиту наступної сторінки потрібно вказати покажчик на перший елемент цієї сторінки (як OFFSET у першому підході). Для такого покажчика може підійти первинний ключ, оскільки він завжди є унікальним. І тоді у відповіді клієнту на початковий запит потрібно буде повернути не лише дані, а й значення вказівника.
Але повертати його як «nextId» не дуже добре, тому що ми прив’язуємо API до реалізації. А простіше буде назвати, наприклад, «nextCursor»:
{
data: [
...
],
nextCursor: 21
}
І тоді клієнт для отримання наступної сторінки надішле запит:
GET /orders?limit=20&next_cursor=21
І це призведе до генерації SQL:
select * from ORDERS WHERE id >= 21 rows fetch first 20 rows only
Єдина відмінність цього запиту від offset-based — заміна OFFSET на WHERE, але такий запит буде виконуватися дуже швидко, тому що тут фільтрація за первинним ключем. Крім того, ми більше не залежимо від видалення/ додавання нових записів, оскільки орієнтуємося на ідентифікатор (а не зміщення). Але що, якщо у нас запит складніший і, наприклад, передбачає сортування (за датою додавання)?
select * from ORDERS WHERE id >= 21 ORDER BY createdAt DESC rows fetch first 20 rows only
У цьому випадку запит буде працювати некоректно, оскільки сортуючи дані за id і датою додавання, ми отримуємо різні результати. Ситуацію ускладнює те, що createdAt — це, швидше за все, кількість мілісекунд (Unix epoch) і не є унікальним значенням.
Тому, якщо ми використовуємо сортування в запиті, нам потрібно вказати в WHERE всі поля сортування:
select * from ORDERS WHERE (createdAt > 1689526152858) OR (createdAt = 1689526152858 AND id >= 21) ORDER BY createdAt, id DESC rows fetch first 20 rows only
Нам довелося двічі порівнювати createdAt, так його значення неунікальне. Але в будь-якому випадку цей запит буде працювати швидко, тому що ми спочатку отримуємо 20 записів, а потім сортуємо їх за createdAt і id. Щоправда, нам необхідний індекс на поля, створеніAt і id, якщо ми хочемо, щоб пошук по їх комбінації працював без table scan.
Такий запит можна спростити, якщо ви використовуєте MySQL чи Postgres:
select * from ORDERS WHERE (createdAt, id) > (1689526152858, 21) ORDER BY createdAt, id DESC rows fetch first 20 rows only
Тепер необхідно змінити і API, щоб клієнт передавав значення всіх полів, які використовуються при пошуку, наприклад, так:
GET /orders?limit=20&next_cursor=21,1689526152858
Таким чином, з точки зору API, ми реалізували cursor-based pagination, а з точки зору даних роботи з БД — keyset-based pagination, так як використовуємо набір ключів (стовпців), які однозначно характеризують наші записи.
Які недоліки такого підходу?
- Не можна перейти на довільну сторінку (лише наступну або попередню).
- Не можна розпаралелити запити для швидшого отримання даних.
- Складніша реалізація на бекенді.
Це досить серйозні мінуси. Уявімо, що у вас є велика таблиця, а клієнту необхідно отримати всі дані з неї. Але ви надаєте лише paginated API, де максимальний розмір сторінки — 20 елементів. У випадку page-based pagination заздалегідь знає кількість елементів та сторінок, тому може розпаралелити отримання даних і навіть відображати прогрес усієї операції. У випадку cursor-based pagination йому доведеться послідовно (і синхронно) отримувати дані. Не факт, що сервер поверне загальну кількість елементів. Це серйозні обмеження, які говорять про те, що не завжди такий підхід застосовується.
Але є ще один мінус. Надаючи клієнту значення ключів, ми розкриваємо деталі нашої реалізації. Чому це погано? Клієнт може здогадатися про те, як утворюється значення курсору і сам генерувати посилання. Але навіть якщо він не буде цього робити, користувачі можуть зберігати цей URI. А це ускладнить зміну (рефакторинг) нашої реалізації. Тому часто застосовують encoded cursor.
Нам необхідно зашифрувати (закодувати) значення курсору, так щоб воно представляло для користувача рядок випадкових символів. Найпростіший варіант — Base64.
І тоді 21,1689526152858 перетвориться на MjEsMTY4OTUyNjE1Mjg1OA==
Можна піти далі і з метою універсалізації таких значень кодувати рядок такого типу: id=21&createdAt=1689526152858
Після Base64 кодування на виході отримаємо aWQ9MjEmY3JlYXRlZEF0PTE2ODk1MjYxNTI4NTg=
Якщо нам не підходять такі довгі значення, можна просто генерувати випадковий рядок (не більше 10 символів) як такий курсор, а відповідність між курсором і оригінальним рядком зберігати в якійсь тимчасовій базі даних. Такий курсор буде більше схожий на короткоживучий токен, тому такий тип API часто називають token-based pagination:
GET /orders?limit=20&next_token= MjEsMTY4OTUyNjE1Mjg1OA==
Ще один підхід, який іноді можна зустріти — time-based pagination. Він популярний тоді, коли необхідно отримати нові дані (наприклад, в соцмережах). Реалізують його за допомогою offset-або cursor-based pagination, але як ми вже розбирали, час (у мілісекундах) не гарантує свою унікальність. Крім того, тут потрібно коректно враховувати часові пояси та інші деталі, що стосуються роботи з часом:
GET /orders?limit=20&after=1689526152858
Перейдемо до реалізації.
Scrolling в Spring Data JPA
JPA не надає спеціального API для server-side pagination. Як і у випадку з offset-based pagination, реалізовувати cursor-based pagination вам доведеться самостійно. З Spring Data JPA ситуація цікавіша. Тут ще в 2015 році задумали реалізувати cursor-based pagination, але зрештою відмовилися від цієї ідеї, оскільки знову ж таки такий підхід не підтримується JPA.
І аж до 2023 року єдине, що було доступне розробникам — page-based pagination. Але у версії 3.1 спочатку у Spring Data Commons, а потім і в інших Spring Data проєктах з’явився новий Scrolling API.
У чому відмінність від існуючого API з Pageable, Page і PagingAndSortingRepository? Новий API зроблений універсальнішим і підтримує два підходи:
- Offset-based pagination.
- Keyset-based pagination.
Оскільки у другому випадку вже немає сторінки як такої (не можна перейти на довільну сторінку), то тут використовується термін вікно (Window) замість Page, а замість pagination — scrolling.
Для того, щоб використовувати цей API, потрібно пройти три етапи:
- Створити та ініціалізувати об’єкт ScrollingPosition.
- Виконати запит та отримати об’єкт Window/WindowIterator.
- Пройтися за результатом та вийняти отримані дані.
Для нашого прикладу із замовленнями достатньо додати новий метод до репозиторію:
Window<Order> findAllBy(ScrollPosition position);
І тепер його можна викликати, вказавши, що ми хочемо використовувати offset-based pagination:
Window<Order> orders = orderRepository.findAllBy(ScrollPosition.offset());
У об’єкта Windows є методи hasNext і positionAt, і якщо нам потрібно відразу отримати наступні «вікна», то для цього потрібно викликати їхню комбінацію:
while(orders.hasNext()) {
orders = orderRepository.findAllBy(orders.positionAt(orders.size() -1));
}
Але що робити, якщо ми хочемо реалізувати «pagination»? Адже нам потрібно буде щоразу в запиті встановлювати нове усунення? Для цього потрібно передати його в метод offset:
Window<Order> orders = orderRepository.findAllBy(ScrollPosition.offset(20));
Якщо ви хочете повністю перейти на новий Scrolling API, тобто можливість сконвертувати об’єкт PageRequest в ScrollPosition:
PageRequest request = PageRequest.of(0, 20);
Window<Order> orders = orderRepository.findAllBy(request.toScrollPosition());
Цікавіша ситуація з keyset-based pagination. Якщо ми хочемо його використовувати, необхідно вказати набір ключових полів з їх значеннями та напрямок scrolling (вперед або назад):
orders = orderRepository.findAllBy(
ScrollPosition.forward(Map.of("id", id, "createdAt", LocalDateTime.now())), Sort.by("id", "createdAt"));
У результаті ми отримаємо такий SQL:
select * from ORDERS o1_0 where o1_0.id>? or o1_0.id=? and o1_0.createdAt>? order by o1_0.id,o1_0.createdAt
Якщо ж ви хочете перетворити дані значення в токен/курсор, це потрібно робити самостійно. Але як передати розмір вікна, як ми це робили з PageRequest? Тут можливі два варіанти. Можна вказати його за допомогою слова Top у назві query method:
Window<Order> findTop5By(ScrollPosition position);
А в Spring Data Commons 3.2, який вийде в жовтні 2023 року, вже з’явився новий інтерфейс Limit, в якому розробники Spring вдало використовували нове ключове слово sealed:
public sealed interface Limit permits Limited, Unlimited{
int max();
default boolean isUnlimited() {
return Unlimited.INSTANCE.equals(this);
}
static Limit unlimited() {
return Unlimited.INSTANCE;
}
static Limit of(int max) {
return new Limited(max);
}
Таким чином, ви тепер можете оголосити в будь-якому методі вашого репозиторію, що використовує scrolling, аргумент типу Limit:
Window<Order> findAllBy(ScrollPosition position, Limit limit);
І передати потрібну вам максимальну кількість елементів:
orders = orderRepository.findAllBy(ScrollPosition.offset(), Limit.of(10));
Використання в production
У Paypal застосовується page-based pagination, де ви передаєте параметр page (індекс сторінки) та page_index (кількість елементів на сторінці).
Cisco використовує offset-based pagination у своєму API, де ви передаєте параметри offset та limit.
Twitter застосовує token-based paginaton, де ви передаєте параметр pagination_token, а у відповіді отримуєте поля next_token та prev_token для навігації. А ось для Ads API використовується cursor-based pagination, де ви передаєте параметр cursor.
Attlassian використовує offset-based pagination, де ви передаєте параметри startAt (індекс елемента) і maxResults.
Slack називає page-based pagination архаїчною (хоча і підтримує її), але для більшості операцій застосовує все-таки cursor-based pagination, де ви передаєте в запиті cursor і limit параметри.
LinkedIn використовує offset-based pagination, де ви можете вказати параметри start (індекс елемента) і count (кількість елементів).
Github використовує page-based pagination, де ви використовуєте у запиті параметри per_page (кількість елементів на сторінці) та page (індекс сторінки).
Google Play використовує token-based pagination, де кожен запит повертає вам токени для переходу на наступну або попередні сторінки.
Stripe використовує cursor-based pagination, де в API ви вказуєте параметри limit (кількість елементів) і опціонально starting_after, який дорівнює ідентифікатору першого об’єкта, який ви хочете отримати. Або ви можете вказати параметр ending_before і це буде ідентифікатором останнього об’єкта, який ви хочете отримати.
IBM Cloud API підтримує offset- та token-based pagination.
Facebook підтримує кілька типів pagination, починаючи з cursor-based, де курсор — це рядок, що генерується, який посилається на певний елемент з результату. Якщо елемент буде видалено, курсор стає невалідним і більше його не можна використовувати. У відповідь від сервера будуть посилання, які дозволять перейти на попередню або наступну сторінку:
{
"data": [
...
],
"paging": {
"cursors": {
"after": "MTAxNTExOTQ1MjAwNzI5NDE=",
"before": "NDMyNzQyODI3OTQw"
},
"previous": "https://graph.facebook.com/{your-user-id}/albums?limit=25&before=NDMyNzQyODI3OTQw"
"next": "https://graph.facebook.com/{your-user-id}/albums?limit=25&after=MTAxNTExOTQ1MjAwNzI5NDE="
}
}
Також тут підтримується tme-based pagination, де дані відсортовані за часом створення, а параметром буде кількість Unix секунд:
{
"data": [
...
],
"paging": {
"previous": "https://graph.facebook.com/{your-user-id}/feed?limit=25&since=1364849754",
"next": "https://graph.facebook.com/{your-user-id}/feed?limit=25&until=1364587774"
}
}
Якщо вам не підходять ці два підходи, то третій варіант — offset-based pagination, де ви передаєте запит параметри offset і limit.
Висновки
Server-side pagination — одна з найпопулярніших технік у реалізації REST API, особливо коли це стосується пошуку та великих обсягів даних. Практично всі великі соцмережі реалізують такий підхід, але у різний спосіб.
З точки зору REST API та його використання клієнтами посторінковий висновок може бути page-, offset, cursor-і token-based pagination. З точки зору реалізації ви використовуєте або offset-або keyset-based pagination. Перший варіант найпростіший (з точки зору реалізації та використання), але вимагає більшого споживання ресурсів на сервері.
Другий варіант ефективніший, але не дозволяє перейти на випадкову сторінку або дізнатися загальну кількість сторінок. У принципі ви можете реалізувати обидва підходи, а ваші клієнти будуть вибирати один з них, виходячи зі своїх потреб і вимог.
16 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів