«1. Обзор
В этом руководстве основное внимание будет уделено реализации разбиения на страницы в REST API с использованием Spring MVC и Spring Data.
2. Страница как ресурс и страница как представление
Первый вопрос при проектировании нумерации страниц в контексте архитектуры RESTful заключается в том, считать ли страницу фактическим ресурсом или просто представлением ресурсов.
Обработка самой страницы как ресурса приводит к множеству проблем, таких как невозможность уникальной идентификации ресурсов между вызовами. Это, в сочетании с тем фактом, что на уровне постоянства страница является не собственно сущностью, а держателем, который создается при необходимости, делает выбор простым; страница является частью представления.
Следующий вопрос в дизайне разбивки на страницы в контексте REST заключается в том, куда включать информацию о разбивке по страницам:
-
в пути URI: /foo/page/1 запрос URI: /foo?page=1
Учитывая, что страница не является ресурсом, кодирование информации о странице в 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();
}
В этом примере мы вводим два параметра запроса, размер и страницу, в метод Controller через @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 по отношению, здесь. 7. Получение всех ресурсов. .
Если решено, что клиент не может получить все Ресурсы одним запросом и требуется разбиение на страницы, то для ответа на запрос доступно несколько вариантов. Один из вариантов — вернуть 404 (не найдено) и использовать заголовок ссылки, чтобы сделать первую страницу доступной для обнаружения:
Другой вариант — вернуть перенаправление 303 (см. «Другое») на первую страницу. Более консервативным маршрутом было бы просто вернуть клиенту 405 (метод не разрешен) для запроса GET.
8. Пейджинг REST с HTTP-заголовками Range
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>; rel=”last”
Относительно другой способ реализации разбивки на страницы — это работа с HTTP-заголовками Range, Range, Content-Range, If-Range, Accept-Range и кодами состояния HTTP, 206 (Частичное содержание), 413 (Слишком большой объект запроса) и 416 (Запрошенный диапазон неудовлетворителен).
Одно из представлений об этом подходе заключается в том, что расширения диапазона HTTP не предназначены для разбиения на страницы и ими должен управлять сервер, а не приложение. Реализация разбивки на страницы на основе расширений заголовка HTTP Range технически возможна, хотя и не так распространена, как реализация, обсуждаемая в этой статье.
9. Spring Data REST Pagination
В Spring Data, если нам нужно вернуть несколько результатов из полного набора данных, мы можем использовать любой метод репозитория Pageable, так как он всегда будет возвращать страницу. Результаты будут возвращены на основе номера страницы, размера страницы и направления сортировки.
Spring Data REST автоматически распознает параметры URL, такие как страница, размер, сортировка и т. д.
Чтобы использовать методы подкачки любого репозитория, нам нужно расширить PagingAndSortingRepository:
Если мы вызовем http://localhost: 8080/subjects, Spring автоматически добавляет страницу, размер, параметры сортировки с помощью API:
По умолчанию размер страницы равен 20, но мы можем изменить его, вызвав что-то вроде http://localhost:8080 /subjects?page=10.
public interface SubjectRepository extends PagingAndSortingRepository<Subject, Long>{}
Если мы хотим внедрить подкачку в наш собственный API пользовательского репозитория, нам нужно передать дополнительный параметр Pageable и убедиться, что API возвращает страницу:
"_links" : {
"self" : {
"href" : "http://localhost:8080/subjects{?page,size,sort}",
"templated" : true
}
}
Всякий раз, когда мы добавляем пользовательский API, конечная точка /search добавляется к сгенерированным ссылкам. Итак, если мы вызовем http://localhost:8080/subjects/search, мы увидим конечную точку с поддержкой разбиения на страницы:
Все API, реализующие PagingAndSortingRepository, будут возвращать страницу. Если нам нужно вернуть список результатов со страницы, API getContent() страницы предоставляет список записей, полученных в результате API REST Spring Data.
@RestResource(path = "nameContains")
public Page<Subject> findByNameContaining(@Param("name") String name, Pageable p);
Код в этом разделе доступен в проекте spring-data-rest.
"findByNameContaining" : {
"href" : "http://localhost:8080/subjects/search/nameContains{?name,page,size,sort}",
"templated" : true
}
10. Преобразование списка в страницу
Предположим, что у нас есть объект Pageable в качестве входных данных, но информация, которую нам нужно получить, содержится в списке, а не в PagingAndSortingRepository. В этих случаях нам может понадобиться преобразовать список в страницу.
Например, представьте, что у нас есть список результатов службы SOAP:
«
«Нам нужно получить доступ к списку в определенных позициях, указанных отправленным нам объектом Pageable. Итак, давайте определим начальный индекс:
List<Foo> list = getListOfFooFromSoapService();
И конечный индекс:
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());
И обратите внимание, что если мы также хотим предоставить поддержку сортировки, нам нужно отсортировать список перед его подлистингом.
11. Заключение
В этой статье показано, как реализовать разбивку на страницы в REST API с помощью Spring, и обсуждалось, как настроить и протестировать обнаруживаемость.
Если мы хотим углубиться в нумерацию страниц на уровне персистентности, мы можем обратиться к руководствам по пагинации JPA или Hibernate.
Реализацию всех этих примеров и фрагментов кода можно найти в проекте GitHub — это проект на основе Maven, поэтому его легко импортировать и запускать как есть.
«