知識庫 / Spring RSS 訂閱

Spring REST分頁

REST,Spring
HongKong
2
04:00 AM · Dec 06 ,2025

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 方法中注入了兩個查詢參數:sizepage

或者,我們也可以使用 Pageable 對象,它會自動映射 pagesizesort 參數。 此外,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 在控制器層觸發。然後我們將實現一個自定義監聽器來與此事件一起使用。

簡而言之,監聽器將檢查導航是否允許訪問 下一頁上一頁firstlast 頁碼。如果允許,它將 將相關的 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=&lt;http://localhost:8080/rest/api/admin/foo?page=0&amp;size=2&gt;; rel=”first”, &lt;http://localhost:8080/rest/api/admin/foo?page=103&amp&size=2&gt;; rel=”last”

另一種選項是返回重定向,303(其他)到第一頁。更保守的方案是簡單地向客户端返回 405(方法不允許)對 GET 請求。

8. 使用 Range HTTP 標頭進行分頁

實施分頁的一種相對不同的方法是使用 HTTP Range 標頭,包括 RangeContent-RangeIf-RangeAccept-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 的分頁教程。

user avatar
0 位用戶收藏了這個故事!
收藏

發佈 評論

Some HTML is okay.