知識庫 / Spring / Spring Boot RSS 訂閱

Spring Boot GraphQL分頁支持

Spring Boot
HongKong
4
10:44 AM · Dec 06 ,2025

1. 引言

GraphQL 是一種強大的查詢語言,允許客户端請求所需的確切數據。在使用 API 時,處理大型數據集的效率是一個常見挑戰。 分頁技術通過將數據分成較小的塊,從而提高性能和用户體驗。

在本教程中,我們將探討如何使用 GraphQL 在 Spring Boot 應用程序中實現分頁。 我們將涵蓋基於頁碼和基於遊標的分頁兩種方法。

2. 項目設置

為了開始,我們將包含對 GraphQLJPA 的必要依賴項,在 pom.xml 文件中:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-graphql</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

Spring Boot GraphQL 提供工具,用於定義 GraphQL 模式並將其綁定到 Java 代碼中。 JPA 幫助我們以面向對象的方式與數據庫進行交互。

3. 創建 圖書實體和存儲庫

接下來,讓我們定義一個簡單的實體類來表示我們想要分頁的數據。我們將使用 圖書實體作為我們的主要領域對象:

@Entity
@Table(name="books")
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;
    private String author;

    // Getters and setters
}

這個 Book 實體直接對應於數據庫表結構。每本書都具有唯一的 ID標題作者@Id 標註表示主鍵,而 @GeneratedValue(strategy = GenerationType.IDENTITY) 允許 JPA 自動生成它。

為了方便使用內置分頁支持的數據訪問,我們創建了一個倉庫接口:

public interface BookRepository extends PagingAndSortingRepository<Book, Long> {
}

在此示例中,我們擴展了 PagingAndSortingRepository,而不是更常用的 CrudRepository,因為前者內置了分頁和排序的支持。 通過這種設置,我們可以使用諸如 findAll(Pageable pageable) 這樣的方法來檢索分頁數據,而無需手動編寫任何 SQL 或 JPQL。

4. 定義基於頁面的分頁的 GraphQL 模式

GraphQL 使用模式來定義數據形狀以及客户端可以發送的查詢。 在我們的情況下,我們想要定義一個查詢,該查詢支持分頁,並允許我們獲取帶有分頁功能的書籍。 我們還需要包含一些分頁元數據,例如總頁數和當前頁碼。

以下是基於頁面的分頁的 GraphQL 模式:

type Book {
    id: ID!
    title: String
    author: String
}

type BookPage {
    content: [Book]
    totalPages: Int
    totalElements: Int
    number: Int
    size: Int
}

type Query {
    books(page: Int, size: Int): BookPage
}

Book 類型定義了我們 GraphQL 模式中單個圖書項目的結構。BookPage 類型作為包裝器,包含當前頁面的圖書列表以及重要的分頁元數據。這些元數據包括總頁數、總元素數、當前頁碼和頁面大小。

此外,books 查詢設計為接受兩個參數,pagesize

page 參數指定我們想要檢索的結果頁碼,size 則確定每頁應顯示多少本書。

5. 實現基於頁面的 GraphQL 查詢解析器

接下來,我們將實現連接我們 GraphQL 模式的查詢解析器。該解析器類將處理對 books 查詢的傳入請求,並返回正確分頁的結果:

@Component
public class BookQueryResolver {
    private final BookRepository bookRepository;

    public BookQueryResolver(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    @QueryMapping
    public BookPage books(@Argument int page, @Argument int size) {
        Pageable pageable = PageRequest.of(page, size);
        Page<Book> bookPage = bookRepository.findAll(pageable);
        return new BookPage(bookPage);
    }
}

這個解析器是一個 Spring 組件,用於處理傳入的 GraphQL 查詢。 其中,books() 方法使用了 @QueryMapping 註解,這直接對應於我們 schema 中定義的 books 查詢。 它接受兩個參數,pagesize,這些參數會自動從 GraphQL 請求中提取。

為了實現分頁,我們首先使用 PageRequest.of(page, size) 創建一個 Pageable 實例。 這個實例是 Spring Data 的核心功能的一部分,它指定了我們想要查看的結果頁以及每個頁應包含的項目數量。 然後,我們將這個 Pageable 實例傳遞給倉庫的 findAll() 方法。

倉庫處理這個請求並返回一個 Page<Book> 對象,其中包含當前頁面的書籍列表以及分頁元數據,例如總頁數、總元素數、當前頁碼和頁面大小。

6. 創建 BookPage DTO

為了確保我們的 GraphQL 響應與 schema 定義相匹配,我們需要創建一個名為 BookPage 的數據傳輸對象 (DTO)。該 DTO 作為 Spring Data 分頁結果與 GraphQL 類型之間的關鍵橋樑:

public class BookPage {
    private List<Book> content;
    private int totalPages;
    private long totalElements;
    private int number;
    private int size;

    public BookPage(Page<Book> page) {
        this.content = page.getContent();
        this.totalPages = page.getTotalPages();
        this.totalElements = page.getTotalElements();
        this.number = page.getNumber();
        this.size = page.getSize();
    }

    // Getters
}

這個 BookPage DTO 旨在在其構造函數中接受一個 Page<Book> 對象,它會提取和組織所有必需的數據,用於我們的 GraphQL 響應。通過從解析器返回此 DTO,我們確保響應與我們的 GraphQL 模式完全匹配。

7. 基於光標的分頁

雖然基於頁面的分頁在典型應用中表現良好,但對於極大的數據集或無限滾動界面,它存在侷限性。 基於光標的分頁在這種場景下提供了一種更高效的替代方案,它通過不同的方法來跟蹤位置。

與其依賴數字頁偏移量,光標分頁使用穩定的參考點——通常是:

  • 編碼後的記錄 ID
  • 精確的時間戳
  • 其他唯一、順序的標識符

對於我們的書的例子,我們將使用書的 ID 作為光標。客户端只需提供最後看到的書的 ID,服務器就會返回該點之後的所有後續記錄。

7.1. 使用遊標分頁更新 GraphQL 模式

讓我們更新我們的 GraphQL 模式以支持遊標分頁。我們將添加一個新的查詢和支持的類型:

type Book {
    id: ID!
    title: String
    author: String
}

type BookEdge {
    node: Book
    cursor: String
}

type PageInfo {
    hasNextPage: Boolean
    endCursor: String
}

type BookConnection {
    edges: [BookEdge]
    pageInfo: PageInfo
}

type Query {
    booksByCursor(cursor: ID, limit: Int!): BookConnection
}

BookEdge 類型結構允許我們維護書籍數據及其在序列中的位置。

BookConnection 類型封裝了邊緣列表以及pageInfo 對象。pageInfo 對象提供有用的元數據,例如是否有更多頁面可用以及檢索下一組結果所需的遊標。

7.2. 實現基於遊標的 GraphQL 查詢解析器

現在,讓我們實現將處理我們的 booksByCursor 查詢的解析器方法。此解析器將處理基於遊標的分頁請求,並返回正確結構化的連接對象:

@QueryMapping
public BookConnection booksByCursor(@Argument Optional<Long> cursor, @Argument int limit) {
    List<Book> books;

    if (cursor.isPresent()) {
        books = bookRepository.findByIdGreaterThanOrderByIdAsc(cursor.get(), PageRequest.of(0, limit));
    } else {
        books = bookRepository.findAllByOrderByIdAsc(PageRequest.of(0, limit));
    }

    List<BookEdge> edges = books.stream()
      .map(book -> new BookEdge(book, book.getId().toString()))
      .collect(Collectors.toList());

    String endCursor = books.isEmpty() ? null : books.get(books.size() - 1).getId().toString();
    boolean hasNextPage = !books.isEmpty() && bookRepository.existsByIdGreaterThan(books.get(books.size() - 1).getId());

    PageInfo pageInfo = new PageInfo(hasNextPage, endCursor);

    return new BookConnection(edges, pageInfo);
}

在本方法中,我們首先檢查客户端是否提供了遊標(cursor)。當遊標存在時,它會查詢 ID 大於解碼後的遊標值的書籍,並保持升序 ID 排序。 對於沒有遊標的初始請求,它將默認從集合的開頭檢索第一批記錄,保持相同的排序。

在檢索到書籍記錄後,該方法會將每個記錄轉換為 BookEdge 對象,並將書籍數據與其遊標組合。 接下來,我們通過提取當前結果集中最後一個書籍的 ID 來確定 endCursor

為了確定是否存在更多頁面,我們檢查當前結果集中是否存在 ID 大於最後一個記錄的書籍。 查詢 可以避免加載不必要的數據,同時提供我們所需的關鍵布爾結果。

7.3. 實現輔助DTO和倉庫

為了完成,我們需要實現輔助DTO並增強倉庫,添加針對光標的特定方法:

public class BookEdge {
    private Book node;
    private String cursor;

    public BookEdge(Book node, String cursor) {
        this.node = node;
        this.cursor = cursor;
    }

    // Getters
}

public class PageInfo {
    private boolean hasNextPage;
    private String endCursor;

    public PageInfo(boolean hasNextPage, String endCursor) {
        this.hasNextPage = hasNextPage;
        this.endCursor = endCursor;
    }

    // Getters
}

public class BookConnection {
    private List edges;
    private PageInfo pageInfo;

    public BookConnection(List edges, PageInfo pageInfo) {
        this.edges = edges;
        this.pageInfo = pageInfo;
    }

    // Getters
}

最後,讓我們擴展我們的倉庫,添加針對光標的查詢方法:

public interface BookRepository extends PagingAndSortingRepository<Book, Long> {
    List<Book> findByIdGreaterThanOrderByIdAsc(Long cursor, Pageable pageable);
    List<Book> findAllByOrderByIdAsc(Pageable pageable);
    boolean existsByIdGreaterThan(Long id);
}

8. 使用 JUnit 測試分頁功能

為了確保分頁功能的正確實現,我們將使用 JUnit 創建集成測試。

8.1. 準備測試數據

首先,我們使用 @BeforeEach 設置方法在每次測試之前初始化一個一致的數據集:

@BeforeEach
void setup() {
    bookRepository.deleteAll();

    for (int i = 1; i <= 50; i++) {
        Book book = new Book();
        book.setTitle("Test Book " + i);
        book.setAuthor("Test Author " + i);
        bookRepository.save(book);
    }
}

這保證了每次測試執行都從包含 50 個數據集的記錄簿開始。

8.2. 基於頁面的分頁測試

為了驗證我們的基於頁面的分頁功能,我們將使用 GraphQlTester 測試 GraphQL 端點。該工具是一個測試實用程序,它簡化了在集成測試中執行 GraphQL 查詢和驗證響應的過程。

讓我們編寫一個方法來測試 /graphql 端點,並定義一個 GraphQL 查詢,要求返回第 0 頁,每頁 5 個項目:

@Test
void givenPageAndSize_whenQueryBooks_thenShouldReturnCorrectPage() {
    String query = "{ books(page: 0, size: 5) { content { id title author } totalPages totalElements number size } }";

    graphQlTester.document(query)
      .execute()
      .path("data.books")
      .entity(BookPageResponse.class)
      .satisfies(bookPage -> {
        assertEquals(5, bookPage.getContent().size());
        assertEquals(0, bookPage.getNumber());
        assertEquals(5, bookPage.getSize());
        assertEquals(50, bookPage.getTotalElements());
        assertEquals(10, bookPage.getTotalPages());
    });
}

此測試檢查第一頁是否返回 5 本書,並確認元數據屬性“總頁數”與 50-test-record 數據集相匹配。

8.3. 遊標式分頁測試

為了測試遊標式分頁功能,我們首先執行一個不提供遊標值以獲取第一頁結果的查詢。收到第一頁響應後,我們使用第一頁中獲得的遊標來請求下一組結果。

這驗證了遊標機制是否正確地維護了我們在數據集中的位置並返回後續項:

@Test
void givenCursorAndLimit_whenQueryBooksByCursor_thenShouldReturnNextBatch() {
    // First page
    String queryPage1 = "{ booksByCursor(limit: 5) { edges { node { id } cursor } pageInfo { endCursor hasNextPage } } }";
    
    BookConnectionResponse firstPage = graphQlTester.document(firstPageQuery)
      .execute()
      .path("data.booksByCursor")
      .entity(BookConnectionResponse.class)
      .get();

    assertEquals(5, firstPage.getEdges().size());
    assertTrue(firstPage.getPageInfo().isHasNextPage());
    assertNotNull(firstPage.getPageInfo().getEndCursor());

    // Second page using cursor
    String queryPage2 = "{ booksByCursor(cursor: \"" + firstPage.getPageInfo().getEndCursor() + "\", limit: 5) { edges { node { id } } pageInfo { hasNextPage } } }";

    graphQlTester.document(secondPageQuery)
      .execute()
      .path("data.booksByCursor")
      .entity(BookConnectionResponse.class)
      .satisfies(secondPage -> {
        assertEquals(5, secondPage.getEdges().size());
        assertTrue(secondPage.getPageInfo().isHasNextPage());
      });
}

9. 結論

在本文中,我們探討了兩種在 Spring Boot GraphQL API 中實現分頁的方法。基於頁面的分頁在處理較小或有限的數據集方面效果良好,因為總項目數已知且不會頻繁更改。另一方面,基於遊標的分頁則非常適合處理大型數據集、無限滾動界面以及數據頻繁添加或刪除的情況。

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

發佈 評論

Some HTML is okay.