《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/
posted @ 2026-10-10 23:48  李福春  阅读(2)  评论(0)    收藏  举报