《Spring Boot + JPA 实战》第6招 方法名查询边界

上周我在评审一个订单查询接口,PR 里躺着一行方法名:

List<Order> findByCustomerIdAndStatusAndCreatedAtBetweenAndOrderNoContainingOrderByCreatedAtDesc(Long customerId, OrderStatus status, LocalDateTime start, LocalDateTime end, String orderNo);

我是老李,看到这行代码的时候,脑子里先想的不是业务,而是这个方法的拼写到底有没有错。更要命的是,这还只是开始。

现在为什么讲这个?因为太多团队把 Spring Data JPA 的 Derived Query 当成了唯一查询手段。早期确实爽,方法名越长、条件越动态,后面维护成本就越高。

01、痛点

上面这行方法名不是段子。它对应的是订单中心一个很常见的查询:根据客户、状态、创建时间区间、订单号模糊搜索,再按创建时间倒序。需求本身不复杂,但被 Derived Query 一路带飞后,代码看起来像在玩拼字游戏。

这类查询有三个典型痛点。第一,方法名失控。只要加一个条件,方法名就多一截。开发一开始觉得省事,三个月后自己都不敢确认 Between 后面到底接了几个参数。第二,动态条件无法表达。真实业务里,客户号可能为空,状态可能为空,创建时间可能只传开始或者只传结束。Derived Query 没法优雅地跳过 null,只能写一堆组合方法,2 个条件 4 个方法,3 个条件 8 个,4 个条件 16 个,指数级爆炸。第三,修改成本高。字段一旦改名,所有相关方法名都要重写;条件组合有变化,方法签名也要变;接口一旦发布,这些长名字还会渗透到调用方和测试用例里。

更难受的是,这类代码往往出现在核心 Repository 里,和业务强绑定。它既不是标准 SQL,也不是清晰的业务语言,时间一久,新同事会把它当成模板继续复制,于是 Repository 里堆满了一百多个长得差不多的方法。

02、是什么

Spring Data JPA 的 Derived Query 不是魔法。它是在 Repository 接口方法名上定义查询规则,框架启动时解析方法名,自动生成对应查询。本质是一种声明式查询 DSL。

常用前缀有:findBy、readBy、queryBy、getBy 用于查询;existsBy 判断存在;countBy 计数;deleteBy、removeBy 删除。条件关键字包括:And、Or、Between、In、NotIn、Containing、StartingWith、EndingWith、IgnoreCase、OrderBy 等。属性名直接拼接,比如 findByCustomerId 就是根据 customerId 查询。

举几个最基础的例子:

Optional<Order> findByOrderNo(String orderNo);
List<Order> findByCustomerIdOrderByCreatedAtDesc(Long customerId);
List<Order> findByStatusAndCreatedAtBetween(OrderStatus status, LocalDateTime start, LocalDateTime end);
boolean existsByOrderNo(String orderNo);
long countByStatus(OrderStatus status);

这些方法没有额外注解,没有 XML,甚至没有 SQL。只要属性名和关键字对得上,Spring Data 就能在启动时自动校验,查错早。

Derived Query 的能力边界也很清晰。它适合单表查询、简单条件、固定组合。一旦涉及多表关联、聚合统计、子查询、复杂排序,它很难表达;即便能拼出来,方法名也会变得极不友好。

03、为什么

Derived Query 之所以受欢迎,是因为它把查询成本压得很低。新建方法只写一行,不用写 JPQL,不用注入 EntityManager,不用维护 SQL 文件;实体属性变了,方法名会在启动时报错,而不是等到线上 SQL 执行失败。对于稳定、明确的小查询,它的开发效率确实高。

但它有两个容易被忽视的代价。

第一,方法名就是接口契约。方法名越长,契约越脆弱。你以为只是内部实现,实际上它暴露了字段、条件、顺序,甚至排序方向。一旦条件增多,调用方需要理解的信息量直线上升。

第二,它不处理动态性。Derived Query 的每个条件都是必填的。如果客户号可空、状态可空、时间区间可空,你就没法用 null 来表示“忽略这个条件”。框架不会帮你跳过 null,于是只能靠组合方法覆盖所有情况。

为什么现在尤其要讲?因为订单、用户、商品这类核心业务,查询条件天然是动态的。后端接口往往要在同一个搜索框里兼容各种筛选,前端可能只传一个关键字,也可能传五个筛选条件。这种情况下,继续堆 Derived Query 已经不是效率问题,而是设计问题。

04、架构

从分层上看,Repository 是持久化层的入口,Service 负责业务编排,Controller 负责协议转换。查询规则应该被限制在 Repository 内部,不让它污染业务层。

Controller -> Service -> Repository -> Entity

查询 DSL 可以按复杂度分层:

  • 简单、稳定、明确:Derived Query
  • 固定但复杂:JPQL
  • 动态组合:Specification / QueryDSL
  • 报表、聚合、原生能力:Native SQL

这里的核心不是选哪个技术,而是给团队建立一条清晰边界:什么样的查询写在方法名里,什么样的查询必须升级。

Repository 的职责是表达业务查询意图,而不是翻译 SQL。一个方法名应该能读成一句业务问题,比如“根据订单号查订单”“根据客户和时间查订单列表”。如果方法名读出来像 SQL 拼接,就说明放错了层。

另外,Projection 也属于查询架构的一部分。当接口只需要订单号、状态、创建时间三个字段时,不应该把整个 Order 实体查出来。用接口投影或者类投影只取需要的字段,既减少 IO,也让接口边界更清晰。

05、实战

以订单查询为例,假设 Order 实体如下:

@Entity
public class Order {
    @Id
    private Long id;
    private String orderNo;
    private Long customerId;
    private OrderStatus status;
    private LocalDateTime createdAt;
    // getter / setter
}

基础查询可以这样写:

public interface OrderRepository extends JpaRepository<Order, Long> {
    Optional<Order> findByOrderNo(String orderNo);
    List<Order> findByCustomerIdOrderByCreatedAtDesc(Long customerId);
    List<Order> findByStatusAndCreatedAtBetween(OrderStatus status, LocalDateTime start, LocalDateTime end);
    List<Order> findByCustomerIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc(
        Long customerId, OrderStatus status, LocalDateTime start, LocalDateTime end);
    boolean existsByOrderNo(String orderNo);
    long countByStatus(OrderStatus status);
    void deleteByCustomerId(Long customerId);
}

这三个字段组合起来还可以接受,但现实中往往还要加上订单号模糊搜索、金额区间、是否加急等条件。方法名会迅速变成:

List<Order> findByCustomerIdAndStatusAndCreatedAtBetweenAndOrderNoContainingOrderByCreatedAtDesc(
    Long customerId, OrderStatus status, LocalDateTime start, LocalDateTime end, String orderNo);

到这里已经超过 80 个字符,可读性很差。更要命的是,这个查询的四个条件通常是可选的:可能只传 customerId,可能只传 orderNo,可能只传 status 和时间。用 Derived Query 只能写一堆组合,或者写一个超长方法然后拼 null,但框架不会自动忽略 null。

这时候应该升级到 Specification。

public interface OrderRepository extends JpaRepository<Order, Long>,
        JpaSpecificationExecutor<Order> {
}
Specification<Order> spec = (root, query, cb) -> {
    List<Predicate> predicates = new ArrayList<>();
    if (customerId != null) {
        predicates.add(cb.equal(root.get("customerId"), customerId));
    }
    if (status != null) {
        predicates.add(cb.equal(root.get("status"), status));
    }
    if (start != null && end != null) {
        predicates.add(cb.between(root.get("createdAt"), start, end));
    }
    if (orderNo != null && !orderNo.isBlank()) {
        predicates.add(cb.like(root.get("orderNo"), "%" + orderNo + "%"));
    }
    return cb.and(predicates.toArray(new Predicate[0]));
};

Page<Order> page = orderRepository.findAll(spec,
    PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")));

如果查询固定但复杂,也可以用 JPQL:

@Query("select o from Order o where o.customerId = :customerId 
       and o.status = :status and o.createdAt between :start and :end 
       order by o.createdAt desc")
Page<Order> search(@Param("customerId") Long customerId,
                   @Param("status") OrderStatus status,
                   @Param("start") LocalDateTime start,
                   @Param("end") LocalDateTime end,
                   Pageable pageable);

如果只返回少量字段,建议使用 Projection:

public interface OrderSummary {
    String getOrderNo();
    OrderStatus getStatus();
    LocalDateTime getCreatedAt();
}

List<OrderSummary> findByCustomerIdOrderByCreatedAtDesc(Long customerId);

这样,订单号、状态、创建时间的查询就有了三条路径:简单且固定用方法名,动态组合用 Specification,固定复杂或统计用 JPQL。

06、洞见

Derived Query 的最佳位置是简单、稳定、明确的查询。它像一把快刀,适合切豆腐,不适合剁骨头。

一旦出现以下信号,就不该继续堆方法名:

  • 方法名超过 35 到 40 个字符,读起来已经需要换气;
  • 参数超过 3 个,且未来还可能增加;
  • 任意条件可空,需要动态组合;
  • 涉及多表关联、聚合、子查询;
  • 返回字段与实体差异很大。

这些信号的本质是:查询复杂度已经超过声明式方法名的表达能力。继续硬写,不只是长,而是把查询逻辑散布在方法名、参数顺序、关键字组合里,变得不可测试、不可复用。

真正稳定的查询,往往业务语义非常清晰,比如“根据订单号查订单”“根据客户查订单列表”。这种查询几年都不变,写在方法名里效率最高。而“按条件搜索订单”本身就是动态需求,应该用 Specification 之类的工具去表达。

我的判断很简单:方法名是业务语言,不是 SQL 的拼音。能读出业务问题,就留在方法名;读出来像筛选器拼接,就升级。

07、行动

我给自己和团队定了一条 Query Complexity Rule,包含六个停止点。

场景 决策
条件数 ≤2,且全部必填 使用 Derived Query
条件数 3~4,且稳定明确 可以继续用,但方法名必须读得通
条件数 >4,或任一条件可选 升级 Specification
多表关联、统计、报表 使用 JPQL 或原生 SQL
只返回部分字段 使用 Projection
方法名超过 40 个字符 停止,重构为 JPQL 或 Specification

具体到订单查询:

  • findByOrderNo 继续用 Derived Query;
  • findByCustomerIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc 虽然长,但条件是固定的,可以保留;
  • 一旦加上 orderNo 模糊搜索,或让条件可空,就改为 Specification;
  • 统计每日订单量、按状态汇总金额,直接用 JPQL;
  • 列表页只展示订单号、状态、创建时间,定义 OrderSummary 投影。

这套规则不难,关键是执行。代码评审时看到超长方法名,应该直接打回;新需求进来,先问条件是固定还是动态,再决定写在哪个层。

如果你现在正在维护一个方法名已经失控的 Repository,不需要一次性重写。新建查询时遵守规则,老查询在修改时顺手重构,慢慢就能把边界拉回来。

08、小结

Derived Query 是 Spring Data JPA 中非常高效的能力,但它不是唯一的查询手段。简单、稳定、明确的查询用它没问题;当方法名越来越长、条件越来越动态时,失控的不是框架,而是我们的边界意识。

给自己立一条 Query Complexity Rule,比记住所有方法名规则更重要。知道什么时候继续写方法名,什么时候升级到 JPQL、Specification 或 Projection,才能让订单查询既好写,又好维护。

09、链接与概念说明

概念说明:

  • Derived Query:通过解析 Repository 方法名生成查询的机制,适合单表单条件或固定组合。
  • JPQL:面向实体的查询语言,类似 SQL 但操作的是实体和属性,适合固定但复杂的查询。
  • Specification:JPA Criteria API 的动态查询封装,适合条件可选、组合多变的查询。
  • Projection:只查询实体部分字段的机制,可以用接口或类接收结果,适合列表页和读多写少场景。
  • QueryDSL:类型安全的动态查询框架,可以作为 Specification 的替代方案。

参考链接:

  • Spring Data JPA 官方文档:派生查询与关键字列表
  • JPA 2.2 Specification 文档
  • Baeldung 的 Spring Data JPA Derived Query 教程
  • Thorben Janssen 的 JPA 性能与查询建议
posted @ 2026-10-10 23:51  李福春  阅读(3)  评论(0)    收藏  举报