Використання та реалізація server-side pagination. Частина 1
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
Всім привіт. Я Сергій Моренець, розробник, викладач, спікер і технічний письменник хочу поділитися з вами своїм досвідом роботи з такою цікавою темою, як посторінковий вивід даних у застосунках, що використовують ORM-технології. Він застосовується практично в будь-якому застосунку, що має UI, але не завжди застосовується раціонально чи оптимально.
Я ж хочу детально розібрати цю тему на прикладі декількох ORM-технологій, які використовуються в Java-застосунках. Також я розберу основні типи pagination, особливості їх застосування та способи реалізації. Ну, і за останні роки у нас накопичилося достатньо досвіду роботи з таким підходом, і ми розглядаємо ці технології на тренінгах з Spring MVC та Spring Data. Сподіваюся, що ця стаття буде корисною для всіх, хто займається роботою з базами даних та ORM-системами.
Що таке посторінковий вивід даних
Думаю, що pagination відома і знайома будь-якому користувачеві інтернету, не обов’язково програмісту:
Якщо даних так багато, що вони не вміщаються на екрані, для зручності користувача ми розбиваємо їх на групи (сторінки) однакового розміру і виводимо тільки поточну (активну) сторінку. При цьому:
- Користувач повинен мати можливість навігації та переходу на інші сторінки (у тому числі і на наступну/ попередню).
- Має бути можливість побачити загальну кількість сторінок.
- Опціонально можна запропонувати користувачеві змінювати кількість записів на сторінці.
Для такого відображення зазвичай використовують спеціальний компонент UI, званий Paginator. Хоча на багатьох сучасних сайтах він відсутній, нові дані автоматично підвантажуються в процесі скролінгу поточної вебсторінки. Для реалізації такого компонента зазвичай застосовують два підходи:
- У разі клієнтського pagination сервер віддає всі дані клієнту, який сам розбиває їх на сторінки. Цей варіант підходить при невеликій кількості даних і дуже популярний через його простоту реалізації як на бекенді, так і на фронтенді. Але клієнтський застосунок повинен буде зберігати в пам’яті всі сторінки, навіть ті, котрі зараз не відображаються.
- При серверному pagination клієнт запитує дані лише однієї (поточної) сторінки. Це дозволяє мінімізувати навантаження на БД та мережевий трафік, але ускладнює реалізацію та комунікацію між клієнтом та сервером.
Про другий варіант і йтиметься у цій статті. Але перш, ніж приступити, слід визначитися з тим, що таке серверний pagination. Якщо це типове рішення якоїсь технічної проблеми, то за визначенням це design pattern. Дивно, але тут немає якоїсь спільної думки. Мартін Фаулер у своїй статті про Enterprise design patterns не згадує його. В багатьох статтях посторінковий вивід взагалі характеризується як «техніка» або «best practice», з чим можна погодитися. Але якогось офіційного статусу як design pattern я не зустрічав.
Отже, нам потрібно створити на бекенді API, який зможе видавати дані посторінково. Як це реалізувати? Таке рішення можна розбити на дві складові:
- створення API;
- реалізація завантаження даних із зовнішнього сховища (бази даних).
При цьому в силу його популярності скоріше за все будуть використовуватися RESTful вебсервіси, які накладають певні обмеження, зокрема, stateless характер взаємодії клієнта і сервера.
Використовуємо зміщення елементів
Найпростіший варіант — передача як параметри запиту індексу поточної сторінки та кількості записів на сторінці:
GET /orders?page_index=10&page_size=20
Такий підхід називається page-based pagination. Сервер в результаті формує запит до БД та завантажує лише вказану сторінку (frame). Оскільки перший елемент будь-якої сторінки має зміщення (offset) щодо початку загального списку даних, то можна використовувати інший підхід, передаючи зміщення (або індекс) першого елемента, який ми хочемо отримати:
GET /orders?offset=100&limit=20
Такий підхід називає offset-based pagination. Дуже важливо розуміти, що їхня відмінність тільки в API для клієнтських запитів, реалізація буде ідентична. У будь-якому випадку всі підходи, що розглядаються, поєднує те, що ми обов’язково передаємо кількість потрібних нам елементів, а відмінність буде в тому, як передавати позицію (або індекс) першого елемента.
Перейдемо до реалізації. Ми не будемо розглядати JDBC, тому що це досить низько-рівневий підхід, який зараз мало використовується. Почнемо з JPA (або Jakarta Persistence). У ньому відсутня пряма підтримка pagination, але є інтерфейс Query, де з самого початку присутні два дуже корисні методи:
- setMaxResults — вказує максимальну кількість записів для повернення (за замовчуванням Integer.MAX_VALUE);
- setFirstResult — вказує позицію (або усунення) першого запису, який потрібно повернути (за замовчуванням нуль).
Щоб розібратися, як це працює, перевіримо роботу JPA на практиці. Створимо запис PageRequest:
public record PageRequest(int page, int size) {}
та утилітний метод findPageable:
public <T> List<T> findPageable(String jpql, PageRequest page, Class<T> clz) {
TypedQuery<T> query = em.createQuery(jpql, clz);
query.setFirstResult(page.page() * page.size());
query.setMaxResults(page.size());
return query.getResultList();
}
Якщо виконати цей запит, він призведе до такого SQL, який буде згенерований нашим JPA-провайдером (в цьому випадку Hibernate 6):
select * from ORDERS o1_0 offset ? rows fetch first ? rows only
Тут є два цікаві ключові слова:
- offset — вказує усунення першого запису, яку сервер БД повинен повернути з того результату, яку він отримав. Тобто за фактом це аналог методу skip у Streams API, оскільки сервер має проігнорувати перші N записів із результату;
- rows fetch first — цей набір операторів може деяких здивувати, оскільки вони звикли до ключового слова limit. Понад те, ранні версії JPA/Hibernate використовували limit, а не fetch. У чому між ними різниця? Хоча зустрічається пояснення, що вони еквівалентні, але це зовсім так. По-перше, limit не входить до стандарту SQL, а fetch увійшов до SQL 2008. Крім того, не гарантується, що з limit будуть повернуті перші N записів результату (хоча швидше за все так і буде). Тому грамотніше використовувати fetch, тим більше що він вже підтримується такими СУБД як Oracle, Postgres, DB2, SQL Server, H2 і Derby.
Тут може виникнути закономірне питання. А що, якщо поточний сервер БД не підтримує конструкцію fetch first (як наприклад, MySQL)? Чи він взагалі не підтримує pagination (теоретично таке може бути)?
Розробники Hibernate підстрахувалися та створили спеціальний внутрішній інтерфейс LimitHandler:
public interface LimitHandler {
boolean supportsLimit();
boolean supportsOffset();
boolean supportsLimitOffset();
У Hibernate використовується концепція діалект-класів, які інформують Hibernate про можливості використовуваної СУБД. У кожному з них вказується та реалізація LimitHander, яка властива цій базі даних. Для MySQLDialect буде додано оператор LIMIT, для Postgres — FETCH FIRST, а ось для Sybase — TOP.
Здавалося б, все легко і просто, але лише на перший погляд. Якщо ми використовуємо SQL без додаткових налаштувань, він працюватиме досить швидко, оскільки дані завантажуватимуться у порядку, де вони перебувають у таблиці.
Уявімо реалістичнішу ситуацію, коли у нас використовується сортування або фільтрація:
SELECT * FROM ORDERS WHERE state='pending' ORDER BY createdAt DESC
У такому разі щоразу, коли ми запитуємо нову сторінку, сервер БД буде змушений виконати весь запит, отримати всі записи, відфільтрувати і відсортувати дані і тільки потім знайти потрібні елементи за індексом. У разі таблиці з мільйонами записів це призведе до дуже великого навантаження на базу даних.
Можлива інша проблема. Якщо ми отримали якусь сторінку, потім додали новий запис на цій сторінці, то при переході на нову сторінку ми отримаємо дублікати, які ми вже бачили. Аналогічно з видаленням елементів.
Є й інші проблеми. Згадаймо, що нам потрібно обов’язково повернути клієнту у першому запиті загальну кількість елементів, тому для відповіді можна використовувати такий запис:
public record PageContent<T>(List<T> rows, int totalElements) {}
Але як отримати цю загальну кількість? Якщо ми хочемо створити загальне рішення, то можна взяти оригінальний SQL-запит і використовувати на початку COUNT. Якщо спочатку у нас було:
SELECT * FROM ORDERS WHERE state='pending' ORDER BY createdAt DESC
то тепер буде:
SELECT COUNT(*) FROM ORDERS WHERE state='pending' ORDER BY createdAt DESC
Якщо придивитися до цього запиту, можна зрозуміти, що в ньому не потрібен ORDER BY, який не впливає на результат, і можна спростити запит як:
SELECT COUNT(*) FROM ORDERS WHERE state='pending'
Складніше, якщо ви використовуєте joins між таблицями для eager fetching дочірніх сутностей:
SELECT * FROM PRODUCTS p left join ORDERS o ON p.ID=o.PRODUCT_ID;
У такому разі просте використання COUNT поверне кількість замовлень (а не товарів):
SELECT COUNT(*) FROM PRODUCTS p left join ORDERS o ON p.ID=o.PRODUCT_ID;
Тому потрібно видалити join та використовувати лише таблицю PRODUCTS:
SELECT COUNT(*) FROM PRODUCTS;
Але і тут ховається каверза. Якщо ми шукаємо тільки ті товари, на які є замовлення, потрібно використовувати inner join, а в COUNT вказати не *, а стовпець з таблиці PRODUCTS:
SELECT COUNT(p.id) FROM PRODUCTS p inner join ORDERS o ON p.ID=o.PRODUCT_ID;
Таким чином, для загального випадку потрібно написати спеціальний парсер, який перетворюватиме загальний SQL запит на COUNT-запит, а це потребує додаткового ресурсів та хорошого покриття тестами. Але вихід є, якщо ви використовуватимете не чистий JPA, а Spring Data JPA.
Pagination в Spring Data JPA
У проєктах Spring Data базовим елементом є маркер-інтерфейс Repository, а його спадкоємцем — PagingAndSortingRepository:
@NoRepositoryBean
public interface PagingAndSortingRepository<T, ID> extends Repository<T, ID> {
у якому вже є повноцінна підтримка pagination:
Page<T> findAll(Pageable pageable);
Pageable — це базовий інтерфейс для page-based pagination, у якого головна реалізація — клас PageRequest, у якому ви вказуєте всю необхідну інформацію для pagination: індекс сторінки, розмір сторінки та опціональне сортування. А Page — це об’єкт-контейнер, що містить результат (у вигляді списку) і загальна кількість записів.
Тепер для того, щоб використовувати цей метод, потрібно лише створити об’єкт Pag-eRequest:
PageRequest pageRequest = PageRequest.of(4, 5);
І передати його в метод findAll:
Page<Product> productPage = productRepository.findAll(pageRequest);
Цей код створить і викликає два SQL-запити (загальний та COUNT). Складніше, якщо ви вказуєте ваш власний JPQL запит для такого методу:
@Override
@Query(value = "SELECT p FROM Product p left join fetch p.orders "
+ "WHERE p.active=true")
Page<Product> findAll(Pageable page);
До Spring Data 3.x такий запит викликав помилку:
Caused by: org.hibernate.QueryException: query specified join fetching, but the owner of the fetched association was not present in the select list
[select count(p) FROM model.Product p left join fetch p.orders WHERE p.active=true]
І доводилося явно вказувати COUNT запит в окремому атрибуті:
@Query(value = "SELECT p FROM Product p left join fetch p.orders "
+ "WHERE p.active=true", countQuery = "SELECT COUNT(p) FROM Product p WHERE p.active=true")
Але в Spring Data JPA 3 з’явився спеціальний JPQL-парсер, який може сам правильно згенерувати COUNT-запит.
Spring інженери виявили винахідливість при реалізації pagination. Якщо сервер поверне відповідь, де кількість елементів менше, ніж розмір сторінки, це означає, що отримана остання сторінка. І виклик COUNT зроблено не буде, а загальна кількість елементів буде розрахована як offset + кількість елементів на сторінці.
У Spring Data JPA, як і в будь-яких налаштуваннях, є і свої накладні витрати. Тепер будь-який виклик findAll призводить до двох SQL-запитів, навіть якщо нам це не потрібно. Коли ми використовували JPA, ми самі контролювали, що і як ми викликаємо. Тому тут можна або змиритися з цим, або використовувати як результат List або об’єкт Slice:
Slice<Product> findBy(Pageable page);
Інтерфейс Slice схожий на Page (більше того, Slice це базовий інтерфейс для Page), але тут немає загальної кількості елементів, тому тепер COUNT-запит не виконуватиметься. Але як тепер передати загальну кількість клієнту? Адже один раз (вперше) це потрібно зробити. Можна або написати свій власний метод, або скористатися Spring Data JPA і навіть закешувати його, щоб він не звертався до бази щоразу:
@Cacheable
@Query("SELECT p FROM Product p join fetch p.orders")
Page<Product> geTotalElements(Pageable page);
А що з native (SQL) запитами?
@Query(value = "SELECT * FROM PRODUCT", nativeQuery = true)
Page<Product> findAll(Pageable page);
Тут не все так просто. Ні Hibernate, ні Spring Data JPA не можуть парсити SQL запити, тому як COUNT тут буде виконано такий запит:
SELECT * FROM PRODUCT
А потім Spring просто підрахує кількість елементів у його результаті. Зрозуміло, це абсолютно неефективно, тому слід явно вказати свій запит COUNT:
@Query(value = "SELECT * FROM PRODUCT", nativeQuery = true, countQuery = "SELECT COUNT(*) FROM PRODUCT")
Page<Product> findAll(Pageable page);
Клас PageRequest бiльш функціональний, ніж наш запис PageRequest, тому що там є методи next і previous:
@Override
public PageRequest next() {
return new PageRequest(getPageNumber() + 1, getPageSize(), getSort());
}
@Override
public PageRequest previous() {
return getPageNumber() == 0 ? this : new PageRequest(getPageNumber() - 1, getPageSize(), getSort());
}
Ці методи можуть стати в нагоді при генерації відповіді для клієнта. Зараз клієнт має сам знати про те, як надіслати посторінковий запит на сервер. Якщо ви чули про Hypermedia і використовували HATEOAS, то швидше за все дотримуєтеся іншого принципу, коли сервер відправляє клієнту не тільки дані, але й посилання на операції (включаючи навігацію).
Таким чином, коли клієнт відправляє початковий запит на сервер, а сервер підраховує, що не всі дані помістяться на поточну сторінку, він поверне клієнту додатково посилання для переходу на наступну сторінку:
"_links" : {
"next" : {
"href" : "http://localhost:8080/orders?limit=10&page=1"
}
}
Це дозволить нашому клієнту не прив’язуватись до особливостей API, а використовувати ті URI, які генерує сервер.
Висновки
Якщо підвести підсумки, то до плюсів offset-based pagination можна віднести простоту реалізації та можливість перейти на будь-яку сторінку за її індексом, а до недоліків:
- погана продуктивність для великих обсягів даних;
- можливість появи фантомних даних при видаленні/ вставці елементів.
Тому доцільно розібрати інші підходи, які не використовують абсолютне усунення елементів, ми це зробимо у другій частині.
Що стосується підтримки з боку Java-спільноти, то JPA надає тільки базові можливості, а ось Spring Data JPA (як і інші підпроєкти Spring Data) дозволяють організувати page-based pagination out-of-the-box з підтримкою сортування.
18 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів