1. 引言
GraphQL 是一种强大的查询语言,允许客户端精确请求所需数据。处理大型数据集时,分页是常见挑战。分页通过将数据拆分为小块,显著提升性能和用户体验。
本教程将探讨在 Spring Boot 应用中使用 GraphQL 实现分页的两种方案:基于页码的分页和基于游标的分页。
2. 项目搭建
在 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 提供了定义 schema 并绑定到 Java 代码的工具,而 JPA 则简化了数据库的面向对象操作。
3. 创建 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
}
这个实体直接映射数据库表结构。**@Id
标记主键,@GeneratedValue
让 JPA 自动生成 ID**。
创建支持分页的仓库接口:
public interface BookRepository extends PagingAndSortingRepository<Book, Long> {
}
这里继承 PagingAndSortingRepository
而非 CrudRepository
,因为它内置了分页和排序支持。使用 findAll(Pageable pageable)
方法即可获取分页数据,无需手动编写 SQL。
4. 定义基于页码的 GraphQL Schema
GraphQL 通过 schema 定义数据结构和客户端可发送的查询。以下是支持分页的 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
}
BookPage
类型包装了当前页的书籍列表和分页元数据(总页数、总元素数等)。books
查询接受两个参数:
page
:指定页码size
:指定每页大小
5. 实现基于页码的查询解析器
创建解析器连接 schema 和业务逻辑:
@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);
}
}
关键点:
@QueryMapping
注解关联 schema 中的books
查询PageRequest.of(page, size)
创建分页参数- 仓库返回包含数据和元数据的
Page<Book>
对象
6. 创建 BookPage DTO
创建 DTO 匹配 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
}
该 DTO 从 Page<Book>
提取数据,确保响应与 schema 完全匹配。
7. 基于游标的分页
当数据集极大或需要无限滚动时,基于游标的分页是更高效的替代方案。它使用稳定引用点(如 ID 或时间戳)而非页码偏移量。
7.1. 更新游标分页的 Schema
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
:包装书籍数据和游标PageInfo
:提供分页元数据(是否有下一页等)BookConnection
:包含边列表和分页信息
7.2. 实现游标分页的查询解析器
@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);
}
实现要点:
✅ 根据是否有游标选择查询方式
✅ 将书籍转换为 BookEdge
对象
✅ 通过 existsByIdGreaterThan
高效检查是否有下一页
⚠️ 避免加载不必要数据,提升性能
7.3. 实现支持类和仓库方法
创建必要的 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 测试分页
8.1. 准备测试数据
@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. 测试页码分页
@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 条数据,元数据正确。
8.3. 测试游标分页
@Test
void givenCursorAndLimit_whenQueryBooksByCursor_thenShouldReturnNextBatch() {
// 第一页请求
String firstPageQuery = "{ 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());
// 使用游标请求第二页
String secondPageQuery = "{ 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 获取。