Цель пагинации — избежать выборки больших объёмов данных, поскольку пользовательский интерфейс имеет ограниченное пространство просмотра, которое может быть использовано для отображения данных. Нет смысла отдавать 100 элементов, если пользователь посмотрит всего 10.
Фронту необходима метаинформация о страницах, такая как: номер текущей страницы, количество всех страниц, количество всех элементов и так далее. Всё это помогает построить элемент навигации по страницам.
История изменений
29.05.2023: Переход на Spring Boot 3. Добавил информацию и пример о Slice
.
Для примеров мы будем использовать демо проект, который содержит в себе один контроллер и один JPA репозиторий. В качестве базы данных будем использовать H2, которая создается в памяти. Hibernate будет пересоздавать схему при каждом запуске. А класс GeneratorPost
отвечает за наполнение базы тестовыми данными, после поднятия контекста спринга.
😸 Репозиторий с примерами: Struchkov Git | GitHub
Используемые версии
Java 17
Spring Boot: 3.1.0
В Spring такую пагинацию легко реализовать используя класс Page<T>
, который возвращает метод findAll(Pageable)
интерфейса PagingAndSortingRepository
. Этот интерфейс расширяет интерфейс JpaRepository
. Page<T>
уже содержит всю необходимую метаинформацию.
@RestController
@RequestMapping("api/post")
@RequiredArgsConstructor
public class PostController {
private final PostJpaRepository repository;
@GetMapping
public Page<Post> getAll(
@RequestParam("offset") Integer offset,
@RequestParam("limit") Integer limit
) {
return repository.findAll(PageRequest.of(offset, limit));
}
}
Используя @RequestParam
принимаем номер страницы (offset
) и количество элементов на странице (limit
). Эти данные мы передаем в статический метод PageRequest.of(offset, limit)
, который и создает объект пагинации Pageable
, который затем передается в метод findAll(Pageable)
. Сто́ит отметить, что PostJpaRepository
расширяет JpaRepository
.
В аннотации @RequestParam
можно использовать поле defaultValue
, чтобы задать значения пагинации по умолчанию.
@RequestParam(value = "offset", defaultValue = "0") Integer offset,
@RequestParam(value = "limit", defaultValue = "20") Integer limit
Также имеет смысл ограничить передаваемые параметры пагинации. О том, как реализовать такие ограничения, я писал в отдельной статье.
@RequestParam(value = "offset", defaultValue = "0") @Min(0) Integer offset,
@RequestParam(value = "limit", defaultValue = "20") @Min(1) @Max(100) Integer limit
Минимальное значение для offset
это 0, пользователь не может запросить -1 страницу. А для количества элементов на странице минимальное значение 1, нет смысла запрашивать 0 элементов.
Также мы не хотим отдавать большое количество элементов на одной странице, иначе в чем смысл пагинации, если пользователь сможет запросить одну страницу и указать размер страницы в 1_000_000 элементов.
Получим первую страницу из 3 элементов:
http://localhost:8080/api/post?offset=0&limit=3
HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 13 Nov 2022 09:45:08 GMT
Keep-Alive: timeout=60
Connection: keep-alive
{
"content": [ // наши объекты
{
"id": "15b48d7d-4bf2-4730-a196-cb927d5b6153",
"title": "Post 0",
"createOn": "2022-11-12T12:42:29.662314"
},
{
"id": "7d94c880-8da3-4c2a-9a88-c5ddce64b6df",
"title": "Post 1",
"createOn": "2022-11-12T12:43:29.662358"
},
{
"id": "05b56e0e-c223-481c-88ca-bc38901ab543",
"title": "Post 2",
"createOn": "2022-11-12T12:44:29.662376"
}
],
"pageable": { // данные пагинации, которые были переданны
"sort": { // использование сортировки
"empty": true,
"unsorted": true,
"sorted": false
},
"offset": 0, // номер страницы
"pageNumber": 0, // текущий номер страницы
"pageSize": 3, // количество элементов на странице
"paged": true,
"unpaged": false
},
"last": false, // является ли страница последней
"totalPages": 3334, //сколько всего страниц доступно
"totalElements": 10000, // сколько всего элементов доступно
"first": true, // является ли страница первой
"size": 3, // количество элементов на странице
"number": 0, // номер страницы
"sort": { // данные сортировки
"empty": true,
"unsorted": true,
"sorted": false
},
"numberOfElements": 3,
"empty": false // является ли страница пустой, без данных
}
Данных для фронта более чем достаточно, но чего нам это стоило? Включим логирование sql запросов и смотрим, какой запрос сгенерировал Hibernate:
Hibernate:
select
post0_.id as id1_0_,
post0_.title as title2_0_
from
post post0_ limit ? offset ?
Hibernate:
select
count(post0_.id) as col_0_0_
from
post post0_
Первый запрос использует операторы LIMIT
и OFFSET
для получения ограниченного набора записей (6-я строка). А второй запрос получает количество всех элементов в таблице. Этой информации достаточно, чтобы вычислить всю остальную метаинформацию.
Пагинация с помощью OFFSET
имеет ряд недостатков и проблемы с производительностью, которые я описал в отдельной статье👇
🤓 Архитектурный зануда
Прокидывание объекта Page
и Pageable
через все архитектурные слои приложения является довольно спорным решением, с точки зрения дизайна приложения.
Более правильно было бы создать свои аналоги Page
и Pageable
и на уровне репозитория перекладывать данные в эти свои объекты. Хотя на практике вы такое вряд ли увидите, но стоит об этом хотя бы задуматься.
Slice
вместо Page
В Spring Data JPA, Slice
является одним из возвращаемых типов, которые можно использовать в репозитории для постраничной выборки данных из базы данных. В отличие от Page
, Slice
загружает только данные для запрошенной страницы, без дополнительной нагрузки на базу данных для подсчета общего числа результатов, что делает его более производительным для больших наборов данных.
При этом Slice
возвращает информацию о том, что следующая/предыдущая страницы существуют, таким образом пагинация остается и ее реализовать проще, чем Keyset пагинацию.
Сортировка страницы
Очень часто пользователи хотят иметь возможность сортировать результаты выдачи. Метод PageRequest.of()
имеет перегрузку, которая позволяет передать также данные необходимые для сортировки:
@GetMapping("exampleSort")
public Page<Post> getAllAndSort(
@RequestParam("offset") Integer offset,
@RequestParam("limit") Integer limit,
@RequestParam("sort") String sortField
) {
return repository.findAll(
PageRequest.of(offset, limit, Sort.by(Sort.Direction.ASC, sortField))
);
}
Дополнительно к offset
и limit
передаем параметр sortField
, который является названием поля в Entity, а не названием столбца, по которому собираемся сортировать. Дополнительно можно задать направление сортировки.
### Получить вторую страницу из 3 элементов с сортировкой
GET http://localhost:8080/api/post/exampleSort?offset=1&limit=3&sort=createOn
Content-Type: application/json
Теперь к SQL запросу добавилась директива ORDER BY
:
Hibernate:
select
post0_.id as id1_0_,
post0_.create_on as create_o2_0_,
post0_.title as title3_0_
from
post post0_
order by
post0_.create_on asc limit ? offset ?
Как передавать параметр сортировки?
Я предпочитаю создавать специальный enum, в котором перечисляю доступные методы сортировки. Это позволяет ограничить возможности API теми вариантами, которые я считаю допустимыми.
Выглядит это так:
@Getter
@RequiredArgsConstructor
public enum PostSort {
ID_ASC(Sort.by(Sort.Direction.ASC, "id")),
ID_DESC(Sort.by(Sort.Direction.DESC, "id")),
DATE_ASC(Sort.by(Sort.Direction.ASC, "createOn"));
private final Sort sortValue;
}
@GetMapping("exampleEnumSort")
public Page<Post> getAllAndEnumSort(
@RequestParam("offset") Integer offset,
@RequestParam("limit") Integer limit,
@RequestParam("sort") PostSort sort
) {
return repository.findAll(
PageRequest.of(offset, limit, sort.getSortValue())
);
}
Дополнительно отмечу, что можно сортировать сразу по нескольким полям. Для этого используется метод Sort.by()
.
Sort.by(
Sort.Order.asc("title"),
Sort.Order.desc("createOn")
)
Кастомные JPA методы
JpaRepository
содержит несколько методов, которые возвращают Page
, а как быть с кастомными методами, которые вы пишите сами. Для них тоже возможно использовать пагинацию.
Генерируемые методы
Spring Data Jpa позволяет вам описать сигнатуру метода в интерфейсе, который расширяет JpaRepository
, после чего Spring сам создаст реализацию этого метода.
В таком методе тоже можно использовать пагинацию. Достаточно объявить возвращаемый тип метода Page
, а в параметры добавить Pageable
:
public interface PostRepository extends JpaRepository<Post, UUID> {
Page<Post> findAllByTitleLikeIgnoreCase(String titleLike, Pageable pageable);
}
Дальше Spring все сделает за вас.
JPQL
JPQL это абстракция Hibernate над SQL. Вместо таблиц и полей в описании запроса, вы оперируете своими объектами. В этом варианте запросов пагинация добавляется также легко:
public interface PostRepository extends JpaRepository<Post, UUID> {
@Query("SELECT p FROM Post p WHERE p.title like %:title%")
Page<Post> findAllByTitleJpql(@Param("title") String title, Pageable pageable);
}
Нативные запросы
Даже для нативных SQL запросов можно добавить пагинацию. Дополнительно можно указать параметр countQuery
аннотации @Query
. Он отвечает за запрос подсчета, который используется для поиска общего количества элементов на странице. Если этот параметр не указан, то будет выполнен запрос count()
для исходного запроса или запроса countProjection()
, если таковой имеется.
@Query(
nativeQuery = true,
value = "SELECT * FROM POST WHERE TITLE LIKE %?1%",
countQuery = "SELECT count(*) FROM POST WHERE TITLE LIKE %?1%"
)
Page<Post> findAllByTitleNative(String title, Pageable pageable);
Преобразование объекта
Объект Page
содержит в себе объекты Entity, но по правилам хорошего тона не стоит отдавать Entity из контроллера. Необходимо переложить данные в класс DTO, и отдавать уже его.
Это возможно сделать с помощью метода Page.map(Function)
. Метод похож на метод Stream.map(Function)
. Вы передаете туда реализацию функционального интерфейса Function
, в котором описываете, как переложить данные из одного объекта в другой.
Резюмирую
Теперь вы знаете, как реализовать постраничную навигацию в Spring Boot. Однако, не забывайте, что OFFSET пагинация имеет множество проблем, о которых вы можете прочитать в отдельной статье. Избавиться от этих проблем можно, используя KeySet Pagination.