一、核心概念

在构建 RESTful API 时,处理大量数据的列表查询是常见需求。直接返回所有数据效率低下且不实用。分页、排序和条件查询是解决此问题的核心技术。

1. 分页 (Pagination)

  • 概念: 将大量数据分割成多个较小的、可管理的“页”(Page),每次只返回一页数据。
  • 目的: 减少单次响应的数据量,提高响应速度,节省网络带宽和客户端内存。
  • 关键参数:
    • 页码 (Page Number): 请求第几页的数据。通常从 01 开始。Spring Data 默认从 0 开始。
    • 页大小 (Page Size): 每页包含多少条记录。
  • 响应信息:
    • 当前页数据列表。
    • 总记录数 (Total Elements)。
    • 总页数 (Total Pages)。
    • 当前页码 (Number)。
    • 页大小 (Size)。
    • 是否有下一页 (Has Next)、上一页 (Has Previous)、首页 (Is First)、末页 (Is Last)。

2. 排序 (Sorting)

  • 概念: 指定数据返回的顺序。
  • 目的: 让用户能按关心的维度(如创建时间、名称、价格)查看数据。
  • 关键参数:
    • 排序字段 (Sort Field): 按哪个字段排序(如 title, createdAt)。
    • 排序方向 (Sort Direction): 升序 (ASC) 或降序 (DESC)。
  • 多字段排序: 可以指定多个排序规则,优先级从左到右。

3. 条件查询 (Conditional Query / Filtering)

  • 概念: 根据一个或多个条件筛选数据。
  • 目的: 返回满足特定业务需求的数据子集。
  • 实现方式:
    • 查询参数 (Query Parameters): 最常见,通过 URL 的 ?key=value&key2=value2 传递。
    • 请求体 (Request Body): 适用于复杂查询条件(如范围、逻辑组合)。
  • 查询类型:
    • 等值查询 (=, !=)
    • 模糊查询 (LIKE)
    • 范围查询 (>=, <=, BETWEEN)
    • 包含查询 (IN)
    • 空值查询 (IS NULL, IS NOT NULL)

4. Spring Data JPA 核心接口

  • Pageable: 封装分页和排序信息的接口。PageRequest 是其常用实现。
  • Page<T>: 封装分页查询结果的接口,包含数据列表和分页元数据。
  • Sort: 封装排序信息的接口。

二、操作步骤(非常详细)

场景设定

在之前的 BookController 基础上,实现对书籍 (Book) 的分页、排序和条件查询功能。

步骤 1:准备实体与 Repository

确保 Book 实体已定义,并且 BookRepository 继承自 JpaRepository

// BookRepository.java
public interface BookRepository extends JpaRepository<Book, Long> {
    // Spring Data JPA 会根据方法名自动生成查询
    // 例如,findByTitleContaining 用于模糊查询标题
    List<Book> findByTitleContaining(String title);
    List<Book> findByAuthor(String author);
    List<Book> findByPublicationYearBetween(Integer startYear, Integer endYear);
    // ... 可以添加更多自定义查询方法
}

步骤 2:定义查询 DTO (推荐)

创建专门用于接收查询条件的 DTO,保持 BookDTO 的简洁。

// BookSearchCriteria.java
package com.example.restfuldemo.dto;

import lombok.Data;
import java.time.LocalDate;

@Data
public class BookSearchCriteria {
    private String title; // 用于模糊查询
    private String author; // 用于等值查询
    private String isbn; // 用于等值查询
    private Integer minPublicationYear;
    private Integer maxPublicationYear;
    // 可以添加更多字段,如 categoryId, status 等
}

步骤 3:在 Controller 中实现分页排序查询

修改 BookController,添加支持分页、排序和条件查询的 getAllBooks 方法。

// BookController.java
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.RequestParam;

// ... 其他导入

@RestController
@RequestMapping("/api/v1/books")
public class BookController {

    // ... 其他字段注入

    /**
     * 分页、排序、条件查询获取书籍列表
     * 支持的查询参数:
     *   page: 页码 (从0开始)
     *   size: 每页大小 (默认20)
     *   sort: 排序字段和方向,格式: field,dir (如 title,asc 或 createdAt,desc)
     *         支持多个: sort=title,asc&sort=author,desc
     *   title: 标题模糊匹配
     *   author: 作者精确匹配
     *   isbn: ISBN 精确匹配
     *   minPublicationYear: 出版年份下限
     *   maxPublicationYear: 出版年份上限
     */
    @GetMapping
    public ResponseEntity<Page<BookDTO>> getAllBooks(
            // 分页参数
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            // 排序参数
            @RequestParam(required = false) List<String> sort,
            // 查询条件参数
            BookSearchCriteria criteria) {

        // 1. 构建 Sort 对象
        Sort sorting = buildSort(sort);

        // 2. 构建 Pageable 对象
        Pageable pageable = PageRequest.of(page, size, sorting);

        // 3. 执行查询
        // 方式一:使用自定义查询方法 (需要在 Repository 中定义)
        // Page<Book> bookPage = bookRepository.findByCriteria(criteria.getTitle(), criteria.getAuthor(), ... , pageable);

        // 方式二:使用 JPA Specifications (更灵活,推荐用于复杂动态查询)
        // Page<Book> bookPage = bookRepository.findAll(createSpecification(criteria), pageable);

        // 方式三:先查 ID 再分页 (如果 Repository 方法不支持分页,但性能可能较差)
        // List<Long> bookIds = bookRepository.findIdsByCriteria(criteria); // 自定义方法返回 ID 列表
        // Pageable idPageable = ... // 可能需要特殊处理

        // 方式四:使用 QueryDSL (需要额外依赖,功能强大)

        // 本例演示方式二:使用 Specifications
        Page<Book> bookPage = bookRepository.findAll(createSpecification(criteria), pageable);

        // 4. 转换 Page<Book> 为 Page<BookDTO>
        Page<BookDTO> bookDTOPage = bookPage.map(bookMapper::toDTO);

        // 5. 返回结果
        return ResponseEntity.ok(bookDTOPage);
    }

    /**
     * 根据前端传入的 sort 参数列表构建 Sort 对象
     * @param sortList 格式如 ["title,asc", "author,desc"]
     * @return Sort 对象
     */
    private Sort buildSort(List<String> sortList) {
        if (sortList == null || sortList.isEmpty()) {
            // 默认排序:按 ID 降序
            return Sort.by(Sort.Direction.DESC, "id");
        }
        Sort sorting = null;
        for (String sort : sortList) {
            try {
                String[] parts = sort.split(",");
                String property = parts[0];
                Sort.Direction direction = parts.length > 1 ?
                        Sort.Direction.fromString(parts[1].trim()) :
                        Sort.Direction.ASC; // 默认升序
                if (sorting == null) {
                    sorting = Sort.by(direction, property);
                } else {
                    sorting = sorting.and(Sort.by(direction, property));
                }
            } catch (IllegalArgumentException e) {
                // 忽略无效的排序参数,使用默认或已构建的排序
                System.err.println("Invalid sort parameter: " + sort);
            }
        }
        return sorting != null ? sorting : Sort.by(Sort.Direction.DESC, "id");
    }

    /**
     * 根据查询条件创建 JPA Specification
     * @param criteria 查询条件
     * @return Specification<Book>
     */
    private Specification<Book> createSpecification(BookSearchCriteria criteria) {
        return (root, query, criteriaBuilder) -> {
            List<Predicate> predicates = new ArrayList<>();

            if (criteria.getTitle() != null && !criteria.getTitle().trim().isEmpty()) {
                predicates.add(criteriaBuilder.like(
                    criteriaBuilder.lower(root.get("title")),
                    "%" + criteria.getTitle().trim().toLowerCase() + "%"
                ));
            }

            if (criteria.getAuthor() != null && !criteria.getAuthor().trim().isEmpty()) {
                predicates.add(criteriaBuilder.equal(
                    root.get("author"), criteria.getAuthor().trim()
                ));
            }

            if (criteria.getIsbn() != null && !criteria.getIsbn().trim().isEmpty()) {
                predicates.add(criteriaBuilder.equal(
                    root.get("isbn"), criteria.getIsbn().trim()
                ));
            }

            if (criteria.getMinPublicationYear() != null) {
                predicates.add(criteriaBuilder.greaterThanOrEqualTo(
                    root.get("publicationYear"), criteria.getMinPublicationYear()
                ));
            }

            if (criteria.getMaxPublicationYear() != null) {
                predicates.add(criteriaBuilder.lessThanOrEqualTo(
                    root.get("publicationYear"), criteria.getMaxPublicationYear()
                ));
            }

            // 如果没有条件,返回 null 表示匹配所有
            return predicates.isEmpty() ? null : criteriaBuilder.and(predicates.toArray(new Predicate[0]));
        };
    }
}

步骤 4:添加 JPA Specifications 依赖

Specifications 是 JPA 2.0+ 的标准,Spring Data JPA 提供了支持,但需要确保 spring-boot-starter-data-jpa 已包含相关类。

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

步骤 5:修改 Repository 以支持 Specifications

确保 BookRepository 继承了 JpaSpecificationExecutor

// BookRepository.java
public interface BookRepository extends JpaRepository<Book, Long>, JpaSpecificationExecutor<Book> {
    // ... 之前的自定义方法
}

步骤 6:测试 API

使用工具测试各种组合:

# 1. 基本分页 (第1页,每页10条)
curl "http://localhost:8080/api/v1/books?page=0&size=10"

# 2. 排序 (按标题升序)
curl "http://localhost:8080/api/v1/books?sort=title,asc"

# 3. 排序 (按标题升序,再按作者降序)
curl "http://localhost:8080/api/v1/books?sort=title,asc&sort=author,desc"

# 4. 条件查询 (标题包含 "Spring")
curl "http://localhost:8080/api/v1/books?title=Spring"

# 5. 条件查询 (作者是 "John Doe")
curl "http://localhost:8080/api/v1/books?author=John%20Doe"

# 6. 组合查询 (分页+排序+条件)
curl "http://localhost:8080/api/v1/books?page=0&size=5&sort=title,asc&title=Guide&minPublicationYear=2020"

# 7. 范围查询 (出版年份在2020-2023之间)
curl "http://localhost:8080/api/v1/books?minPublicationYear=2020&maxPublicationYear=2023"

预期响应示例 (Page<BookDTO>):

{
  "content": [
    {
      "id": 1,
      "title": "Effective Java",
      "author": "Joshua Bloch",
      "isbn": "9780134685991",
      "publicationYear": 2017
    },
    // ... 其他书籍
  ],
  "pageable": {
    "sort": {
      "sorted": true,
      "unsorted": false,
      "empty": false
    },
    "offset": 0,
    "pageNumber": 0,
    "pageSize": 1,
    "paged": true,
    "unpaged": false
  },
  "totalElements": 150,
  "totalPages": 150,
  "last": false,
  "size": 1,
  "number": 0,
  "sort": {
    "sorted": true,
    "unsorted": false,
    "empty": false
  },
  "first": true,
  "numberOfElements": 1,
  "empty": false
}

三、常见错误

  1. Pageable 参数解析失败:

    • 错误: Failed to convert value of type 'java.lang.String' to required type 'org.springframework.data.domain.Pageable'
    • 原因: @RequestParam 注解使用不当。pagesizeint,不能用 @RequestParam 直接注入 Pageable
    • 解决: 如操作步骤所示,分别定义 int page, int size, List<String> sort,然后手动构建 Pageable。或者使用 @PageableDefault 注解(见技巧部分)。
  2. 排序字段名错误或不存在:

    • 错误: SQL 错误或 IllegalArgumentException
    • 原因: sort 参数中的字段名 (property) 在 Book 实体中不存在或拼写错误。
    • 解决:buildSort 方法中添加校验,只允许预定义的字段排序;或捕获异常并使用默认排序。
  3. 条件查询无效果:

    • 错误: 查询返回所有数据或不符合预期。
    • 原因: Specification 中的 Predicate 构建逻辑错误(如字段名错误、大小写问题、LIKE 模式错误);criteria 对象未正确注入(检查 @RequestParam 是否遗漏)。
    • 解决: 启用 JPA show-sql 查看生成的 SQL 语句;调试 createSpecification 方法。
  4. NullPointerException in Specification:

    • 错误: NullPointerException
    • 原因: criteria 对象为 null,或 criteria 中的字段为 null 时未做判空处理。
    • 解决:createSpecification 方法开头检查 criteria 是否为 null;在构建 Predicate 前检查字段是否 null 或空字符串。
  5. 分页结果 totalElements 不准确:

    • 错误: totalElements 总是等于当前页大小或为 0
    • 原因: 使用了 Slice 而不是 PageSlice 不查询总数,性能更好但无总数信息。
    • 解决: 确保 Repository 方法返回 Page<T> 而不是 Slice<T>
  6. 400 Bad Request on invalid sort:

    • 错误: 客户端传入无效 sort 参数时返回 400。
    • 原因: Sort.Direction.fromString() 抛出 IllegalArgumentException
    • 解决:buildSort 中捕获异常,忽略无效参数或使用默认值,避免整个请求失败。

四、注意事项

  1. 分页起始页: Spring Data JPA 的 Pageable 默认页码从 0 开始。如果前端习惯从 1 开始,需要在 Controller 中转换(page - 1)。
  2. 页大小限制:size 参数设置上限(如最大 100 或 1000),防止客户端请求过大数据量导致性能问题。
  3. 排序字段安全: 只允许对预定义的、安全的字段进行排序,防止 SQL 注入或暴露敏感信息。在 buildSort 中校验字段名。
  4. 查询条件校验: 对查询条件(如年份范围)进行基本校验(如 min <= max)。
  5. 性能: 复杂的 Specification 或多表 JOIN 可能导致慢查询。确保相关字段有索引。
  6. Specification vs 自定义查询方法:
    • Specifications 更灵活,适合动态组合查询。
    • 自定义查询方法(findByXxxAndYyy)更直观,性能可能更好(可优化 SQL),但组合爆炸时难以维护。
  7. Page vs Slice vs List:
    • Page: 需要总数和分页信息时使用,会执行 COUNT 查询。
    • Slice: 不需要总数时使用(如“下一页”按钮),只查 size + 1 条,判断是否有下一页,性能更好。
    • List: 不分页,返回所有匹配数据(慎用)。
  8. @RequestParam 对象: BookSearchCriteria 作为 @RequestParam 的参数时,其字段会自动从 URL 查询参数映射。字段名需匹配。

五、使用技巧

  1. 使用 @PageableDefault 简化分页参数:

    • 可以直接注入 Pageable,并用 @PageableDefault 设置默认值。
    @GetMapping
    public ResponseEntity<Page<BookDTO>> getAllBooks(
            @PageableDefault(size = 10, sort = "id", direction = Sort.Direction.DESC) Pageable pageable,
            BookSearchCriteria criteria) {
        // ... 使用 pageable
        Page<Book> bookPage = bookRepository.findAll(createSpecification(criteria), pageable);
        Page<BookDTO> bookDTOPage = bookPage.map(bookMapper::toDTO);
        return ResponseEntity.ok(bookDTOPage);
    }
    
    • 注意: 这种方式下,sort 参数的格式是 field:direction (如 title:asc),与之前 field,direction 不同。可通过 @SortDefault 微调。
  2. 自定义 Pageable 参数名:

    • 使用 @Qualifier 注解改变默认参数名。
    @GetMapping
    public ResponseEntity<Page<BookDTO>> getAllBooks(
            @PageableDefault(size = 10) @Qualifier("pageable") Pageable myPageable,
            BookSearchCriteria criteria) {
        // ...
    }
    
    • URL: /api/v1/books?myPageable.page=0&myPageable.size=10
  3. 使用 Slice 优化性能:

    • 当不需要总页数时,使用 Slice
    // Repository
    Slice<Book> findAll(Specification<Book> spec, Pageable pageable);
    
    // Controller
    @GetMapping("/slice")
    public ResponseEntity<Slice<BookDTO>> getBooksSlice(Pageable pageable, BookSearchCriteria criteria) {
        Slice<Book> bookSlice = bookRepository.findAll(createSpecification(criteria), pageable);
        Slice<BookDTO> bookDTOSlice = bookSlice.map(bookMapper::toDTO);
        return ResponseEntity.ok(bookDTOSlice);
    }
    
    • 响应中无 totalElementstotalPages
  4. 预定义排序字段白名单:

    private static final Set<String> ALLOWED_SORT_FIELDS = Set.of("id", "title", "author", "publicationYear", "createdAt");
    
    private Sort buildSort(List<String> sortList) {
        // ... 在解析前检查
        if (sortList != null) {
            for (String sort : sortList) {
                String[] parts = sort.split(",");
                String property = parts[0];
                if (!ALLOWED_SORT_FIELDS.contains(property)) {
                    // 忽略或记录日志
                    continue;
                }
                // ... 构建 Sort
            }
        }
        // ...
    }
    
  5. 使用 @Query 自定义复杂查询:

    • 对于非常复杂的查询,直接使用 @Query 注解编写 JPQL 或原生 SQL。
    @Query("SELECT b FROM Book b WHERE " +
           "(:title IS NULL OR LOWER(b.title) LIKE LOWER(CONCAT('%', :title, '%'))) AND " +
           "(:author IS NULL OR b.author = :author) AND " +
           "(:minYear IS NULL OR b.publicationYear >= :minYear) AND " +
           "(:maxYear IS NULL OR b.publicationYear <= :maxYear)")
    Page<Book> findByDynamicCriteria(
        @Param("title") String title,
        @Param("author") String author,
        @Param("minYear") Integer minYear,
        @Param("maxYear") Integer maxYear,
        Pageable pageable);
    
  6. 使用 Projections 减少数据传输:

    • 如果只需要部分字段,定义接口投影。
    public interface BookSummary {
        Long getId();
        String getTitle();
        String getAuthor();
    }
    
    // Repository
    Page<BookSummary> findByTitleContaining(String title, Pageable pageable);
    

六、最佳实践

  1. 强制分页: 对于列表接口,必须要求分页参数,禁止返回全量数据。
  2. 设置合理的默认值:pagesize 提供合理的默认值(如 page=0, size=20)。
  3. 限制页大小: 通过配置或代码限制 size 的最大值(如 maxSize=100)。
  4. 使用 DTO 和 Specifications: 保持 Controller 层的清晰,将复杂查询逻辑封装在 Specification@Query 中。
  5. 清晰的 API 文档: 使用 Swagger UI (Springdoc OpenAPI) 明确标注分页、排序和查询参数。
  6. 考虑使用 Slice 对于“加载更多”场景,优先使用 Slice 以获得更好的性能。
  7. 索引优化: 为经常用于查询、排序、连接的字段创建数据库索引。
  8. 缓存: 对于不经常变化的查询结果,考虑使用缓存(如 @Cacheable)。
  9. 错误处理: 对无效的分页/排序参数提供友好的错误信息或降级处理(如使用默认值)。
  10. 监控: 监控分页查询的性能,特别是 COUNT 查询的耗时。

七、性能优化

  1. 数据库索引:
    • WHERE 子句中的字段(如 title, author, publicationYear)创建索引。
    • ORDER BY 子句中的字段创建索引。
    • 考虑复合索引(如 (author, publicationYear))。
  2. 避免 COUNT(*)
    • 使用 Slice 代替 Page 当不需要总页数时。
    • 考虑使用近似计数或缓存总数(如果允许一定延迟)。
  3. 优化 JOIN 查询:
    • 避免在分页查询中进行不必要的 JOIN,特别是大表 JOIN
    • 如果需要关联数据,考虑分两次查询:先查主表 ID,再根据 ID 查关联数据。
  4. 选择性查询字段:
    • 使用 Projections 只查询需要的字段,减少数据库 I/O 和网络传输。
  5. 查询缓存:
    • 启用 Hibernate 二级缓存和查询缓存(需谨慎评估缓存失效策略)。
  6. 应用层缓存:
    • 使用 @Cacheable 缓存频繁访问且变化不频繁的分页结果(注意缓存键的设计,包含所有查询参数)。
  7. 连接池优化:
    • 配置合适的数据库连接池(如 HikariCP)参数(maximumPoolSize, connectionTimeout)。
  8. 异步处理:
    • 对于极其耗时的复杂查询,考虑返回 202 Accepted 并通过轮询或 Webhook 通知结果。
  9. 读写分离:
    • 将分页查询路由到只读副本数据库,减轻主库压力。
  10. 监控与分析:
    • 使用 APM 工具(如 SkyWalking, Prometheus + Grafana)监控慢查询。
    • 分析数据库的 EXPLAIN 执行计划。

总结: Spring Boot 结合 Spring Data JPA 提供了强大且灵活的分页、排序和条件查询能力。掌握 Pageable, Page, SortSpecifications 是核心。实践中,应优先使用 Specifications@Query 处理动态查询,注意分页参数的安全性与性能,并通过索引、缓存和 Slice 等手段持续优化。遵循最佳实践,可以构建出高效、易用的 RESTful 列表查询 API。