Ohhnews

分类导航

$ cd ..
DZone Java原文

这个Spring Data JPA模式清除了三年的仓储代码债务

#spring data jpa#动态查询#代码重构#企业应用#可维护性

如果您从事企业级 Java 应用开发超过一年,很可能体验过这种特有的痛苦:产品经理要求添加一个新的搜索筛选条件,您打开仓库文件,发现里面已经有 18 个方法。您写了第 19 个、第 20 个,然后在写到第 25 个方法时开始怀疑是否有更好的方式。

确实有。它叫做 Spring Data JPA Specifications,而且它一直安静地存在于框架中。

硬编码查询方法的问题

Spring Data JPA 的派生查询方法对于简单的查找非常适用。findByEmail 清晰、可读,且无需任何 SQL。但企业搜索很少保持简单。您的 CRM 用户希望按名称 状态筛选客户。然后是按日期范围。再然后是城市。接着是一个可能匹配名称 邮箱的关键词。很快,您就需要维护一个像下面这样的仓库:

$ java
findByNameAndStatus(...)
findByNameAndStatusAndCreatedDateBetween(...)
findByNameOrEmailAndStatus(...)
findByNameContainingIgnoreCaseAndStatusAndCreatedDateBetween(...)

每增加一个新需求就意味着一个新方法。仓库变成了一个垃圾场。测试它变成了苦差事。引导新人上手变成了一场关于该使用 30 个方法中哪一个的对话。

Specifications 通过让您定义小型、可组合的查询谓词,并在运行时根据用户实际提供的筛选条件组合它们,从而解决了这个问题。

Specification 到底是什么

在底层,Specification 包装了 JPA Criteria API,这是一种通过编程方式、类型安全地构建查询的方法,无需编写原始的 SQLJPQL。Criteria API 功能强大但冗长且难以阅读。Specifications 在更清晰的表面区域下提供了这种能力。

每个 Specification 只是一个生成谓词的 lambda 表达式:(root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("status"), "ACTIVE")

仅此而已。一个条件,一个方法,可以与任何其他条件组合。

构建它:一个客户搜索示例

让我们具体化。假设有一个 Customer 实体,包含 nameemailstatuscreatedDate 字段。用户可以按这些字段的任意组合(或完全不组合)进行筛选。

实体

$ java
@Entity
public class Customer {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String email;
    private String status;
    private LocalDate createdDate;
}

一个 Specifications 工具类

我倾向于将谓词放在一个专门的类中,而不是散落在服务中:

$ java
public class CustomerSpecifications {
    public static Specification<Customer> nameContains(String name) {
        return (root, query, cb) ->
            name == null ? null : cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%");
    }

    public static Specification<Customer> emailContains(String email) {
        return (root, query, cb) ->
            email == null ? null : cb.like(cb.lower(root.get("email")), "%" + email.toLowerCase() + "%");
    }

    public static Specification<Customer> statusEquals(String status) {
        return (root, query, cb) ->
            status == null ? null : cb.equal(root.get("status"), status);
    }

    public static Specification<Customer> createdBetween(LocalDate start, LocalDate end) {
        return (root, query, cb) -> {
            if (start == null || end == null) return null;
            return cb.between(root.get("createdDate"), start, end);
        };
    }
}

null 返回是有意为之的;Spring Data JPA 会忽略 null 谓词,这意味着您可以免费获得“如果未提供则跳过此筛选条件”的行为。

仓库

您的仓库需要扩展 JpaSpecificationExecutor

$ java
public interface CustomerRepository extends JpaRepository<Customer, Long>, JpaSpecificationExecutor<Customer> {
}

在服务中组合起来

$ java
public List<Customer> searchCustomers(CustomerSearchRequest request) {
    Specification<Customer> spec = Specification
            .where(CustomerSpecifications.nameContains(request.getName()))
            .and(CustomerSpecifications.emailContains(request.getEmail()))
            .and(CustomerSpecifications.statusEquals(request.getStatus()))
            .and(CustomerSpecifications.createdBetween(request.getStartDate(), request.getEndDate()));

    return customerRepository.findAll(spec);
}

这一个 findAll 调用会根据调用者提供的筛选条件组合动态适配。没有分支逻辑,没有 20 个仓库方法。当产品经理在下个冲刺要求添加第五个筛选条件时,您只需在 CustomerSpecifications 中添加一个方法,并在服务中添加一行 .and()。搞定。

更进一步:OR 条件、连接和分页

OR 条件

.or() 组合器的工作方式与预期完全一致。一个同时检查名称或邮箱的全局搜索栏:

$ java
Specification<Customer> spec = Specification
        .where(CustomerSpecifications.nameContains(keyword))
        .or(CustomerSpecifications.emailContains(keyword));

跨连接筛选

如果您的 Customer 有一个嵌套的 Address,您可以在不修改服务层的情况下触及它:

$ java
public static Specification<Customer> cityEquals(String city) {
    return (root, query, cb) ->
        city == null ? null : cb.equal(root.join("address").get("city"), city);
}

连接发生在了 Specification 内部。您的服务代码依然整洁。

分页

由于 JpaSpecificationExecutor 暴露了一个 findAll(Specification, Pageable) 的重载,添加分页只需一行代码:

$ java
Page<Customer> page = customerRepository.findAll(spec, PageRequest.of(0, 20, Sort.by("name")));

我在实践中见过的错误

  • 为 null 筛选条件返回非 null 谓词:这是最常见的陷阱。如果您忘记检查 null 并仍然返回了一个有效谓词,您会静默地过滤掉本应返回的数据。始终在 lambda 开头进行保护。
  • 将业务逻辑混入 Specifications:一个 Specification 应该只做一件事:生成一个谓词。我见过记录日志、调用服务、检查权限的 Specifications。不要这样做。保持它们的纯粹性。
  • 创建一个处理所有筛选条件的“God Specification”:这只不过是把臃肿的仓库问题变成了臃肿的 Specification 问题。小型、单一职责的 Specifications 保持可测试和可复用。一个 statusEquals 的 Specification 可以服务于您的搜索界面、报表模块和管理仪表盘,而它们彼此之间无需了解。
  • 字符串搜索跳过大小写规范cb.like(root.get("name"), "%dzone%") 不会匹配 "DZone" 或 "DZONE"。始终进行规范化:使用 cb.lower(root.get("name")) 配合输入内容的小写化。

为什么这种方案会随着时间的推移带来回报

Specifications 真正的红利出现在引入它们六个月之后,当需求发生变化时——而需求总是会变化。添加一个筛选条件?一个新的静态方法,一行 .and()。删除一个筛选条件?删除方法和组合行。在两个功能间复用某个筛选条件?导入同一个 Specification 类。对筛选条件进行单元测试?实例化 Specification,传入一个 mock 的 CriteriaBuilder,断言谓词。无需 Spring 上下文。

在复杂的企业级代码库中——那种拥有多个开发团队、不断演化的产品需求和长期维护周期的代码库——这种模块化的价值远比初听起来要大得多。

最后的想法

Specifications 并不新奇。它们是 Spring Data JPA 标准库的一部分;它们与您已有的所有东西都能协同工作,并且它们解决了每个拥有搜索界面的团队最终都会遇到的问题。

如果您的仓库开始看起来像一个按字母顺序排列的、包含用户曾经请求过的所有筛选条件组合的索引,那么现在就是切换的好时机。

DZone 贡献者表达的观点仅代表其个人观点。