一、核心概念
在构建 RESTful API 时,处理大量数据的列表查询是常见需求。直接返回所有数据效率低下且不实用。分页、排序和条件查询是解决此问题的核心技术。
1. 分页 (Pagination)
- 概念: 将大量数据分割成多个较小的、可管理的“页”(Page),每次只返回一页数据。
- 目的: 减少单次响应的数据量,提高响应速度,节省网络带宽和客户端内存。
- 关键参数:
- 页码 (Page Number): 请求第几页的数据。通常从
0
或1
开始。Spring Data 默认从0
开始。 - 页大小 (Page Size): 每页包含多少条记录。
- 页码 (Page Number): 请求第几页的数据。通常从
- 响应信息:
- 当前页数据列表。
- 总记录数 (Total Elements)。
- 总页数 (Total Pages)。
- 当前页码 (Number)。
- 页大小 (Size)。
- 是否有下一页 (Has Next)、上一页 (Has Previous)、首页 (Is First)、末页 (Is Last)。
2. 排序 (Sorting)
- 概念: 指定数据返回的顺序。
- 目的: 让用户能按关心的维度(如创建时间、名称、价格)查看数据。
- 关键参数:
- 排序字段 (Sort Field): 按哪个字段排序(如
title
,createdAt
)。 - 排序方向 (Sort Direction): 升序 (
ASC
) 或降序 (DESC
)。
- 排序字段 (Sort Field): 按哪个字段排序(如
- 多字段排序: 可以指定多个排序规则,优先级从左到右。
3. 条件查询 (Conditional Query / Filtering)
- 概念: 根据一个或多个条件筛选数据。
- 目的: 返回满足特定业务需求的数据子集。
- 实现方式:
- 查询参数 (Query Parameters): 最常见,通过 URL 的
?key=value&key2=value2
传递。 - 请求体 (Request Body): 适用于复杂查询条件(如范围、逻辑组合)。
- 查询参数 (Query Parameters): 最常见,通过 URL 的
- 查询类型:
- 等值查询 (
=
,!=
) - 模糊查询 (
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
}
三、常见错误
Pageable
参数解析失败:- 错误:
Failed to convert value of type 'java.lang.String' to required type 'org.springframework.data.domain.Pageable'
- 原因:
@RequestParam
注解使用不当。page
和size
是int
,不能用@RequestParam
直接注入Pageable
。 - 解决: 如操作步骤所示,分别定义
int page
,int size
,List<String> sort
,然后手动构建Pageable
。或者使用@PageableDefault
注解(见技巧部分)。
- 错误:
排序字段名错误或不存在:
- 错误: SQL 错误或
IllegalArgumentException
。 - 原因:
sort
参数中的字段名 (property
) 在Book
实体中不存在或拼写错误。 - 解决: 在
buildSort
方法中添加校验,只允许预定义的字段排序;或捕获异常并使用默认排序。
- 错误: SQL 错误或
条件查询无效果:
- 错误: 查询返回所有数据或不符合预期。
- 原因:
Specification
中的Predicate
构建逻辑错误(如字段名错误、大小写问题、LIKE
模式错误);criteria
对象未正确注入(检查@RequestParam
是否遗漏)。 - 解决: 启用 JPA
show-sql
查看生成的 SQL 语句;调试createSpecification
方法。
NullPointerException
in Specification:- 错误:
NullPointerException
。 - 原因:
criteria
对象为null
,或criteria
中的字段为null
时未做判空处理。 - 解决: 在
createSpecification
方法开头检查criteria
是否为null
;在构建Predicate
前检查字段是否null
或空字符串。
- 错误:
分页结果
totalElements
不准确:- 错误:
totalElements
总是等于当前页大小或为0
。 - 原因: 使用了
Slice
而不是Page
。Slice
不查询总数,性能更好但无总数信息。 - 解决: 确保 Repository 方法返回
Page<T>
而不是Slice<T>
。
- 错误:
400 Bad Request
on invalid sort:- 错误: 客户端传入无效
sort
参数时返回 400。 - 原因:
Sort.Direction.fromString()
抛出IllegalArgumentException
。 - 解决: 在
buildSort
中捕获异常,忽略无效参数或使用默认值,避免整个请求失败。
- 错误: 客户端传入无效
四、注意事项
- 分页起始页: Spring Data JPA 的
Pageable
默认页码从0
开始。如果前端习惯从1
开始,需要在 Controller 中转换(page - 1
)。 - 页大小限制: 对
size
参数设置上限(如最大 100 或 1000),防止客户端请求过大数据量导致性能问题。 - 排序字段安全: 只允许对预定义的、安全的字段进行排序,防止 SQL 注入或暴露敏感信息。在
buildSort
中校验字段名。 - 查询条件校验: 对查询条件(如年份范围)进行基本校验(如
min <= max
)。 - 性能: 复杂的
Specification
或多表JOIN
可能导致慢查询。确保相关字段有索引。 Specification
vs 自定义查询方法:Specifications
更灵活,适合动态组合查询。- 自定义查询方法(
findByXxxAndYyy
)更直观,性能可能更好(可优化 SQL),但组合爆炸时难以维护。
Page
vsSlice
vsList
:Page
: 需要总数和分页信息时使用,会执行COUNT
查询。Slice
: 不需要总数时使用(如“下一页”按钮),只查size + 1
条,判断是否有下一页,性能更好。List
: 不分页,返回所有匹配数据(慎用)。
@RequestParam
对象:BookSearchCriteria
作为@RequestParam
的参数时,其字段会自动从 URL 查询参数映射。字段名需匹配。
五、使用技巧
使用
@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
微调。
- 可以直接注入
自定义
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
- 使用
使用
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); }
- 响应中无
totalElements
和totalPages
。
- 当不需要总页数时,使用
预定义排序字段白名单:
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 } } // ... }
使用
@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);
- 对于非常复杂的查询,直接使用
使用
Projections
减少数据传输:- 如果只需要部分字段,定义接口投影。
public interface BookSummary { Long getId(); String getTitle(); String getAuthor(); } // Repository Page<BookSummary> findByTitleContaining(String title, Pageable pageable);
六、最佳实践
- 强制分页: 对于列表接口,必须要求分页参数,禁止返回全量数据。
- 设置合理的默认值: 为
page
和size
提供合理的默认值(如page=0
,size=20
)。 - 限制页大小: 通过配置或代码限制
size
的最大值(如maxSize=100
)。 - 使用 DTO 和 Specifications: 保持 Controller 层的清晰,将复杂查询逻辑封装在
Specification
或@Query
中。 - 清晰的 API 文档: 使用 Swagger UI (Springdoc OpenAPI) 明确标注分页、排序和查询参数。
- 考虑使用
Slice
: 对于“加载更多”场景,优先使用Slice
以获得更好的性能。 - 索引优化: 为经常用于查询、排序、连接的字段创建数据库索引。
- 缓存: 对于不经常变化的查询结果,考虑使用缓存(如
@Cacheable
)。 - 错误处理: 对无效的分页/排序参数提供友好的错误信息或降级处理(如使用默认值)。
- 监控: 监控分页查询的性能,特别是
COUNT
查询的耗时。
七、性能优化
- 数据库索引:
- 为
WHERE
子句中的字段(如title
,author
,publicationYear
)创建索引。 - 为
ORDER BY
子句中的字段创建索引。 - 考虑复合索引(如
(author, publicationYear)
)。
- 为
- 避免
COUNT(*)
:- 使用
Slice
代替Page
当不需要总页数时。 - 考虑使用近似计数或缓存总数(如果允许一定延迟)。
- 使用
- 优化
JOIN
查询:- 避免在分页查询中进行不必要的
JOIN
,特别是大表JOIN
。 - 如果需要关联数据,考虑分两次查询:先查主表 ID,再根据 ID 查关联数据。
- 避免在分页查询中进行不必要的
- 选择性查询字段:
- 使用
Projections
只查询需要的字段,减少数据库 I/O 和网络传输。
- 使用
- 查询缓存:
- 启用 Hibernate 二级缓存和查询缓存(需谨慎评估缓存失效策略)。
- 应用层缓存:
- 使用
@Cacheable
缓存频繁访问且变化不频繁的分页结果(注意缓存键的设计,包含所有查询参数)。
- 使用
- 连接池优化:
- 配置合适的数据库连接池(如 HikariCP)参数(
maximumPoolSize
,connectionTimeout
)。
- 配置合适的数据库连接池(如 HikariCP)参数(
- 异步处理:
- 对于极其耗时的复杂查询,考虑返回
202 Accepted
并通过轮询或 Webhook 通知结果。
- 对于极其耗时的复杂查询,考虑返回
- 读写分离:
- 将分页查询路由到只读副本数据库,减轻主库压力。
- 监控与分析:
- 使用 APM 工具(如 SkyWalking, Prometheus + Grafana)监控慢查询。
- 分析数据库的
EXPLAIN
执行计划。
总结: Spring Boot 结合 Spring Data JPA 提供了强大且灵活的分页、排序和条件查询能力。掌握 Pageable
, Page
, Sort
和 Specifications
是核心。实践中,应优先使用 Specifications
或 @Query
处理动态查询,注意分页参数的安全性与性能,并通过索引、缓存和 Slice
等手段持续优化。遵循最佳实践,可以构建出高效、易用的 RESTful 列表查询 API。