Введение
В этой статье блога мы рассмотрим, как можно реализовать пагинацию с помощью hateoas, поддерживая при этом фильтрацию и сортировку в приложении Spring Boot. Сначала мы начнем с реализации фильтрации, затем итеративно создадим страницы, сортировку и, наконец, пагинацию с помощью Hateoas.
Технологический стек
Ниже приведен список языков, фреймворков и библиотек, используемых в этом учебнике
- Java 11
- Maven
- база данных h2 в памяти
- REST apis
- SpringBoot 2.6.6
- Spring Data jpa
- 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.
Введите данные вашего приложения и добавьте следующие зависимости
- Spring Web
- Spring Data JPA
- База данных H2
- 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 дополнительных файла
- Customer Model (extends RepresentationModel<>)
- 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, который мы создали с дополнительными параметрами.
Внимательно посмотрев на наш вывод, мы замечаем некоторую действительно полезную информацию.
- «_links» — этот ключ содержит объект со ссылками на первую, собственную, следующую и последнюю страницу. Эти ссылки могут быть использованы командой frontend для использования пагинации в пользовательском интерфейсе.
- «page» — здесь указывается количество элементов, общее количество элементов после фильтрации и сортировки, общее количество страниц и номер текущей страницы.
- «_embedded» — содержит список CustomerModels. Фактические данные, которые преобразуются из сущности в модель.
Заключение
Я потратил много времени на то, чтобы разобраться в этом, когда мне нужно было реализовать это в рабочем проекте, и я надеюсь, что это поможет кому-то, кому нужно сделать то же самое.
Проверьте ссылку на Github, поиграйте с ней и посмотрите, работает ли она в вашем проекте.
https://github.com/markbdsouza/hateoas-with-pagination
Не стесняйтесь оставлять комментарии, если у вас есть вопросы или вам нужна помощь.