Spring Data JPA Specification API

最后更新: 2022-11-09 01:13:09 +0800 +0800
tags: Java | Spring | Jpa
原文以英文撰写。您正在阅读AI翻译版本。

当 Spring Data JPA 仓库需要一个可选、可组合并在运行时决定的 WHERE 子句时,通常使用的 API 是 org.springframework.data.jpa.repository.JpaSpecificationExecutor<T>。

它连接了以下组件:

  1. Spring Data 仓库方法,例如 findAll(...)、count(...) 和 exists(...)
  2. Spring 的 org.springframework.data.jpa.domain.Specification<T>
  3. 以 jakarta.persistence.criteria.CriteriaBuilder 为核心的 Jakarta Persistence Criteria API

JpaSpecificationExecutor<T> #

假设存在一个实体:

java
@Entity
@Table(name = "orders")
public class PurchaseOrder {
    @Id
    private Long id;

    @Column(nullable = false)
    private String status;

    @Column(nullable = false)
    private String customerEmail;

    @Column(nullable = false)
    private Instant createdAt;

    @ManyToOne(fetch = FetchType.LAZY)
    private Customer customer;
}

以及一个仓库:

java
public interface PurchaseOrderRepository
        extends JpaRepository<PurchaseOrder, Long>,
                JpaSpecificationExecutor<PurchaseOrder> {
}

JpaRepository 提供 CRUD 和分页 API。JpaSpecificationExecutor 添加了一组方法,其过滤逻辑由 Specification<PurchaseOrder> 描述。

典型方法如下:

java
List<PurchaseOrder> findAll(Specification<PurchaseOrder> spec);
Page<PurchaseOrder> findAll(Specification<PurchaseOrder> spec, Pageable pageable);
List<PurchaseOrder> findAll(Specification<PurchaseOrder> spec, Sort sort);
Optional<PurchaseOrder> findOne(Specification<PurchaseOrder> spec);
long count(Specification<PurchaseOrder> spec);
boolean exists(Specification<PurchaseOrder> spec);

核心思想很简单:不需要手动拼接 JPQL 字符串。将 Specification<T> 交给 Spring Data 后,Spring Data 会为你构建 Criteria 查询。

Specification<T> 的本质 #

org.springframework.data.jpa.domain.Specification<T> 是一个函数式接口:

java
public interface Specification<T> extends Serializable {
    Predicate toPredicate(
        Root<T> root,
        CriteriaQuery<?> query,
        CriteriaBuilder criteriaBuilder
    );
}

也就是说,Specification<T> 只是一个可复用的函数,它知道如何为实体类型 T 贡献一个 Predicate。

三个参数都很重要:

  1. Root<T> 表示被查询的根表或根实体
  2. CriteriaQuery<?> 表示正在构建的完整查询
  3. CriteriaBuilder 是用于创建表达式和谓词的工厂,例如 equal、like、between、and、or、not、lower、count 等

实际上,大多数应用代码只关心 root 和 criteriaBuilder。当需要 distinct、groupBy、自定义排序或 fetch join 时,query 才会变得重要。

实际获取 Specification 的方式 #

最常见的方式是从小型静态工厂方法中返回它:

java
public final class PurchaseOrderSpecs {
    private PurchaseOrderSpecs() {}

    public static Specification<PurchaseOrder> hasStatus(String status) {
        return (root, query, cb) -> cb.equal(root.get("status"), status);
    }

    public static Specification<PurchaseOrder> customerEmailContains(String token) {
        return (root, query, cb) ->
                cb.like(
                        cb.lower(root.get("customerEmail")),
                        "%" + token.toLowerCase(Locale.ROOT) + "%"
                );
    }

    public static Specification<PurchaseOrder> createdAtGte(Instant lowerBound) {
        return (root, query, cb) -> cb.greaterThanOrEqualTo(root.get("createdAt"), lowerBound);
    }

    public static Specification<PurchaseOrder> customerIdEquals(Long customerId) {
        return (root, query, cb) -> cb.equal(root.get("customer").get("id"), customerId);
    }
}

然后将它们组合起来:

java
Specification<PurchaseOrder> spec = Specification.where(null);

if (status != null) {
    spec = spec.and(PurchaseOrderSpecs.hasStatus(status));
}
if (emailToken != null && !emailToken.isBlank()) {
    spec = spec.and(PurchaseOrderSpecs.customerEmailContains(emailToken));
}
if (createdAfter != null) {
    spec = spec.and(PurchaseOrderSpecs.createdAtGte(createdAfter));
}
if (customerId != null) {
    spec = spec.and(PurchaseOrderSpecs.customerIdEquals(customerId));
}

Page<PurchaseOrder> page =
        purchaseOrderRepository.findAll(spec, PageRequest.of(0, 20, Sort.by("createdAt").descending()));

在大多数代码库中,这个 API 的实际形式是:将请求参数转换为一组可选的 Specification,然后将它们串联起来。

另一种常见风格是保留请求对象,并由一个方法构建完整的 specification:

java
public record PurchaseOrderFilter(
        String status,
        String emailToken,
        Instant createdAfter,
        Long customerId
) {}

public static Specification<PurchaseOrder> fromFilter(PurchaseOrderFilter filter) {
    Specification<PurchaseOrder> spec = Specification.where(null);

    if (filter.status() != null) {
        spec = spec.and(hasStatus(filter.status()));
    }
    if (filter.emailToken() != null && !filter.emailToken().isBlank()) {
        spec = spec.and(customerEmailContains(filter.emailToken()));
    }
    if (filter.createdAfter() != null) {
        spec = spec.and(createdAtGte(filter.createdAfter()));
    }
    if (filter.customerId() != null) {
        spec = spec.and(customerIdEquals(filter.customerId()));
    }

    return spec;
}

CriteriaBuilder 的工作方式 #

jakarta.persistence.criteria.CriteriaBuilder 是一个工厂 API。它本身不会执行 SQL,而是创建一个表达式对象树,之后由 JPA 提供程序(例如 Hibernate)将其转换为 SQL。

其方法通常可分为以下类别:

  1. 比较谓词:equal、notEqual、greaterThan、lessThan、between
  2. 字符串操作:like、lower、upper、trim、concat
  3. null/boolean 检查:isNull、isNotNull、isTrue、isFalse
  4. 布尔组合:and、or、not、conjunction、disjunction
  5. 聚合或查询表达式:count、avg、sum、exists

示例:

java
cb.equal(root.get("status"), "PAID");
cb.like(cb.lower(root.get("customerEmail")), "%gmail.com");
cb.between(root.get("createdAt"), start, end);
cb.and(
        cb.equal(root.get("status"), "PAID"),
        cb.isNotNull(root.get("customerEmail"))
);

因此,Criteria API 本质上是一个用于构建类似 SQL 查询的类型化 AST 构建器。

相关的重要 Criteria API 类型 #

要理解 Specification,以下接口值得了解:

Root<T> #

Root<T> 是 FROM 子句中的根实体。如果仓库查询的是 PurchaseOrder,那么 root 就对应于该表或实体。

示例:

java
root.get("status");
root.get("createdAt");
root.get("customer").get("id");

Path<X> #

每个 root.get(...) 都会返回一个 Path<?>,表示对属性的导航。这正是属性访问可以链式调用的原因。

java
Path<String> email = root.get("customerEmail");
Path<Long> customerId = root.get("customer").get("id");

Predicate #

Predicate 是 toPredicate(...) 返回的布尔表达式,并会成为生成的 WHERE 子句的一部分。

CriteriaQuery<?> #

CriteriaQuery<?> 是完整的查询定义。必要时,specification 可以修改它:

java
return (root, query, cb) -> {
    query.distinct(true);
    return cb.equal(root.get("status"), "PAID");
};

对此需要谨慎:一旦 specification 开始改变查询形状,它就不再只是一个小型过滤片段。它虽然功能强大,但也会使组合行为变得不那么可预测。

Join<Z, X> #

按关联实体过滤时,显式 join 往往比链式调用 get(...) 更清晰:

java
public static Specification<PurchaseOrder> customerRegionEquals(String region) {
    return (root, query, cb) -> {
        Join<PurchaseOrder, Customer> customer = root.join("customer");
        return cb.equal(customer.get("region"), region);
    };
}

Order #

排序通常应该交给 Spring Data 的 Sort 或 Pageable 处理,而不是硬编码在 specification 中。但是,如果 specification 必须通过 query.orderBy(...) 影响排序,可以使用 CriteriaBuilder.asc(...) / desc(...)。

Specification 为什么存在 #

没有 Specification 时,通常会遇到以下两个问题之一:

  1. 出现大量类似 findByStatusAndCustomerEmailContainingAndCreatedAtAfter(...) 的仓库方法
  2. 使用包含大量可为 null 条件的手写 JPQL 字符串

Specification 通过将每个过滤器表示为可以使用 .and(...) 和 .or(...) 组合的对象或函数,解决了“动态 WHERE 子句”问题。

它尤其适用于以下场景:

  1. 包含许多可选字段的搜索界面
  2. 支持分页过滤的管理 API
  3. 可复用的业务过滤器,例如“对租户可见”、“未被软删除”或“由当前用户创建”

类型安全的访问与字符串属性名 #

示例通常使用:

java
root.get("status")

这种方式很方便,但属于字符串类型化。拼写错误只有在运行时才会失败。

如果构建过程生成了 JPA 静态元模型,可以使用更安全的形式:

java
root.get(PurchaseOrder_.status)

使用 Specification 并不要求采用这种方式,但如果希望 Criteria 代码能够安全地配合重构,它就是一个重要的配套 API。

Spring Data 在幕后为你做了什么 #

在普通应用代码中,不需要自己创建 CriteriaBuilder。Spring Data 的 JPA 仓库实现会获取 EntityManager,向其请求 CriteriaBuilder,创建 CriteriaQuery,调用你的 Specification#toPredicate(...),然后通过 JPA 提供程序执行最终查询。

因此,控制流程大致如下:

  1. 应用程序创建或组合 Specification<T>
  2. 应用程序调用 repository.findAll(spec, pageable)
  3. Spring Data 获取 EntityManager 基础设施
  4. Spring Data 构建 Criteria 查询
  5. Specification 贡献谓词树
  6. Hibernate 将 Criteria 树转换为 SQL
  7. 数据库执行 SQL

这解释了为什么 Specification 看起来很小:它只是过滤片段,而不是完整的仓库实现。

一个实用的判断标准 #

当查询仍然属于“一个实体根加一个动态谓词树”的范围时,使用 JpaSpecificationExecutor。一旦查询变成包含投影、聚合、复杂分组、供应商特定 SQL 或复杂 fetch 计划的报表,Specification API 就会变得不太适用,此时自定义仓库实现或其他查询方式通常更清晰。

不过,对于常规的搜索和过滤用例,这套结构很简洁:

  1. JpaSpecificationExecutor<T> 是仓库入口
  2. Specification<T> 是可复用的过滤器抽象
  3. CriteriaBuilder 是创建谓词/表达式树的工厂
  4. Root<T>、Path<?>、Join<?, ?>、Predicate 和 CriteriaQuery<?> 是主要的辅助类型

理解这个心智模型后,这个 API 就不再神秘。

评论

在 GitHub 上阅读或评论(发表评论需要登录)