Spring Data JPA高级特性详解
定位:第 02 篇,讲透 Specification 动态查询、投影、自定义仓库实现与领域事件
适用版本:Spring Data JPA 3.x(Hibernate 6.x)
目录
- [一、Specification 动态查询](#一、Specification 动态查询)
- 二、投影(Projection)
- 三、自定义仓库实现
- 四、领域事件与回调
- 五、总结
- 六、常见高频面试题
一、Specification 动态查询
1.1 解决的问题
派生查询与 @Query 都是静态 的------条件固定。后台筛选场景条件动态(用户可能填任意条件组合),方法名无法枚举,@Query 拼字符串易错。Specification 提供运行时组合谓词的能力。
1.2 用法
java
public interface OrderRepository extends JpaRepository<Order, Long>,
JpaSpecificationExecutor<Order> { }
java
public Page<Order> search(OrderQuery q, Pageable pageable) {
Specification<Order> spec = (root, query, cb) -> {
List<Predicate> ps = new ArrayList<>();
if (q.getStatus() != null) {
ps.add(cb.equal(root.get("status"), q.getStatus()));
}
if (q.getMinAmount() != null) {
ps.add(cb.greaterThan(root.get("amount"), q.getMinAmount()));
}
if (q.getStartTime() != null) {
ps.add(cb.greaterThanOrEqualTo(root.get("createTime"), q.getStartTime()));
}
return cb.and(ps.toArray(new Predicate[0]));
};
return orderRepository.findAll(spec, pageable);
}
1.3 要点
| 要点 | 说明 |
|---|---|
| 前提 | 仓库继承 JpaSpecificationExecutor |
| 组合 | Specification 可用 .and()/.or() 链式合并 |
| 分页 | findAll(spec, pageable) 天然支持分页 |
| 关联 | root.get("user").get("name") 跨关联属性 |
| 边界 | 本质仍是 JPA Criteria------表达力受 JPQL 上限约束,超复杂仍用原生 |
1.4 选型位置
固定简单条件 → 派生查询
固定复杂条件 → @Query(JPQL/原生)
动态条件组合 → Specification
二、投影(Projection)
2.1 为什么需要投影
列表页只要 3 个字段,加载完整实体是浪费(且触发懒加载风险)。投影让查询只映射所需。
2.2 接口投影
java
public interface OrderSummary {
Long getId();
BigDecimal getAmount();
String getUserLogin(); // 属性路径:user.login
}
List<OrderSummary> findByStatus(String status);
| 类型 | 特点 |
|---|---|
| 封闭投影 | 只有 getter,映射固定属性 |
| 开放投影 | @Value("#{target.amount.add(target.fee)}") 带表达式 |
| 动态投影 | 方法参数传投影类型,一个方法多种返回 |
2.3 DTO 投影
java
public record OrderDTO(Long id, BigDecimal amount, String userName) {}
@Query("SELECT new com.demo.OrderDTO(o.id, o.amount, u.name) FROM Order o JOIN o.user u")
List<OrderDTO> listDTO();
接口投影无需类但受属性路径限制;DTO 投影显式可控、可跨关联自由组合------复杂列表查询首选 DTO 投影。
三、自定义仓库实现
3.1 片段(Fragment)模式
标准仓库方法之外,某些操作不适合用方法名/JPQL 表达(如复杂批量更新、调用存储特定能力):
java
// 1. 自定义接口
public interface UserRepositoryCustom {
int bulkUpdateStatus(List<Long> ids, String status);
}
// 2. 实现类(命名:仓库名 + Impl)
public class UserRepositoryImpl implements UserRepositoryCustom {
@PersistenceContext private EntityManager em;
public int bulkUpdateStatus(List<Long> ids, String status) {
return em.createQuery("UPDATE User u SET u.status = :s WHERE u.id IN :ids")
.setParameter("s", status).setParameter("ids", ids)
.executeUpdate();
}
}
// 3. 主仓库继承自定义接口------两者方法合并可用
public interface UserRepository extends JpaRepository<User, Long>, UserRepositoryCustom { }
框架把自定义实现"装配"进仓库代理------对外仍是一个仓库。
四、领域事件与回调
4.1 @DomainEvents
聚合根在保存时自动发布携带的领域事件:
java
@Entity
public class Order {
@Transient
private final List<Object> events = new ArrayList<>();
public void markPaid() {
this.status = PAID;
events.add(new OrderPaidEvent(this.id)); // 领域逻辑内产生事件
}
@DomainEvents List<Object> events() { return events; }
@AfterDomainEventPublication void clear() { events.clear(); }
}
repository.save(order) 时,Spring Data 自动把 events() 返回的事件发布到应用上下文------配合 @TransactionalEventListener(AFTER_COMMIT)在事务提交后处理副作用。
4.2 价值
领域行为内聚事件:状态变更与事件在同一处定义
副作用延迟到提交后:避免"事务回滚了副作用已发生"
解耦:保存方不直接调用下游服务
五、总结
- Specification:JpaSpecificationExecutor + 运行时组合 Predicate,解决动态条件筛选;与分页组合;表达力受 JPA 上限约束。
- 投影:接口投影(封闭/开放/动态)与 DTO 投影;复杂列表查询首选 DTO 投影,避免加载完整实体。
- 自定义实现:自定义接口 + Impl 类,框架合并进仓库代理,承载方法名/JPQL 表达不了的逻辑。
- 领域事件:@DomainEvents 让聚合根保存时自动发布事件,配合事务事件监听实现"提交后副作用"的解耦模式。
六、常见高频面试题
1. 动态多条件查询在 Spring Data JPA 里怎么做?
要点:用 Specification。仓库继承 JpaSpecificationExecutor;编写 Specification(root/query/cb),按入参判空动态组装 Predicate(equal/greaterThan/like 等),多个条件 cb.and 合并;调 findAll(spec, pageable) 天然带分页。适合后台筛选这类"条件组合未知"的场景。对比:固定条件用派生查询/@Query,超复杂用原生。可封装工具类把常用条件构造方法化复用。
2. Specification 和 @Query 怎么选?
要点:@Query 是静态查询------语句固定,适合条件明确的场景,可读性好、便于审查优化;Specification 是动态查询------运行时组合谓词,适合条件任意组合(后台筛选)。也可混合:固定部分 @Query + 动态部分 Specification 较难合并,通常二选一。注意 Specification 本质是 Criteria API,写法较冗长,可封装条件构造器;表达力受 JPQL 限制,极复杂场景仍需原生查询。
3. 什么是投影?接口投影和 DTO 投影的区别?
要点:投影让查询只映射所需字段而非完整实体,减少数据传输与懒加载风险。接口投影:定义接口,getter 名对应属性(或属性路径),框架动态实现;支持开放投影(@Value 表达式)与动态投影(参数传类型)。DTO 投影:定义类/record,用构造器表达式(SELECT new ...)映射,可自由跨关联组合字段。选择:简单属性映射用接口投影省事;复杂列表、跨关联、需类型确定用 DTO 投影。
4. 只查列表页需要的几个字段,怎么做最优?
要点:用投影/DTO 查询,不加载完整实体。方案:接口投影(定义只含所需属性的接口)或 DTO 投影(JPQL 构造器表达式);配合分页。好处:减少查询数据量与内存、避免序列化实体触发懒加载、接口与实体解耦。避免的反模式:findAll 全实体再内存过滤、或序列化实体时靠 @JsonIgnore 遮字段(数据已加载,只是不输出)。读路径"按需取数"是持久层性能的基本功。
5. 如何给仓库添加自定义方法实现?
要点:片段模式。① 定义自定义接口(如 UserRepositoryCustom)声明方法;② 写实现类,命名约定为"主仓库名 + Impl"(UserRepositoryImpl),内部可用 EntityManager 等任意手段;③ 主仓库接口继承自定义接口。Spring Data 创建仓库代理时把自定义实现合并进来,调用方像用标准方法一样使用。适合批量操作、存储特定能力、复杂更新等声明式查询表达不了的逻辑。
6. @DomainEvents 是怎么工作的?解决什么问题?
要点:实体(聚合根)用 @DomainEvents 标注一个返回事件集合的方法,repository.save() 时 Spring Data 自动把这些事件发布到应用事件系统(发布后调 @AfterDomainEventPublication 标注的清理方法)。解决:领域状态变更产生的副作用(发通知、同步索引)与保存动作解耦------事件在实体内部产生,配合 @TransactionalEventListener 在事务提交后执行,避免回滚后副作用已发生。是"领域事件驱动"在持久层的落地。
7. Specification 能做关联查询吗?有什么限制?
要点:可以------通过 root.get("user").get("name") 沿关联路径访问属性,生成 JOIN;也可 join 显式控制(root.join("items"))与 fetch(注意分页 + fetch 的内存分页警告)。限制:本质是 JPA Criteria,表达力等同 JPQL------数据库特定函数、复杂统计、多子查询等可能表达困难或不可移植;复杂到一定程度应降级原生查询。另外注意懒加载关联的 N+1 在投影查询中同样存在,按需 fetch。
8. Spring Data JPA 的分页 + Specification 底层执行了什么?
要点:两条查询。① 数据查询:把 Specification 谓词与 Pageable 翻译成 JPQL/SQL,加排序与 LIMIT/OFFSET 取本页;② 计数查询:同样条件的 count(*) 得总数,填充 Page 元信息。注意:有复杂 join/fetch 时 count 可能出错或低效;深分页的大 OFFSET 与 count 都是性能点,超大列表考虑 Slice(省 count)或游标。排序字段来自前端要白名单校验。
9. 如何封装通用的动态查询条件,避免重复代码?
要点:把 Specification 条件构造方法化/工具化。常见做法:① 查询对象(Query DTO)+ 对应的 Specification 构建器------每个字段一个静态构造方法(statusEq、amountGt),调用方按需 and 组合;② 通用匹配器------按查询对象的注解/约定自动反射生成条件(简单场景);③ 基础 Specification 抽象类沉淀公共条件(租户过滤、软删除过滤)。原则:条件逻辑集中可复用,仓库保持薄。
10. 领域事件和直接调用下游服务有什么区别?
要点:直接调用是同步耦合------保存逻辑里显式调通知/积分服务,依赖关系硬编码,且时机难控制(事务内调用,回滚后副作用已发生或超时拖长事务)。领域事件是解耦------实体只声明"发生了什么",监听方决定响应;配合 @TransactionalEventListener(AFTER_COMMIT) 保证事务提交后才执行副作用,回滚不产生副作用。代价:异步处理需考虑失败重试与幂等,一致性是最终一致。适合副作用多、可异步的场景。
