1. 概述
本教程將重點介紹使用 Spring MVC 和 Spring Data 在 REST API 中實現分頁功能的具體實施過程。
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();
}在此示例中,我們通過使用 @RequestParam 註解,向 Controller 方法中注入了兩個查詢參數:size 和 page。
或者,我們也可以使用 Pageable 對象,它會自動映射 page、size 和 sort 參數。 此外,PagingAndSortingRepository 實體提供了內置的方法,用於支持使用 Pageable 作為參數。
我們還注入了 Http Response 和 UriComponentsBuilder,以幫助可發現性,並通過自定義事件進行解耦。如果這不是 API 的目標,則可以簡單地刪除自定義事件。
最後,請注意,本文的重點是 REST 和 Web 層;要更深入地瞭解分頁的數據訪問部分,可以查看有關使用 Spring Data 的分頁文章:關於分頁。
4. REST 分頁的可發現性
在分頁的範圍內,滿足 HATEOAS 約束 的含義是,使 API 客户端能夠根據當前頁面,發現 下一頁 和 上一頁 頁碼。為此,我們將使用 Link HTTP 頭部,與“next,” “prev,” “first,” 和 “last” 鏈接關係類型相結合。
在 REST 中,Discoverability 是一個橫跨所有方面的考慮,不僅適用於特定的操作,也適用於操作的類型。例如,每次資源創建時,該資源的 URI 應該由客户端發現。由於此要求適用於任何資源的創建,我們將單獨處理它。
我們將使用事件來分離這些考慮因素,正如我們在之前關於 REST 服務的可發現性的文章中所討論的。在分頁的案例中,事件 PaginatedResultsRetrievedEvent 在控制器層觸發。然後我們將實現一個自定義監聽器來與此事件一起使用。
簡而言之,監聽器將檢查導航是否允許訪問 下一頁、上一頁、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。 讓我們看看如何處理指向下一頁的鏈接:
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());請注意,為了簡潔起見,僅包含部分代碼示例,完整的代碼可以在這裏找到:
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);
}請注意,負責從 rel 關係中提取 URI 的低級代碼 extractURIByRel 的完整代碼可以在這裏找到:。
7. 獲取所有資源
在分頁和可發現性相同的主題上,必須決定客户端是否允許一次性從系統中檢索所有資源,或者客户端必須按分頁方式請求它們。
如果決定客户端不能通過單個請求檢索所有資源,並且需要分頁,則有幾種響應選項可供選擇。一種選項是在返回 404(未找到)並使用 Link 標頭來使第一頁可發現。
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 (Partial Content)、413 (Request Entity Too Large) 和 416 (Requested Range Not Satisfiable).
這種方法的觀點是,HTTP Range 擴展不應用於分頁,而是應由服務器管理,而不是由應用程序管理。儘管基於 HTTP Range 擴展的實現在技術上是可行的,但它並不像本文中討論的實現那樣常見。
9. Spring Data REST 分頁
在 Spring Data 中,如果需要返回完整數據集中的少量結果,可以使用任何 <em >Pageable</em > 倉庫方法,因為它始終會返回一個 `Page>。結果將根據頁碼、頁面大小和排序方向進行返回。
Spring Data REST 會自動識別 URL 參數,例如 <em >page, size, sort</em > 等。
要使用任何倉庫中的分頁方法,需要擴展 <em >PagingAndSortingRepository</em >。
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
}所有實現 <em >PagingAndSortingRepository</em> 的 API 都將返回一個 <em >Page</em>。如果需要從 <em >Page</em> 中獲取結果列表,則 <em >Page</em> 的 <em >getContent()</em> API 提供 Spring Data REST API 檢索的記錄列表。
10. 將列表轉換為頁面
假設我們有一個分頁對象作為輸入,但我們需要檢索的信息包含在一個列表而不是分頁和排序存儲庫中。 在這些情況下,我們可能需要將列表轉換為頁面。
例如,假設我們有一個來自 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());這就完成了!我們現在可以把 頁面 作為有效結果返回。
並且請注意,如果我們還希望支持排序,則需要在子列表之前 對列表進行排序。
11. 結論
本文介紹瞭如何使用 Spring 在 REST API 中實現分頁,並討論瞭如何設置和測試可發現性。如果想要深入瞭解持久層中的分頁,可以查看 JPA 或 Hibernate 的分頁教程。