Spring Boot GraphQL 中的分頁支持
1. 簡介
GraphQL 是一種強大的查詢語言,允許客戶端精確請求所需的資料。使用 API 時,一個常見的挑戰是如何有效率地處理大型資料集。分頁功能可以將資料分解成更小的區塊,從而提升效能和使用者體驗。
在本教程中,我們將探索如何使用 GraphQL 在 Spring Boot 應用程式中實現分頁。我們將介紹基於頁面和基於遊標的分頁。
2. 設定項目
首先,我們將在pom.xml
檔中包含GraphQL和JPA所需的依賴項:
<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.建立Book
實體和儲存庫
接下來,讓我們定義一個簡單的實體類別來表示我們想要分頁的資料。我們將使用Book
實體作為我們的主要網域物件:
@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
、一個title
和一個author
。 @Id
註解標記了主鍵,而@GeneratedValue(strategy = GenerationType.IDENTITY)
則允許 JPA 自動產生主鍵。
為了透過內建分頁支援方便資料訪問,我們創建了一個儲存庫介面:
public interface BookRepository extends PagingAndSortingRepository<Book, Long> {
}
在此範例中,我們擴展了PagingAndSortingRepository
而不是更常用的CrudRepository
,因為它提供了對分頁和排序的內建支援。透過此設置,我們可以使用findAll(Pageable pageable)
之類的方法來檢索分頁數據,而無需手動編寫任何 SQL 或 JPQL 語句。
4. 定義以頁面為基礎的分頁的 GraphQL Schema
GraphQL 使用 schema 來定義資料的結構以及客戶端可以傳送的查詢。在本例中,我們想要定義一個查詢,以便取得支援分頁的書籍。我們還需要包含一些分頁元數據,例如總頁數和當前頁碼。
以下是基於頁面的分頁的架構:
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 schema 中各個書籍條目的結構。 BookPage BookPage
則充當包裝器,包含目前頁面的書籍清單以及重要的分頁元資料。這些元資料包括總頁數、總元素數、目前頁碼和頁面大小。
此外, books
查詢被設計為接受兩個參數: page
和size.
參數指定我們要檢索哪一頁結果,而 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 books()
方法帶有@QueryMapping
註解,它直接對應到我們架構中定義的books
查詢。它接受兩個參數: page
和size
,這兩個參數是從 GraphQL 請求中自動提取的。
要實作分頁,我們先使用PageRequest.of(page, size)
建立一個Pageable
實例。此實例是 Spring Data 核心功能的一部分,它指定了我們想要的結果頁以及每頁應包含多少個項目。然後,我們將此Pageable
傳遞給儲存庫的findAll()
方法。
儲存庫處理此請求並返回一個Page<Book>
對象,其中包含當前頁面的書籍列表以及分頁元數據,如總頁數、總元素數、當前頁碼和頁面大小。
6.建立BookPage
DTO
為了確保我們的 GraphQL 回應與模式定義匹配,我們需要建立一個名為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,伺服器就會傳回該 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 提供了一些有用的元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 大於目前結果中最後一筆記錄的書籍。查詢existsByIdGreaterThan()
避免了載入不必要的數據,同時提供了我們所需的關鍵布林值結果。
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 查詢,該查詢要求第 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 個測試記錄資料集相符。
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 中實作分頁的兩種方法。基於頁面的分頁在處理較小或有限的資料集(其中項目總數已知且不會頻繁變更)時效果很好。另一方面,基於遊標的分頁非常適合大型資料集、無限滾動的介面以及頻繁添加或刪除資料的情況。
與往常一樣,原始碼可在 GitHub 上取得。