《Spring Boot + JPA 实战》第12招 动态查询怎么写
最近一个做交易系统的同事找过来,说订单后台的筛选条件已经从最早的 4 个加到了 11 个。运营今天要按租户和状态查,明天要按金额区间和支付方式查,后天又要按关键字、渠道和是否开票查。开发一开始还很勤快,每来一个组合就写一个 Repository 方法,后来方法名越来越长:
List<Order> findByTenantIdAndStatusAndCustomerNameContainingAndCreatedAtBetween(...);
再后来,连自己都分不清该调哪个方法。我是老李,今天把 Spring Data JPA 里处理动态筛选的 Specification 思路完整拆一遍。
01、痛点
企业后台的订单查询通常有这些条件:tenant、status、customer、amount、time、keyword,还有 channel、payType、invoiced 等。它们不是固定出现,而是自由组合。理论上,10 个布尔条件能产生 1024 种组合,不可能为每种组合建立 Repository 方法。
常见的错误做法有两种:一是继续在 Service 里写大量 if-else 拼接 JPQL 字符串,条件多了以后括号、空字符串、like 特殊字符、时间边界、null 判断都非常容易出错。二是为高频组合新增专用查询方法,导致方法数量膨胀,语义混乱,后面维护的人根本不敢改。
这带来的不只是代码丑,而是测试覆盖难、SQL 问题延迟到线上、每次新增条件都牵一发动全身。我们需要一种可组合、可复用、可测试的查询抽象,这正是 Specification 的用武之地。
02、是什么
Specification 是 Spring Data JPA 提供的一个接口,用来把 JPA Criteria API 的查询条件封装成一个可组合对象。核心方法只有一个:
Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
三个参数各司其职:
- Root
:当前查询的实体根,可以把它理解成实体在查询里的别名,通过 root.get("字段名")拿路径。 - CriteriaQuery:整体查询结构,动态筛选里一般不需要动它。
- CriteriaBuilder:构造谓词的工具,提供 equal、like、greaterThan、lessThan、between、and、or、in 等方法。
Predicate 就是一个个条件表达式,比如“状态等于已支付”“金额在 100 到 500 之间”。多个 Predicate 用 cb.and(...)、cb.or(...) 组合。
Repository 只要继承 JpaSpecificationExecutor<T>,就能获得 findAll(Specification<T>, Pageable)、findAll(Specification<T>, Sort) 等方法。这样,DAO 层不再为动态条件写一堆方法,而是接收一个 Specification 对象,由它描述筛选规则。
Spring Data JPA 4.x 里更强调 PredicateSpecification 这种链式组合思路:每个筛选条件是一个独立的规格,空条件返回 null 表示不加谓词,非空条件返回 Predicate;然后用 Specification.where(...).and(...).or(...) 串联。相比自己 new Specification 匿名类,链式写法更贴近业务规则,也更方便测试。
03、为什么
为什么推荐用 Specification 处理这类问题,而不是继续堆 Repository 方法?
第一,动态筛选本质是规则集合。租户、状态、时间范围这些条件不是固定的查询签名,而是可选的过滤规则。把每个规则封装成 Specification,符合业务的自然表达。
第二,关注点分离。Repository 只关心“执行查询”,Service 只关心“从请求参数生成筛选条件”,Specification 只关心“构造谓词”。三层各干各的,不会出现 Service 里拼 SQL 字符串的坏味道。
第三,条件可复用。租户隔离、状态过滤、时间范围过滤不仅订单模块会用,售后单、结算单也可能需要类似规则。独立成方法后,可以复用到不同查询场景。
第四,组合测试更容易。单个 Specification 可以单独验证;组合后的 Builder 可以用参数化测试覆盖空条件、单条件、多条件、边界值等主要路径。相比之下,如果逻辑藏在 Repository 方法名或 JPQL 拼串里,测试成本会高很多。
第五,和分页排序天然兼容。后台列表几乎都需要分页,findAll(spec, pageable) 直接支持 PageRequest.of(page, size, Sort.by(...)),不需要额外封装。
当然,Specification 也不是万能钥匙,这一点放到第 06 节展开。
04、架构
先给出推荐的分层结构:
Controller
↓ 接收 Query 参数并转成 SearchCriteria
Service
↓ 调用 Builder 生成 Specification
Repository
↓ 执行 findAll(spec, pageable)
Database
关键点是:不要让 HttpServletRequest 或 Controller 的 Query 对象直接穿透到数据层。应该用一个独立 SearchCriteria 接收筛选意图,这样后续条件来源从 HTTP 请求变成 RPC 消息、导出任务、定时任务时,查询逻辑不用改。
订单 SearchCriteria 可以这样设计:
public class OrderSearchCriteria {
private Long tenantId;
private OrderStatus status;
private Long customerId;
private String keyword;
private BigDecimal minAmount;
private BigDecimal maxAmount;
private LocalDateTime startTime;
private LocalDateTime endTime;
private String channel;
private String payType;
private Boolean invoiced;
// 省略 getter/setter 或使用 builder
}
这里一共有 11 个条件,去掉 min/max amount 合并、start/end time 合并,最终会落地成 9 个左右独立 Specification,完全满足 8~10 个条件自由组合的需求。
Builder 的职责是:根据 SearchCriteria 里哪些字段非空,决定启用哪些 Specification。Builder 内部一般不写复杂条件逻辑,只做非空判断和链式组合。这样每个条件规则保持单一职责,后续新增条件只是新增一个方法和一行 and。
需要注意,单实体动态筛选可以放心用 Specification;一旦涉及复杂多表 Join、聚合报表,就应该重新评估,不要让 Builder 变成垃圾桶。
05、实战
下面以订单实体为例,展示具体实现。订单实体包含这些字段:id、tenantId、orderNo、status、customerId、customerName、amount、channel、payType、invoiced、createdAt、remark。
首先,Repository 继承 JpaSpecificationExecutor:
public interface OrderRepository extends JpaRepository<Order, Long>, JpaSpecificationExecutor<Order> {
}
然后,把每个条件写成独立 Specification 工厂方法:
public final class OrderSpecifications {
public static Specification<Order> tenantEq(Long tenantId) {
return (root, query, cb) ->
tenantId == null ? null : cb.equal(root.get("tenantId"), tenantId);
}
public static Specification<Order> statusEq(OrderStatus status) {
return (root, query, cb) ->
status == null ? null : cb.equal(root.get("status"), status);
}
public static Specification<Order> customerEq(Long customerId) {
return (root, query, cb) ->
customerId == null ? null : cb.equal(root.get("customerId"), customerId);
}
public static Specification<Order> keywordLike(String keyword) {
return (root, query, cb) -> {
if (!StringUtils.hasText(keyword)) {
return null;
}
String pattern = "%" + keyword.trim().toLowerCase() + "%";
return cb.or(
cb.like(cb.lower(root.get("orderNo")), pattern),
cb.like(cb.lower(root.get("customerName")), pattern),
cb.like(cb.lower(root.get("remark")), pattern)
);
};
}
public static Specification<Order> amountBetween(BigDecimal min, BigDecimal max) {
return (root, query, cb) -> {
if (min == null && max == null) {
return null;
}
if (min != null && max != null) {
return cb.between(root.get("amount"), min, max);
}
if (min != null) {
return cb.greaterThanOrEqualTo(root.get("amount"), min);
}
return cb.lessThanOrEqualTo(root.get("amount"), max);
};
}
public static Specification<Order> createdBetween(LocalDateTime start, LocalDateTime end) {
return (root, query, cb) -> {
if (start == null && end == null) {
return null;
}
if (start != null && end != null) {
return cb.between(root.get("createdAt"), start, end);
}
if (start != null) {
return cb.greaterThanOrEqualTo(root.get("createdAt"), start);
}
return cb.lessThanOrEqualTo(root.get("createdAt"), end);
};
}
// channelEq、payTypeEq、invoicedEq 与 tenantEq 类似,都是简单等值判断,这里不再重复。
}
这里每个方法都遵循同一条约定:条件无效时返回 null,条件有效时返回 Predicate。返回 null 会让 Spring Data 在组合时忽略这个条件。这样处理比在 Builder 里写一堆 if 更内聚。
接下来是 Builder:
public final class OrderSpecificationBuilder {
public static Specification<Order> from(OrderSearchCriteria c) {
return Specification
.where(OrderSpecifications.tenantEq(c.getTenantId()))
.and(OrderSpecifications.statusEq(c.getStatus()))
.and(OrderSpecifications.customerEq(c.getCustomerId()))
.and(OrderSpecifications.keywordLike(c.getKeyword()))
.and(OrderSpecifications.amountBetween(c.getMinAmount(), c.getMaxAmount()))
.and(OrderSpecifications.createdBetween(c.getStartTime(), c.getEndTime()))
.and(OrderSpecifications.channelEq(c.getChannel()))
.and(OrderSpecifications.payTypeEq(c.getPayType()))
.and(OrderSpecifications.invoicedEq(c.getInvoiced()));
}
}
Service 层变得很薄:
@Service
public class OrderQueryService {
private final OrderRepository orderRepository;
public OrderQueryService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public Page<Order> search(OrderSearchCriteria criteria, Pageable pageable) {
Specification<Order> spec = OrderSpecificationBuilder.from(criteria);
return orderRepository.findAll(spec, pageable);
}
}
测试不能只写一两个 happy path,建议用参数化测试覆盖主要组合。比如:
@ParameterizedTest
@MethodSource("orderQueryCases")
void shouldBuildSpecificationFromCriteria(OrderSearchCriteria criteria, long expectedCount) {
Specification<Order> spec = OrderSpecificationBuilder.from(criteria);
long count = orderRepository.count(spec);
assertEquals(expectedCount, count);
}
测试数据设计要覆盖:全空条件、单租户、单状态、金额区间、时间区间、关键字模糊匹配、租户加状态加金额三条件组合、只有 maxAmount 没有 minAmount、只有 startTime 没有 endTime、非法关键字无结果等。参数化测试的好处是,新增条件后只需补充一条 case,不用重写整套测试。
06、洞见
Specification 最擅长的是动态筛选,不是所有复杂查询的终极方案。
如果需求是列表页的“按条件筛订单”,单实体或少量简单 Join,用 Specification 合适。但如果需求是“生成一张多表关联的月度经营报表”,包含多个 group by、聚合函数、子查询、union、窗口函数,那么 Specification 和 Criteria API 会变得非常啰嗦,写出来的代码可读性差,维护成本高。
尤其是把大量 Join 放进 Specification 后,有两个常见坑:第一,一对多 Join 会导致结果集膨胀,分页时出现重复行,需要 distinct 或重新设计查询口径;第二,fetch join 用于解决 N+1 的写法,和分页一起使用时容易触发内存分页或条数错误,需要额外处理。
因此,我的判断标准很简单:如果查询主要是“过滤条件自由组合”,用 Specification;如果查询核心已经是“多表聚合、报表统计、复杂关系图”,就改成原生 SQL、专用报表模型或数仓方案。不要为了技术统一而把 Specification 当成唯一的查询入口。真正成熟的架构,是让动态筛选归动态筛选,复杂报表归复杂报表。
07、行动
如果今天就开始改订单高级查询,建议按下面顺序落地。
第一,建立 OrderSearchCriteria。先别碰 Repository,把 Controller 传入的条件收口到一个对象里,字段只保留筛选相关数据,不包含请求上下文。
第二,把 status、tenant、timeRange 等做成独立 Specification。每个方法只做一件事:无效返回 null,有效返回 Predicate。文件可以叫 OrderSpecifications,逐步替换 Service 里的 if-else 拼查询逻辑。
第三,建立 OrderSpecificationBuilder。用 Specification.where(...).and(...) 链式组合,Builder 只做条件非空判断和顺序组合,不写具体谓词。
第四,Repository 继承 JpaSpecificationExecutor。保留必要的简单 JPA 方法,但动态筛选统一走 findAll(spec, pageable)。
第五,用参数化测试覆盖主要组合。至少覆盖空条件、单条件、多条件、边界金额、时间边界、like 特殊字符等。测试通过后再把旧查询方法删掉,避免新旧逻辑并存。
第六,评估复杂查询边界。如果后续出现多表报表、复杂聚合需求,单独建报表查询通道,不要把复杂逻辑塞回 Specification。
08、小结
企业后台订单查询的难点不在 SQL 本身,而在条件组合如何维护。Repository 方法名穷举法在条件少时看似简单,一旦条件超过五六个就会迅速失控。Specification 把动态筛选变成一个个可组合、可复用、可测试的谓词规则,配合 JpaSpecificationExecutor 和分页排序,非常适合列表页的高级筛选。
但也要认识到它的边界:单实体动态筛选是主场,复杂 Join、聚合、报表不是它的强项。把动态筛选做干净,把复杂报表放对位置,才是后端查询层更稳妥的架构选择。
09、链接与概念说明
- Criteria API:JPA 提供的类型安全查询构建 API,通过
CriteriaBuilder创建查询条件。 - Predicate:JPA 查询中的条件表达式,多个 Predicate 可以通过
and/or组合。 - Specification:Spring Data JPA 提供的动态查询接口,核心方法是
toPredicate。 - JpaSpecificationExecutor:Repository 继承该接口后,可执行
findAll(Specification, Pageable)等查询。 - PredicateSpecification:Spring Data JPA 4.x 中链式组合 Specification 的思路,常用
Specification.where(...).and(...)表达。 - 官方文档:https://docs.spring.io/spring-data/jpa/reference/

浙公网安备 33010602011771号