Пагинация (с Hateoas), фильтрация и сортировка с Spring Boot и JPA

Введение

В этой статье блога мы рассмотрим, как можно реализовать пагинацию с помощью hateoas, поддерживая при этом фильтрацию и сортировку в приложении Spring Boot. Сначала мы начнем с реализации фильтрации, затем итеративно создадим страницы, сортировку и, наконец, пагинацию с помощью Hateoas.

Технологический стек

Ниже приведен список языков, фреймворков и библиотек, используемых в этом учебнике

  1. Java 11
  2. Maven
  3. база данных h2 в памяти
  4. REST apis
  5. SpringBoot 2.6.6
  6. Spring Data jpa
  7. Hateoas

Пример использования Hateoas с пагинацией и фильтрацией/сортировкой

При получении данных из таблицы вы, вероятно, реализуете свой объект ответа как List<Object>. Это прекрасно работает, если речь идет о небольшом наборе данных.
Когда у вас есть набор данных, который является «большим», вы не хотите отправлять данные клиенту все сразу. Представьте, что у вас в таблице 100 тысяч строк, и каждая строка имеет 10 столбцов. Если пользователь в определенный момент времени просматривает в пользовательском интерфейсе (наш клиент) только 50 строк, зачем отправлять 100 тысяч строк?
Если только это не специфическая функция экспорта, вы не должны отправлять пользователю все данные. Вместо этого вы должны отправить только подмножество данных.
В то же время мы не хотим ограничивать пользователя в просмотре всех данных. Если пользователь хочет добавить фильтр или отсортировать некоторые данные из этих 100 тысяч строк, он должен иметь возможность сделать это.
С точки зрения фронт-энда, если пользователь нажмет на следующую страницу, он выполнит еще один вызов бэкэнда, чтобы получить следующий набор данных.
Вот где пагинация + фильтрация + сортировка становятся очень полезными.
Hateoas также предоставляет ссылки на другие страницы, что очень полезно, как мы увидим.

Как будет выглядеть наш конечный результат

Прежде чем перейти к деталям, посмотрите на наш URL запроса и формат ответа, который мы в итоге получим. Мы создадим длинный URL, поскольку мы допускаем высокий уровень настройки.

URL запроса: http://localhost:8080/api/v4/customers?firstNameFilter=R&lastNameFilter=S&page=0&size=10&sortList=firstName&sortOrder=ASC.
Ответ:

{
   "_embedded": {
      "customerModelList": [
         {
         "id": 971,
         "customerId": "de6b8664-ba90-41fc-a9f4-da7d0b89c106",
         "firstName": "Rabi",
         "lastName": "Dufour"
         },
         {
         "id": 339,
         "customerId": "44b5c01d-c379-4f66-b8ed-0fda4837db4e",
         "firstName": "Rachelle",
         "lastName": "Fleischer"
         },
         {
         "id": 838,
         "customerId": "443b06fd-7160-4234-9102-93afb0f6d9ad",
         "firstName": "Rafaelia",
         "lastName": "Bladen"
         }
      ]
   },
   "_links": {
      "first": {
         "href": "http://localhost:8080/api/v4/customers?firstNameFilter=R&sortList=firstName&sortOrder=ASC&page=0&size=3&sort=firstName,asc"
      },
      "self": {
         "href": "http://localhost:8080/api/v4/customers?firstNameFilter=R&sortList=firstName&sortOrder=ASC&page=0&size=3&sort=firstName,asc"
      },
      "next": {
        "href": "http://localhost:8080/api/v4/customers?firstNameFilter=R&sortList=firstName&sortOrder=ASC&page=1&size=3&sort=firstName,asc"
      },
      "last": {
        "href": "http://localhost:8080/api/v4/customers?firstNameFilter=R&sortList=firstName&sortOrder=ASC&page=19&size=3&sort=firstName,asc"
      }
   },
   "page": {
      "size": 3,
      "totalElements": 60,
      "totalPages": 20,
      "number": 0
   }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Как вы можете видеть в этом случае, конечная точка предоставляет вам ссылки на то, как вы можете получить доступ к другим данным. Она также сообщает, сколько там данных, сколько страниц и элементов на странице и многое другое. Это очень полезно для любого клиента, обращающегося к нашему API, для просмотра данных.

Ссылка на Github

Весь код, рассмотренный ниже, доступен на Github
https://github.com/markbdsouza/hateoas-with-pagination

Создание базового приложения Spring Boot

Прежде чем перейти к собственно коду, давайте пройдемся по нашему базовому коду и установим простой контроллер, сервис, сущность и хранилище. То, что вы сделаете для любого приложения spring boot. Затем давайте также настроим базу данных и вставим некоторые данные.
ПРИМЕЧАНИЕ: Пропустите этот раздел, если вам просто нужно посмотреть, как выполняется реализация. Это только для тех, кто будет следить за развитием событий.

Создание базового приложения

Вы можете создать базовое приложение с помощью сайта start.spring.io.
Введите данные вашего приложения и добавьте следующие зависимости

  1. Spring Web
  2. Spring Data JPA
  3. База данных H2
  4. Spring HATEOASЭто автоматически создаст ваш pom.xml и папки проекта.

Создайте сущность и репозиторий

Мы создадим 1 сущность — клиент с 4 полями — id (генерируемая последовательность), customerId (уникальная строка), имя и фамилия. Мы также сохраним геттеры и сеттеры для инкапсуляции.

@Entity
@Table(name="customer")
public class Customer {
    @Id
    @GeneratedValue
    private Long id;
    @Column(nullable = false, unique = true)
    private String customerId;
    @Column(nullable = false, length = 50)
    private String firstName;
    @Column(nullable = false, length = 50)
    private String lastName;

    public Long getId() {
        return id;
    }
    public void setId(Long id) {
        this.id = id;
    }
    public String getCustomerId() {
        return customerId;
    }
    public void setCustomerId(String customerId) {
        this.customerId = customerId;
    }
    public String getFirstName() {
        return firstName;
    }
    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }
    public String getLastName() {
        return lastName;
    }
    public void setLastName(String lastName) {
        this.lastName = lastName;
    }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Создайте интерфейс repoistory для сущности. Мы обновим его позже.

@Repository
public interface CustomerRepository extends JpaRepository<Customer, Long> {}
Вход в полноэкранный режим Выход из полноэкранного режима

Создание службы

Теперь создайте простой служебный файл для получения данных

@Service
public class CustomerService {
    @Autowired
    private CustomerRepository customerRepository;

    public List<Customer> fetchCustomerDataAsList() {
        // Fetch all customers using findAll
        return customerRepository.findAll();
    }
}
Войдите в полноэкранный режим Выйти из полноэкранного режима

Создайте контроллер

Теперь создадим базовый контроллер с GET-запросом, который вызывает метод сервиса

@RestController
public class CustomerController {
    @Autowired
    private CustomerService customerService;
    /**
     * @return List of all customers
     */
    @GetMapping("/api/v0/customers")
    public List<Customer> fetchCustomersAsList() {
        return customerService.fetchCustomerDataAsList();
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь у нас есть весь наш базовый код. Давайте перейдем к настройке нашей базы данных и вставке некоторых данных.

Настройка нашей базы данных

Обновите application.properties для отображения нашей базы данных H2 DB

spring.h2.console.enabled=true
spring.h2.console.settings.web-allow-others=true
spring.datasource.username=sa
spring.datasource.password=
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.initialization-mode=always
spring.datasource.initialize=true
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update
Войдите в полноэкранный режим Выйдите из полноэкранного режима

Теперь, когда наша БД настроена, мы можем использовать автоматическую конфигурацию springboot для запуска скриптов на нашей БД при запуске приложения.
Создайте файл schema.sql.

CREATE TABLE customer (
   id INT NOT NULL,
   first_name VARCHAR(100) NOT NULL,
   last_name VARCHAR(100) NOT NULL,
   customer_id VARCHAR(100) NOT NULL
);
Войдите в полноэкранный режим Выйти из полноэкранного режима

И создайте файл data.sql.

insert into customer values(1,'Dorine','McGrouther','6252fcab-a17e-4af4-aa70-0fda826e67cf');
insert into customer values(2,'Gerianna','Capstack','f787ce02-06b7-4fc6-be83-408c652e924b');
insert into customer values(3,'Rosemarie','Salerno','4ac787e6-2534-43ea-a86e-16957b7410a2');
Войти в полноэкранный режим Выйти из полноэкранного режима

Скрипт в репозитории Github имеет около 1000 клиентов в файле.
Каждый раз при запуске приложения, поскольку мы используем базу данных h2, будет выполняться скрипт schema.sql, а затем скрипт data.sql, который настроит среду нашей базы данных.

Тестируем наше базовое приложение

Чтобы протестировать его, запустите приложение spring boot в IDE, используйте браузер и введите URL-адрес


Если вы использовали сценарий вставки в хранилище, то в этом выводе JSON вы получите 1000 записей.

Реализация фильтрации

Теперь давайте сначала реализуем базовую фильтрацию в нашем коде. Мы позволим пользователю фильтровать по двум полям — FirstName и/или LastName. Для этого мы создадим новую конечную точку и передадим эти 2 переменные в качестве параметров запроса со значением по умолчанию «».

Обновление хранилища

Добавьте приведенный ниже запрос к нашему интерфейсу CustomerRepository

     String FILTER_CUSTOMERS_ON_FIRST_NAME_AND_LAST_NAME_QUERY = "select b from Customer b where UPPER(b.firstName) like CONCAT('%',UPPER(?1),'%') and UPPER(b.lastName) like CONCAT('%',UPPER(?2),'%')";

    @Query(FILTER_CUSTOMERS_ON_FIRST_NAME_AND_LAST_NAME_QUERY)
    List<Customer> findByFirstNameLikeAndLastNameLike(String firstNameFilter, String lastNameFilter);
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы написали пользовательский запрос, который будет фильтровать на основе имени и фамилии.
Теперь все, что нам нужно сделать, это вызвать его из нашего сервиса.

Обновление службы

Добавьте новый метод в наш сервис

    public List<Customer> fetchFilteredCustomerDataAsList(String firstNameFilter, String lastNameFilter) {
        // Apply the filter for firstName and lastName
        return customerRepository.findByFirstNameLikeAndLastNameLike(firstNameFilter, lastNameFilter);
    }
Вход в полноэкранный режим Выйти из полноэкранного режима

Обратите внимание, что мы по-прежнему возвращаем List<Customer>. Однако это отфильтрованный список.

Обновление контроллера

Добавьте новую конечную точку, которая принимает 2 поля в качестве условий фильтрации

    /**
     * @param firstNameFilter Filter for the first Name if required
     * @param lastNameFilter  Filter for the last Name if required
     * @return List of filtered customers
     */
    @GetMapping("/api/v1/customers")
    public List<Customer> fetchCustomersAsFilteredList(@RequestParam(defaultValue = "") String firstNameFilter,
                                                       @RequestParam(defaultValue = "") String lastNameFilter) {
        return customerService.fetchFilteredCustomerDataAsList(firstNameFilter, lastNameFilter);
    }
Войти в полноэкранный режим Выйти из полноэкранного режима

Протестируйте это

Запустите приложение и перейдите по URL v1, который мы создали.

Попробуйте это с разными фильтрами. Вместо всех 1000 клиентов мы получим только отфильтрованный список клиентов.

В этом случае есть только 2 клиента с именем, содержащим «ur» и фамилией, содержащей «as».

Реализация страницы с фильтрацией

Page — это обычный интерфейс, используемый для получения данных в формате страницы из нашей базы данных. Хотя такой формат не обязательно содержит необходимую информацию, это все равно ограниченный набор данных, и мы можем применить к нему нашу логику фильтрации.

Обновление хранилища

Добавьте следующий запрос в интерфейс CustomerRepository

    @Query(FILTER_CUSTOMERS_ON_FIRST_NAME_AND_LAST_NAME_QUERY)
    Page<Customer> findByFirstNameLikeAndLastNameLike(String firstNameFilter, String lastNameFilter, Pageable pageable);
Войдите в полноэкранный режим Выйти из полноэкранного режима

Единственное отличие заключается в том, что мы также передаем объект pageable с фильтрами и возвращаем Page<Customer> вместо List<Customer>.

Обновление сервиса

Добавим новый метод в наш сервис

    public Page<Customer> fetchCustomerDataAsPageWithFiltering(String firstNameFilter, String lastNameFilter, int page, int size) {
        // create Pageable object using the page and size
        Pageable pageable = PageRequest.of(page, size);
        // fetch the page object by additionally passing pageable with the filters
        return customerRepository.findByFirstNameLikeAndLastNameLike(firstNameFilter, lastNameFilter, pageable);
    }
Вход в полноэкранный режим Выйти из полноэкранного режима

Здесь нам нужны 2 дополнительных ввода — страница и размер. Страница указывает, какую страницу мы хотим получить из БД. А size указывает на количество объектов, которые мы хотим получить на каждой странице.
Примечание: страница начинается с 0, поэтому будьте внимательны.
Так, если имеется 1000 объектов, мы зададим страницу 2 и размер 50. Мы получим третий набор из 101-150 объектов.

Обновление контроллера

Добавьте новую конечную точку, которая принимает 2 поля в качестве условий фильтрации и 2 поля для функциональности страницы

    /**
     * @param firstNameFilter Filter for the first Name if required
     * @param lastNameFilter  Filter for the last Name if required
     * @param page            number of the page returned
     * @param size            number of entries in each page
     * @return Page object with customers after filtering
     */
    @GetMapping("/api/v2/customers")
    public Page<Customer> fetchCustomersWithPageInterface(@RequestParam(defaultValue = "") String firstNameFilter,
                                                          @RequestParam(defaultValue = "") String lastNameFilter,
                                                          @RequestParam(defaultValue = "0") int page,
                                                          @RequestParam(defaultValue = "30") int size) {
        return customerService.fetchCustomerDataAsPageWithFiltering(firstNameFilter, lastNameFilter, page, size);
    }
Войти в полноэкранный режим Выйти из полноэкранного режима

Обратите внимание, что мы сохранили страницу по умолчанию равной 0 и размер 30.

Протестируйте это

Запустите приложение и перейдите по URL v2, который мы создали с дополнительными параметрами.


Мы видим, что отображается больше информации, но она не кажется слишком важной. При необходимости вы можете выполнить некоторое пользовательское кодирование для преобразования формата.

Реализация страницы с фильтрацией и сортировкой

Теперь мы внедрим сортировку в вышеприведенную логику.

Обновление хранилища

Обновление хранилища не требуется. Мы повторно используем предыдущий метод, который возвращает Page<Customer>.

Обновление сервиса

Добавьте новый метод в наш сервис

    public Page<Customer> fetchCustomerDataAsPageWithFilteringAndSorting(String firstNameFilter, String lastNameFilter, int page, int size, List<String> sortList, String sortOrder) {
        // create Pageable object using the page, size and sort details
        Pageable pageable = PageRequest.of(page, size, Sort.by(createSortOrder(sortList, sortOrder)));
        // fetch the page object by additionally passing pageable with the filters
        return customerRepository.findByFirstNameLikeAndLastNameLike(firstNameFilter, lastNameFilter, pageable);
    }

    private List<Sort.Order> createSortOrder(List<String> sortList, String sortDirection) {
        List<Sort.Order> sorts = new ArrayList<>();
        Sort.Direction direction;
        for (String sort : sortList) {
            if (sortDirection != null) {
                direction = Sort.Direction.fromString(sortDirection);
            } else {
                direction = Sort.Direction.DESC;
            }
            sorts.add(new Sort.Order(direction, sort));
        }
        return sorts;
    }
Вход в полноэкранный режим Выйти из полноэкранного режима

У нас есть 2 метода. Один публичный метод, который мы будем вызывать из нашего контроллера и который имеет 2 дополнительных входа — sortList и sortOrder.
SortList принимает список строк, по которым нам нужно отсортировать, а sortOrder принимает ASC или DESC.
Второй частный метод создает объект списка Sort.Order, который используется при создании объекта Page.

Обновление контроллера

Добавьте новую конечную точку, которая принимает 2 поля в качестве условий фильтрации, 2 поля для функциональности страницы и 2 для функциональности сортировки

        /**
     * @param firstNameFilter Filter for the first Name if required
     * @param lastNameFilter  Filter for the last Name if required
     * @param page            number of the page returned
     * @param size            number of entries in each page
     * @param sortList        list of columns to sort on
     * @param sortOrder       sort order. Can be ASC or DESC
     * @return Page object with customers after filtering and sorting
     */
    @GetMapping("/api/v3/customers")
    public Page<Customer> fetchCustomersWithPageInterfaceAndSorted(@RequestParam(defaultValue = "") String firstNameFilter,
                                                                   @RequestParam(defaultValue = "") String lastNameFilter,
                                                                   @RequestParam(defaultValue = "0") int page,
                                                                   @RequestParam(defaultValue = "30") int size,
                                                                   @RequestParam(defaultValue = "") List<String> sortList,
                                                                   @RequestParam(defaultValue = "DESC") Sort.Direction sortOrder) {
        return customerService.fetchCustomerDataAsPageWithFilteringAndSorting(firstNameFilter, lastNameFilter, page, size, sortList, sortOrder.toString());
    }

Вход в полноэкранный режим Выйти из полноэкранного режима

Протестируйте это

Запустите приложение и перейдите по URL v3, который мы создали с дополнительными параметрами.


Единственное преимущество по сравнению с предыдущей версией — это то, что теперь мы можем сортировать данные.

Реализация пагинации с помощью HATEOAS

Переходим к нашему последнему шагу — реализации hateoas с фильтрацией и сортировкой.

Обновление хранилища

Обновление хранилища не требуется. Мы повторно используем уже созданный метод, возвращающий Page<Customer>.

Обновление сервиса

Обновление не требуется. Мы повторно используем метод, возвращающий Page<Customer>.

Создание файлов для поддержки Hateoas

Теперь, чтобы использовать hateoas, мы преобразуем формат страницы в формат hateoas PagedModel. Для этого нам понадобятся 2 дополнительных файла

  1. Customer Model (extends RepresentationModel<>)
  2. Customer Model Assembler(extends RepresentationModelAssemblerSupport<,>)

Создание CustomerModel

Прежде всего, нам нужно создать модель клиента, которая расширяет ненавистную модель RepresentationModel, которая является частью тела ответа, который мы отправляем. Мы также создадим геттеры и сеттеры для переменных объекта.

/**
 * The CustomerModel class extends the Hateoas Representation Model and is required if we want to convert the Customer
 * Entity to a pagination format
 */
public class CustomerModel extends RepresentationModel<CustomerModel> {
    private Long id;
    private String customerId;
    private String firstName;
    private String lastName;

    public Long getId() {
        return id;
    }
    public void setId(Long id) {
        this.id = id;
    }
    public String getCustomerId() {
        return customerId;
    }
    public void setCustomerId(String customerId) {
        this.customerId = customerId;
    }
    public String getFirstName() {
        return firstName;
    }
    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }
    public String getLastName() {
        return lastName;
    }
    public void setLastName(String lastName) {
        this.lastName = lastName;
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Если мы не хотим, чтобы возвращался сгенерированный последовательностью идентификатор, мы можем удалить его из этого объекта.

Создание CustomerModelAssembler

Теперь нам нужен ассемблер, который расширяет RepresentationModelAssemblerSupport, и мы передаем в него объект from, который является нашей сущностью, и объект to, который является нашей вновь созданной моделью. Мы вынуждены переопределить метод toModel(), в котором мы копируем значения сущности в модель.

/**
 * This class extends RepresentationModelAssemblerSupport which is required for Pagination.
 * It converts the Customer Entity to the Customer Model and has the code for it
 */
@Component
public class CustomerModelAssembler extends RepresentationModelAssemblerSupport<Customer, CustomerModel> {
    public CustomerModelAssembler() {
        super(CustomerController.class, CustomerModel.class);
    }

    @Override
    public CustomerModel toModel(Customer entity) {
        CustomerModel model = new CustomerModel();
        // Both CustomerModel and Customer have the same property names. So copy the values from the Entity to the Model
        BeanUtils.copyProperties(entity, model);
        return model;
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Контроллер обновления

Требуется Autowire 2 компонента

    @Autowired
    private CustomerModelAssembler customerModelAssembler;

    @Autowired
    private PagedResourcesAssembler<Customer> pagedResourcesAssembler;
Вход в полноэкранный режим Выйти из полноэкранного режима

и добавьте новую конечную точку, которая будет вызывать страницу

    /**
     * @param firstNameFilter Filter for the first Name if required
     * @param lastNameFilter  Filter for the last Name if required
     * @param page            number of the page returned
     * @param size            number of entries in each page
     * @param sortList        list of columns to sort on
     * @param sortOrder       sort order. Can be ASC or DESC
     * @return PagedModel object in Hateoas with customers after filtering and sorting
     */
    @GetMapping("/api/v4/customers")
    public PagedModel<CustomerModel> fetchCustomersWithPagination(
            @RequestParam(defaultValue = "") String firstNameFilter,
            @RequestParam(defaultValue = "") String lastNameFilter,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "30") int size,
            @RequestParam(defaultValue = "") List<String> sortList,
            @RequestParam(defaultValue = "DESC") Sort.Direction sortOrder) {
        Page<Customer> customerPage = customerService.fetchCustomerDataAsPageWithFilteringAndSorting(firstNameFilter, lastNameFilter, page, size, sortList, sortOrder.toString());
        // Use the pagedResourcesAssembler and customerModelAssembler to convert data to PagedModel format
        return pagedResourcesAssembler.toModel(customerPage, customerModelAssembler);
    }
Войдите в полноэкранный режим Выход из полноэкранного режима

Обратите внимание, как мы используем автоподключаемые компоненты. Используя ассемблер, мы преобразуем Page<Customer> в PagedModel<Customer>, вызывая метод toModel().

Протестируйте это

Запустите приложение и перейдите по URL v4, который мы создали с дополнительными параметрами.


Внимательно посмотрев на наш вывод, мы замечаем некоторую действительно полезную информацию.

  1. «_links» — этот ключ содержит объект со ссылками на первую, собственную, следующую и последнюю страницу. Эти ссылки могут быть использованы командой frontend для использования пагинации в пользовательском интерфейсе.
  2. «page» — здесь указывается количество элементов, общее количество элементов после фильтрации и сортировки, общее количество страниц и номер текущей страницы.
  3. «_embedded» — содержит список CustomerModels. Фактические данные, которые преобразуются из сущности в модель.

Заключение

Я потратил много времени на то, чтобы разобраться в этом, когда мне нужно было реализовать это в рабочем проекте, и я надеюсь, что это поможет кому-то, кому нужно сделать то же самое.
Проверьте ссылку на Github, поиграйте с ней и посмотрите, работает ли она в вашем проекте.
https://github.com/markbdsouza/hateoas-with-pagination
Не стесняйтесь оставлять комментарии, если у вас есть вопросы или вам нужна помощь.

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *