Перейти к основному содержимому

Разбивка на страницы REST весной

· 9 мин. чтения

1. Обзор

В этом руководстве основное внимание будет уделено реализации разбиения на страницы в REST API с использованием Spring MVC и Spring Data.

2. Страница как ресурс против страницы как представления

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

Обращение к самой странице как к ресурсу приводит к множеству проблем, таких как невозможность уникальной идентификации ресурсов между вызовами. Это, в сочетании с тем фактом, что на уровне постоянства страница является не собственно сущностью, а держателем, который создается при необходимости, делает выбор простым; страница является частью представления .

Следующий вопрос в дизайне разбиения на страницы в контексте REST — куда включать информацию о разбивке на страницы :

  • в пути URI: /foo/page/1
  • запрос URI: /foo?page=1

Имея в виду, что страница не является Resource , кодирование информации о странице в URI недопустимо.

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

3. Контроллер

Теперь о реализации. Контроллер Spring MVC для разбивки на страницы прост :

@GetMapping(params = { "page", "size" })
public List<Foo> findPaginated(@RequestParam("page") int page,
@RequestParam("size") int size, UriComponentsBuilder uriBuilder,
HttpServletResponse response) {
Page<Foo> resultPage = service.findPaginated(page, size);
if (page > resultPage.getTotalPages()) {
throw new MyResourceNotFoundException();
}
eventPublisher.publishEvent(new PaginatedResultsRetrievedEvent<Foo>(
Foo.class, uriBuilder, response, page, resultPage.getTotalPages(), size));

return resultPage.getContent();
}

В этом примере мы вводим два параметра запроса, размер и страницу, в метод контроллера через @RequestParam.

В качестве альтернативы мы могли бы использовать объект Pageable , который автоматически сопоставляет параметры страницы , размера и сортировки . Кроме того, объект PagingAndSortingRepository предоставляет готовые методы, поддерживающие использование Pageable в качестве параметра.

Мы также внедряем Http Response и UriComponentsBuilder , чтобы помочь с обнаружением, которое мы разделяем с помощью пользовательского события. Если это не является целью API, мы можем просто удалить пользовательское событие.

Наконец, обратите внимание, что в этой статье основное внимание уделяется только REST и веб-слою; чтобы углубиться в часть доступа к данным разбивки на страницы, мы можем прочитать эту статью о разбивке на страницы с помощью Spring Data.

4. Возможность обнаружения для разбиения на страницы REST

В рамках разбивки на страницы соблюдение ограничения HATEOAS для REST означает предоставление клиенту API возможности обнаруживать следующую и предыдущую страницы на основе текущей страницы в навигации. Для этой цели мы будем использовать HTTP- заголовок Link в сочетании с типами связи « следующий», « предыдущий», « первый » и « последний » .

В REST обнаруживаемость — сквозная проблема , применимая не только к конкретным операциям, но и к типам операций. Например, каждый раз, когда создается Ресурс, URI этого Ресурса должен быть доступен для обнаружения клиентом. Поскольку это требование актуально для создания ЛЮБОГО ресурса, мы рассмотрим его отдельно.

Мы разделим эти проблемы с помощью событий, как обсуждалось в предыдущей статье, посвященной возможности обнаружения службы REST. В случае разбиения на страницы событие PaginatedResultsRetrievedEvent запускается на уровне контроллера. Затем мы реализуем возможность обнаружения с помощью специального прослушивателя для этого события.

Короче говоря, слушатель проверит, позволяет ли навигация перейти на следующую , предыдущую , первую и последнюю страницы. Если это так, он добавит соответствующие URI в ответ в виде HTTP-заголовка Link .

Теперь пойдем шаг за шагом. UriComponentsBuilder , переданный из контроллера, содержит только базовый URL-адрес (хост, порт и контекстный путь). Поэтому нам придется добавить оставшиеся разделы:

void addLinkHeaderOnPagedResourceRetrieval(
UriComponentsBuilder uriBuilder, HttpServletResponse response,
Class clazz, int page, int totalPages, int size ){

String resourceName = clazz.getSimpleName().toString().toLowerCase();
uriBuilder.path( "/admin/" + resourceName );

// ...

}

Далее мы будем использовать StringJoiner для объединения каждой ссылки. Мы будем использовать uriBuilder для создания URI. Давайте посмотрим, как мы поступим со ссылкой на следующую страницу:

StringJoiner linkHeader = new StringJoiner(", ");
if (hasNextPage(page, totalPages)){
String uriForNextPage = constructNextPageUri(uriBuilder, page, size);
linkHeader.add(createLinkHeader(uriForNextPage, "next"));
}

Давайте посмотрим на логику методаstructNextPageUri :

String constructNextPageUri(UriComponentsBuilder uriBuilder, int page, int size) {
return uriBuilder.replaceQueryParam(PAGE, page + 1)
.replaceQueryParam("size", size)
.build()
.encode()
.toUriString();
}

Мы поступим аналогичным образом для остальных URI, которые мы хотим включить.

Наконец, мы добавим вывод в качестве заголовка ответа:

response.addHeader("Link", linkHeader.toString());

Обратите внимание, что для краткости включен только частичный пример кода, а полный код находится здесь .

5. Разбивка на страницы тестового вождения

Как основная логика разбиения на страницы, так и возможность обнаружения покрываются небольшими целенаправленными интеграционными тестами. Как и в предыдущей статье , мы будем использовать библиотеку с поддержкой REST для использования службы REST и проверки результатов.

Это несколько примеров интеграционных тестов разбиения на страницы; полный набор тестов можно найти в проекте GitHub (ссылка в конце статьи):

@Test
public void whenResourcesAreRetrievedPaged_then200IsReceived(){
Response response = RestAssured.get(paths.getFooURL() + "?page=0&size=2");

assertThat(response.getStatusCode(), is(200));
}
@Test
public void whenPageOfResourcesAreRetrievedOutOfBounds_then404IsReceived(){
String url = getFooURL() + "?page=" + randomNumeric(5) + "&size=2";
Response response = RestAssured.get.get(url);

assertThat(response.getStatusCode(), is(404));
}
@Test
public void givenResourcesExist_whenFirstPageIsRetrieved_thenPageContainsResources(){
createResource();
Response response = RestAssured.get(paths.getFooURL() + "?page=0&size=2");

assertFalse(response.body().as(List.class).isEmpty());
}

6. Тестирование открываемости разбиения на страницы

Проверить, может ли клиент обнаружить разбивку на страницы, относительно просто, хотя есть много вопросов, которые нужно охватить.

Тесты будут сосредоточены на позиции текущей страницы в навигации и различных URI, которые должны быть доступны для каждой позиции:

@Test
public void whenFirstPageOfResourcesAreRetrieved_thenSecondPageIsNext(){
Response response = RestAssured.get(getFooURL()+"?page=0&size=2");

String uriToNextPage = extractURIByRel(response.getHeader("Link"), "next");
assertEquals(getFooURL()+"?page=1&size=2", uriToNextPage);
}
@Test
public void whenFirstPageOfResourcesAreRetrieved_thenNoPreviousPage(){
Response response = RestAssured.get(getFooURL()+"?page=0&size=2");

String uriToPrevPage = extractURIByRel(response.getHeader("Link"), "prev");
assertNull(uriToPrevPage );
}
@Test
public void whenSecondPageOfResourcesAreRetrieved_thenFirstPageIsPrevious(){
Response response = RestAssured.get(getFooURL()+"?page=1&size=2");

String uriToPrevPage = extractURIByRel(response.getHeader("Link"), "prev");
assertEquals(getFooURL()+"?page=0&size=2", uriToPrevPage);
}
@Test
public void whenLastPageOfResourcesIsRetrieved_thenNoNextPageIsDiscoverable(){
Response first = RestAssured.get(getFooURL()+"?page=0&size=2");
String uriToLastPage = extractURIByRel(first.getHeader("Link"), "last");

Response response = RestAssured.get(uriToLastPage);

String uriToNextPage = extractURIByRel(response.getHeader("Link"), "next");
assertNull(uriToNextPage);
}

Обратите внимание, что полный низкоуровневый код для extractURIByRel, отвечающего за извлечение URI по отношению rel , находится здесь .

7. Получение всех ресурсов

Что касается той же темы разбивки на страницы и возможности обнаружения, необходимо сделать выбор, разрешено ли клиенту извлекать все Ресурсы в системе одновременно или клиент должен запрашивать их с разбивкой на страницы .

Если решено, что клиент не может получить все Ресурсы с помощью одного запроса и требуется разбиение на страницы, то для ответа на получение запроса доступно несколько вариантов. Один из вариантов — вернуть ошибку 404 ( Not Found ) и использовать заголовок Link , чтобы сделать первую страницу доступной для обнаружения:

Ссылка=<http://localhost:8080/rest/api/admin/foo?page=0&size=2>; rel="first", <http://localhost:8080/rest/api/admin/foo?page=103&size=2>; отн = «последний»

Другой вариант — вернуть перенаправление 303 (см. Другое ) на первую страницу. Более консервативным маршрутом было бы просто вернуть клиенту 405 ( метод не разрешен) для запроса GET.

8. Пейджинг REST с диапазоном HTTP-заголовков

Относительно другой способ реализации нумерации страниц — это работа с заголовками HTTP Range , Range , Content-Range , If-Range , Accept- Range и кодами состояния HTTP, 206 ( Partial Content ), 413 ( Request Entity Too Large ) и 416 ( запрошенный диапазон неудовлетворителен ).

Один из взглядов на этот подход заключается в том, что расширения диапазона HTTP не предназначены для разбиения на страницы, и ими должен управлять сервер, а не приложение. Реализация разбивки на страницы на основе расширений заголовка HTTP Range технически возможна, хотя и не так распространена, как реализация, обсуждаемая в этой статье.

9. Spring Data REST Разбивка на страницы

В Spring Data, если нам нужно вернуть несколько результатов из полного набора данных, мы можем использовать любой метод репозитория Pageable , так как он всегда будет возвращать страницу. Результаты будут возвращены на основе номера страницы, размера страницы и направления сортировки.

Spring Data REST автоматически распознает параметры URL, такие как страница, размер, сортировка и т. д.

Чтобы использовать методы подкачки любого репозитория, нам нужно расширить PagingAndSortingRepository:

public interface SubjectRepository extends PagingAndSortingRepository<Subject, Long>{}

Если мы вызовем http://localhost:8080/subjects, Spring автоматически добавит предложения по странице, размеру и параметрам сортировки с помощью API:

"_links" : {
"self" : {
"href" : "http://localhost:8080/subjects{?page,size,sort}",
"templated" : true
}
}

По умолчанию размер страницы равен 20, но мы можем изменить его, вызвав что-то вроде http://localhost:8080/subjects?page=10.

Если мы хотим реализовать пейджинг в нашем собственном пользовательском API репозитория, нам нужно передать дополнительный параметр Pageable и убедиться, что API возвращает страницу:

@RestResource(path = "nameContains")
public Page<Subject> findByNameContaining(@Param("name") String name, Pageable p);

Всякий раз, когда мы добавляем пользовательский API, конечная точка /search добавляется к сгенерированным ссылкам. Итак, если мы вызовем http://localhost:8080/subjects/search, мы увидим конечную точку с поддержкой разбиения на страницы:

"findByNameContaining" : {
"href" : "http://localhost:8080/subjects/search/nameContains{?name,page,size,sort}",
"templated" : true
}

Все API, реализующие PagingAndSortingRepository , будут возвращать Page. Если нам нужно вернуть список результатов со страницы, API getContent() страницы предоставляет список записей, полученных в результате API REST Spring Data .

Код в этом разделе доступен в проекте spring-data-rest .

10. Превратите список в страницу

Предположим, что у нас есть объект Pageable в качестве входных данных, но информация, которую нам нужно получить, содержится в списке, а не в PagingAndSortingRepository . В этих случаях нам может понадобиться преобразовать List в Page .

Например, представьте, что у нас есть список результатов службы SOAP :

List<Foo> list = getListOfFooFromSoapService();

Нам нужно получить доступ к списку в определенных позициях, указанных отправленным нам объектом Pageable . Итак, давайте определим начальный индекс:

int start = (int) pageable.getOffset();

И конечный индекс:

int end = (int) ((start + pageable.getPageSize()) > fooList.size() ? fooList.size()
: (start + pageable.getPageSize()));

Имея эти два места, мы можем создать страницу для получения списка элементов между ними:

Page<Foo> page 
= new PageImpl<Foo>(fooList.subList(start, end), pageable, fooList.size());

Вот и все! Теперь мы можем вернуть страницу как допустимый результат.

И обратите внимание, что если мы также хотим предоставить поддержку sorting , нам нужно отсортировать список перед его подлистингом .

11. Заключение

В этой статье показано, как реализовать разбиение на страницы в REST API с помощью Spring, и обсуждалось, как настроить и протестировать обнаруживаемость.

Если мы хотим углубиться в разбиение на страницы на уровне сохраняемости, мы можем ознакомиться с учебными пособиями по разбивке на страницы JPA или Hibernate .

Реализацию всех этих примеров и фрагментов кода можно найти в проекте GitHub — это проект на основе Maven, поэтому его должно быть легко импортировать и запускать как есть.