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 获取。


原始标题:Pagination Support in Spring Boot GraphQL | Baeldung