Постраничная пагинация в SpringBoot

Разбираемся, как отдавать данные из БД порциями - страницами в SpringBoot. Такой подход метод называется постраничной пагинацией.

· 6 минуты на чтение
Постраничная пагинация в SpringBoot

Цель пагинации — избежать выборки больших объёмов данных, поскольку пользовательский интерфейс имеет ограниченное пространство просмотра, которое может быть использовано для отображения данных. Нет смысла отдавать 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> уже содержит всю необходимую метаинформацию.

import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController;
@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.

Struchkov Mark
Struchkov Mark
Задавайте вопросы, если что-то осталось не понятным👇