1. 概述
本教程將重點介紹使用 Spring MVC 和 Spring Data 在 REST API 中實現分頁的實施方法。
2. 頁面作為資源與頁面作為表示
在 RESTful 架構中設計分頁時,首先要考慮 頁面是否是實際的資源,還是資源的表示。將頁面本身視為資源會帶來一系列問題,例如無法在調用之間唯一標識資源。 此外,在持久層中,頁面不是一個真正的實體,而是一個在必要時構建的持有者,這使得選擇變得簡單: 頁面是表示的一部分。
在 REST 中分頁設計中,下一個問題是 在何處包含分頁信息:
- 在 URI 路徑中:/foo/page/1URI 查詢:/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();
}
在這個例子中,我們通過@RequestParam注入兩個查詢參數,size和page,到控制器方法中。
或者,我們可以使用Pageable對象,它會自動映射page、size和sort參數。此外,PagingAndSortingRepository實體提供了內置的方法,支持使用Pageable作為參數。
我們還注入了 Http Response 和 UriComponentsBuilder,以幫助可發現性,並通過自定義事件進行解耦。如果這不是 API 的目標,則可以簡單地刪除自定義事件。
最後,請注意,本文的重點僅是 REST 和 Web 層;要深入瞭解分頁的數據訪問部分,可以查看有關使用 Spring Data 的分頁文章。
4. 發現性(Discoverability)對於REST分頁(Pagination)
在分頁的範圍內,滿足REST的HATEOAS約束意味着使API客户端能夠根據當前頁面在導航中發現next和previous頁面。為此,我們將使用Link HTTP Header,並結合“next,” “prev,” “first,” 和 “last” 鏈接關係類型。
在REST中,Discoverability 是一個跨越式問題,適用於不僅限於特定操作,還適用於操作類型。例如,每次創建資源時,該資源的 URI 應該由客户端發現。由於此要求適用於任何資源的創建,我們將單獨處理它。
我們將使用事件來解耦這些問題,正如我們在上一篇文章中討論的,該文章側重於REST服務的發現性。在分頁的案例中,事件PaginatedResultsRetrievedEvent在控制器層觸發。然後我們將使用此事件的自定義監聽器來實現發現性。
簡而言之,監聽器將檢查導航是否允許訪問next、previous、first和last頁。如果允許,它將添加相關的 URI 到響應中作為 ‘Link’ HTTP Header。
現在我們逐步進行。從控制器中傳遞的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。讓我們看看如何處理指向next頁的鏈接:
StringJoiner linkHeader = new StringJoiner(", ");
if (hasNextPage(page, totalPages)){
String uriForNextPage = constructNextPageUri(uriBuilder, page, size);
linkHeader.add(createLinkHeader(uriForNextPage, "next"));
}
讓我們看一下constructNextPageUri方法的邏輯:
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());
請注意,為了簡潔,僅包含部分代碼示例,完整的代碼可以在這裏找到:https://gist.github.com/1622997。
5. 試用分頁
分頁的主要邏輯和可發現性都由小型的、專注的集成測試覆蓋。 就像在上一篇文章中一樣,我們將使用 REST-assured 庫 來消費 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 的完整低級代碼,負責通過rel關係提取URI,在此
7. 獲取所有資源
在分頁和可發現性同一主題上,必須決定是否允許客户端一次性檢索系統中的所有資源,或者客户端必須按分頁方式請求它們
如果決定客户端不能通過單個請求檢索所有資源,並且需要分頁,則有幾種響應選項可供選擇。一種選項是返回 404 (未找到) 並使用 鏈接標頭以使第一頁可發現:
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”
另一種選項是返回重定向,303 (其他) 到第一頁。更保守的方案是簡單地向客户端返回 405 (方法不允許) 對 GET 請求。
8. 使用 Range HTTP 標頭進行分頁
實現分頁的一種相對不同的方法是使用 HTTP Range 標頭,Range、Content-Range、If-Range、Accept-Ranges,以及 HTTP 狀態碼,206 (部分內容)、413 (請求實體太大) 和 416 (請求範圍無法滿足)。
這種方法的觀點是,HTTP Range 擴展不應用於分頁,而是應由服務器管理,而不是由應用程序管理。雖然基於 HTTP Range 標頭擴展的實現在技術上是可行的,但不如本文中討論的實現普遍。
9. Spring Data REST 分頁
在 Spring Data 中,如果需要返回數據集中的少量結果,我們可以使用任何 Pageable 倉庫方法,因為它始終會返回一個 Page。結果將根據頁碼、頁面大小和排序方向返回。
Spring Data REST 自動識別 URL 參數,如 page, size, sort 等。
為了使用任何倉庫中的分頁方法,我們需要擴展 PagingAndSortingRepository:
public interface SubjectRepository extends PagingAndSortingRepository<Subject, Long>{}
如果調用 http://localhost:8080/subjects, Spring 會自動添加 page, size, sort 參數建議,與 API 一起:
"_links" : {
"self" : {
"href" : "http://localhost:8080/subjects{?page,size,sort}",
"templated" : true
}
}
默認情況下,頁面大小為 20,但我們可以通過調用類似於 http://localhost:8080/subjects?page=10. 的方式來更改它。
如果我們想將分頁實施到我們自己的自定義倉庫 API 中,則需要傳遞額外的 Pageable 參數並確保該 API 返回一個 Page:
@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
}
所有實現 PagingAndSortingRepository 的 API 都將返回一個 Page。 如果我們需要從 Page 中返回結果列表,則 getContent() API 提供作為 Spring Data REST API 檢索的記錄列表。
10. 將一個 列表 轉換為一個 頁面
假設我們有一個 Pageable 對象作為輸入,但我們需要檢索的信息包含在一個列表而不是一個 PagingAndSortingRepository 中。 在這些情況下,我們可能需要 將一個 列表 轉換為一個 頁面。
例如,想象一下我們有一個來自 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 以獲取它們之間的元素列表:
Page<Foo> page
= new PageImpl<Foo>(fooList.subList(start, end), pageable, fooList.size());
就這樣! 我們可以現在返回 page 作為有效的結果。
請注意,如果我們還希望提供排序支持,則需要 在子列表之前對列表進行排序。
11. 結論
本文介紹瞭如何使用 Spring 在 REST API 中實現分頁,並討論瞭如何設置和測試可發現性。
如果想深入瞭解持久層中的分頁,可以查看 JPA 或 Hibernate 的分頁教程。