Specifications 与 QueryDSL
概述
在复杂的企业级应用中,动态查询是核心需求。Spring Data JPA 提供了两种强大的解决方案:Specifications(基于 JPA Criteria API)和 QueryDSL。本文档将深入介绍这两种技术及其在实际项目中的应用。
1. JpaSpecificationExecutor 接口
JpaSpecificationExecutor 是 Spring Data JPA 提供的接口,用于支持基于 Specification 对象的动态查询。让 Repository 具备该能力,只需在继承 JpaRepository 的同时也继承 JpaSpecificationExecutor。
1.1 定义 Repository
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface UserRepository extends JpaRepository<User, Long>,
JpaSpecificationExecutor<User> {
}1.2 接口方法详解
import org.springframework.data.domain.*;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.lang.Nullable;
import java.util.List;
import java.util.Optional;
public interface JpaSpecificationExecutor<T> {
Optional<T> findOne(@Nullable Specification<T> spec);
List<T> findAll(@Nullable Specification<T> spec);
Page<T> findAll(@Nullable Specification<T> spec, Pageable pageable);
List<T> findAll(@Nullable Specification<T> spec, Sort sort);
long count(@Nullable Specification<T> spec);
boolean exists(@Nullable Specification<T> spec);
}| 方法 | 说明 |
|---|---|
findOne(Specification) | 返回满足条件的单个实体 |
findAll(Specification) | 返回满足条件的所有实体 |
findAll(Specification, Pageable) | 分页返回满足条件的实体 |
findAll(Specification, Sort) | 排序返回满足条件的实体 |
count(Specification) | 统计满足条件的记录数 |
exists(Specification) | 判断是否存在满足条件的记录 |
1.3 Specification 接口
Specification 是一个函数式接口,核心方法 toPredicate 接收三个参数:Root<T>(实体根)、CriteriaQuery<?>(查询结构)、CriteriaBuilder(条件构建器)。
import javax.persistence.criteria.*;
import org.springframework.lang.Nullable;
public interface Specification<T> {
@Nullable
Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder);
default Specification<T> and(@Nullable Specification<T> other) { ... }
default Specification<T> or(@Nullable Specification<T> other) { ... }
static <T> Specification<T> not(@Nullable Specification<T> spec) { ... }
static <T> Specification<T> where(@Nullable Specification<T> spec) { ... }
}2. Criteria API 动态查询
JPA Criteria API 是 Specification 底层实现的基础,是一套类型安全的动态查询构建 API。
2.1 核心接口
| 接口 | 作用 |
|---|---|
CriteriaBuilder | 工厂类,构建 CriteriaQuery、Predicate 和各种表达式 |
CriteriaQuery | 定义查询结构(SELECT、FROM、WHERE、ORDER BY 等) |
Predicate | 表示一个过滤条件(WHERE 子句中的单个条件) |
Root | 表示 FROM 子句中的实体根,用于导航到实体属性 |
2.2 基本用法
import javax.persistence.criteria.*;
import org.springframework.data.jpa.domain.Specification;
Specification<User> spec = (root, query, cb) ->
cb.equal(root.get("username"), "admin");
List<User> users = userRepository.findAll(spec);2.3 常用 CriteriaBuilder 方法
Predicate p1 = cb.equal(root.get("username"), "admin"); // 等于
Predicate p2 = cb.notEqual(root.get("status"), 0); // 不等于
Predicate p3 = cb.like(root.get("email"), "%@example.com%"); // 模糊匹配
Predicate p4 = cb.between(root.get("age"), 18, 60); // 范围
Predicate p5 = cb.greaterThan(root.get("createdAt"), localDate); // 大于
Predicate p6 = cb.lessThanOrEqualTo(root.get("updatedAt"), now); // 小于等于
Predicate p7 = root.get("status").in(Arrays.asList(1, 2, 3)); // IN 条件
Predicate p8 = cb.isNull(root.get("deletedAt")); // IS NULL
Predicate p9 = cb.isNotNull(root.get("email")); // IS NOT NULL
Predicate p10 = cb.equal(cb.lower(root.get("username")), "admin");// 忽略大小写2.4 路径导航(关联查询)
import javax.persistence.criteria.Join;
import javax.persistence.criteria.JoinType;
// 单层关联:User -> Department
Join<User, Department> deptJoin = root.join("department", JoinType.LEFT);
Predicate deptPredicate = cb.equal(deptJoin.get("name"), "技术部");
// 多层关联:User -> Department -> Company
Join<Department, Company> companyJoin = deptJoin.join("company", JoinType.INNER);
Predicate companyPredicate = cb.like(companyJoin.get("name"), "%集团%");
// 集合关联:User -> List<Role>
Join<User, Role> roleJoin = root.join("roles", JoinType.LEFT);
Predicate rolePredicate = roleJoin.get("code").in(Arrays.asList("ADMIN", "MANAGER"));注意:使用
JoinType.LEFT避免关联实体不存在导致数据遗漏。@OneToMany和@ManyToMany关联需结合query.distinct(true)去重。
3. Predicate 组合
3.1 and 组合
// 方式一:criteriaBuilder.and()
Specification<User> spec1 = (root, query, cb) -> {
Predicate p1 = cb.equal(root.get("status"), 1);
Predicate p2 = cb.like(root.get("username"), "admin%");
return cb.and(p1, p2);
};
// 方式二:Specification 链式调用
Specification<User> spec2 = Specification
.where((r, q, cb) -> cb.equal(r.get("status"), 1))
.and((r, q, cb) -> cb.like(r.get("username"), "admin%"));3.2 or 组合
// 方式一:criteriaBuilder.or()
Specification<User> spec1 = (root, query, cb) -> {
Predicate p1 = cb.equal(root.get("status"), 0);
Predicate p2 = cb.isNull(root.get("deletedAt"));
return cb.or(p1, p2);
};
// 方式二:Specification 链式调用
Specification<User> spec2 = Specification
.where((r, q, cb) -> cb.equal(r.get("status"), 0))
.or((r, q, cb) -> cb.isNull(r.get("deletedAt")));3.3 not 取反
Specification<User> spec1 = (root, query, cb) ->
cb.not(cb.equal(root.get("locked"), true));
Specification<User> spec2 = Specification.not(
(root, query, cb) -> cb.equal(root.get("locked"), true));3.4 复杂组合
// (status = 1 AND age >= 18) OR (vipLevel IN (1,2) AND registerDate > '2024-01-01')
Specification<User> spec = (root, query, cb) -> {
Predicate group1 = cb.and(cb.equal(root.get("status"), 1),
cb.greaterThanOrEqualTo(root.get("age"), 18));
Predicate group2 = cb.and(root.get("vipLevel").in(Arrays.asList(1, 2)),
cb.greaterThan(root.get("registerDate"), LocalDate.of(2024, 1, 1)));
return cb.or(group1, group2);
};3.5 动态 List 收集模式(推荐)
通过 List<Predicate> 动态收集条件,是实际项目中最常用的模式。
import org.springframework.data.jpa.domain.Specification;
import org.springframework.util.StringUtils;
import javax.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.List;
Specification<User> dynamicSpec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (StringUtils.hasText(username))
predicates.add(cb.like(root.get("username"), "%" + username + "%"));
if (status != null)
predicates.add(cb.equal(root.get("status"), status));
if (startDate != null)
predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startDate));
if (endDate != null)
predicates.add(cb.lessThanOrEqualTo(root.get("createdAt"), endDate));
if (deptId != null) {
Join<User, Department> deptJoin = root.join("department");
predicates.add(cb.equal(deptJoin.get("id"), deptId));
}
return cb.and(predicates.toArray(new Predicate[0]));
};4. Specification 工具类(静态工厂方法)
提升代码复用性,将通用 Specification 封装为静态工厂方法。
import org.springframework.data.jpa.domain.Specification;
import org.springframework.lang.Nullable;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import javax.persistence.criteria.JoinType;
import java.time.LocalDate;
import java.util.Collection;
public final class JpaSpecificationUtils {
private JpaSpecificationUtils() {}
public static <T> Specification<T> equal(String field, @Nullable Object value) {
return (r, q, cb) -> value == null ? null : cb.equal(r.get(field), value);
}
public static <T> Specification<T> like(String field, @Nullable String value) {
return (r, q, cb) -> !StringUtils.hasText(value) ? null
: cb.like(r.get(field), "%" + value + "%");
}
public static <T> Specification<T> likePrefix(String field, @Nullable String value) {
return (r, q, cb) -> !StringUtils.hasText(value) ? null
: cb.like(r.get(field), value + "%");
}
public static <T> Specification<T> in(String field, @Nullable Collection<?> values) {
return (r, q, cb) -> CollectionUtils.isEmpty(values) ? null : r.get(field).in(values);
}
public static <T> Specification<T> greaterThanOrEqualTo(
String field, @Nullable Comparable<?> value) {
return (r, q, cb) -> value == null ? null
: cb.greaterThanOrEqualTo(r.get(field).as(value.getClass()), value);
}
public static <T> Specification<T> lessThanOrEqualTo(
String field, @Nullable Comparable<?> value) {
return (r, q, cb) -> value == null ? null
: cb.lessThanOrEqualTo(r.get(field).as(value.getClass()), value);
}
public static <T> Specification<T> between(
String field, @Nullable Comparable<?> min, @Nullable Comparable<?> max) {
return (r, q, cb) -> {
if (min == null && max == null) return null;
if (min == null) return cb.lessThanOrEqualTo(r.get(field).as(max.getClass()), max);
if (max == null) return cb.greaterThanOrEqualTo(r.get(field).as(min.getClass()), min);
return cb.between(r.get(field).as(min.getClass()), min, max);
};
}
public static <T> Specification<T> dateBetween(
String field, @Nullable LocalDate start, @Nullable LocalDate end) {
return (r, q, cb) -> {
if (start == null && end == null) return null;
if (start == null) return cb.lessThanOrEqualTo(r.get(field), end);
if (end == null) return cb.greaterThanOrEqualTo(r.get(field), start);
return cb.between(r.get(field), start, end);
};
}
public static <T> Specification<T> joinEqual(
String joinField, String targetField, @Nullable Object value) {
return (r, q, cb) -> value == null ? null
: cb.equal(r.join(joinField, JoinType.LEFT).get(targetField), value);
}
}使用示例:
Specification<User> spec = Specification
.where(JpaSpecificationUtils.equal("status", 1))
.and(JpaSpecificationUtils.like("username", searchKeyword))
.and(JpaSpecificationUtils.dateBetween("createdAt", startDate, endDate))
.and(JpaSpecificationUtils.joinEqual("department", "id", deptId));
Page<User> page = userRepository.findAll(spec,
PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")));5. QueryDSL 集成
QueryDSL 在编译期通过 APT(Annotation Processing Tool)生成 Q 类,提供 IDE 自动补全和编译时类型检查。
5.1 Maven 依赖与插件
<properties>
<querydsl.version>5.1.0</querydsl.version>
</properties>
<dependencies>
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-jpa</artifactId>
<version>${querydsl.version}</version>
<classifier>jakarta</classifier>
</dependency>
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-apt</artifactId>
<version>${querydsl.version}</version>
<classifier>jakarta</classifier>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>com.mysema.maven</groupId>
<artifactId>apt-maven-plugin</artifactId>
<version>1.1.3</version>
<executions>
<execution>
<goals><goal>process</goal></goals>
<configuration>
<outputDirectory>target/generated-sources/java</outputDirectory>
<processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>注意:Spring Boot 3.x / Jakarta EE 必须添加
classifier为jakarta的依赖。
5.2 QClass 生成
APT 处理器自动为每个 @Entity 类生成 Q 前缀类:
// 实体类
@Entity @Table(name = "sys_user")
public class User {
@Id private Long id;
private String username; private String email;
private Integer status; private Integer age;
private LocalDateTime createdAt;
@ManyToOne private Department department;
}
// 生成的 QClass(target/generated-sources/...)
public class QUser extends EntityPathBase<User> {
public static final QUser user = new QUser("user");
public final NumberPath<Long> id = createNumber("id", Long.class);
public final StringPath username = createString("username");
public final StringPath email = createString("email");
public final NumberPath<Integer> status = createNumber("status", Integer.class);
public final NumberPath<Integer> age = createNumber("age", Integer.class);
public final DateTimePath<LocalDateTime> createdAt =
createDateTime("createdAt", LocalDateTime.class);
public final QDepartment department;
}5.3 配置 JPAQueryFactory
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
@Configuration
public class QuerydslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}5.4 Spring Data QueryDSL 支持
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
public interface UserRepository extends JpaRepository<User, Long>,
QuerydslPredicateExecutor<User> {
}QuerydslPredicateExecutor 提供的方法:
Optional<T> findOne(Predicate predicate);
List<T> findAll(Predicate predicate);
List<T> findAll(Predicate predicate, Sort sort);
List<T> findAll(Predicate predicate, OrderSpecifier<?>... orders);
Page<T> findAll(Predicate predicate, Pageable pageable);
long count(Predicate predicate);
boolean exists(Predicate predicate);5.5 QueryDSL 基础查询
import com.querydsl.jpa.impl.JPAQueryFactory;
import java.util.List;
@Repository
public class UserQuerydslRepository {
private final JPAQueryFactory queryFactory;
public UserQuerydslRepository(JPAQueryFactory queryFactory) {
this.queryFactory = queryFactory;
}
public List<User> findActiveUsers(String keyword, LocalDateTime startDate) {
QUser qUser = QUser.user;
return queryFactory.selectFrom(qUser)
.where(qUser.status.eq(1),
qUser.username.containsIgnoreCase(keyword),
qUser.createdAt.after(startDate))
.orderBy(qUser.createdAt.desc()).limit(10).fetch();
}
// 关联查询(fetchJoin 避免 N+1)
public List<User> findByDepartmentName(String deptName) {
QUser qUser = QUser.user;
return queryFactory.selectFrom(qUser)
.leftJoin(qUser.department).fetchJoin()
.where(qUser.department.name.eq(deptName)).fetch();
}
}5.6 BooleanExpression 组合
BooleanExpression 是 QueryDSL 构建谓词的核心类,类似 Criteria API 的 Predicate。
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.core.types.dsl.Expressions;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
public class UserQueryBuilder {
private static final QUser qUser = QUser.user;
public static BooleanExpression hasStatus(Integer status) {
return status != null ? qUser.status.eq(status) : null;
}
public static BooleanExpression usernameContains(String keyword) {
return StringUtils.hasText(keyword) ? qUser.username.containsIgnoreCase(keyword) : null;
}
public static BooleanExpression ageBetween(Integer min, Integer max) {
if (min == null && max == null) return null;
if (min == null) return qUser.age.loe(max);
if (max == null) return qUser.age.goe(min);
return qUser.age.between(min, max);
}
public static BooleanExpression createdAtAfter(LocalDate date) {
return date != null ? qUser.createdAt.after(date.atStartOfDay()) : null;
}
public static BooleanExpression createdAtBefore(LocalDate date) {
return date != null ? qUser.createdAt.before(date.plusDays(1).atStartOfDay()) : null;
}
public static BooleanExpression departmentIdEq(Long deptId) {
return deptId != null ? qUser.department.id.eq(deptId) : null;
}
}组合使用:
BooleanExpression predicate = Expressions.allOf(
UserQueryBuilder.hasStatus(1),
UserQueryBuilder.usernameContains(keyword),
UserQueryBuilder.ageBetween(18, 60),
UserQueryBuilder.departmentIdEq(deptId)
);
List<User> users = userRepository.findAll(predicate);
Expressions.allOf(...)自动忽略null条件,类似Specification.where(...)的短路行为。
6. Specifications vs QueryDSL 对比
6.1 对比表格
| 维度 | Specifications (Criteria API) | QueryDSL |
|---|---|---|
| 类型安全 | 运行时(String 字段名) | 编译时(属性引用) |
| IDE 补全 | 有限(字段名无提示) | 完全支持 |
| 学习曲线 | 较低(标准 JPA) | 中等(需学习 DSL) |
| 重构友好度 | 字段名修改需全局搜索 | 编译器自动检测 |
| 复杂查询 | 链式调用较冗长 | 流畅 API 更简洁 |
| 子查询 | 支持但语法繁琐 | 原生支持,语法优雅 |
| 动态排序 | Order 对象 | OrderSpecifier |
| Spring Data 集成 | JpaSpecificationExecutor | QuerydslPredicateExecutor |
| 构建配置 | 无额外配置 | 需 APT 插件生成 Q 类 |
| 第三方依赖 | JPA 自带 | 需引入 querydsl-jpa |
6.2 选型建议
优先选择 Specifications / Criteria API:
- 团队熟悉 JPA 标准,不愿引入额外依赖
- 查询逻辑相对简单,字段名变更不频繁
- 项目构建流程不允许 APT 处理器
- 需要保持纯粹的 Jakarta EE / JPA 标准兼容性
优先选择 QueryDSL:
- 查询逻辑复杂,涉及大量动态条件组合
- 实体模型频繁变更,需要编译期安全保证
- 团队要求强类型和 IDE 提示
- 需要频繁执行子查询、聚合查询或复杂关联
7. 实战:后台管理多条件动态查询
以完整的后台管理用户列表查询为例,展示两种实现方式。
7.1 实体模型
@Entity @Table(name = "sys_user")
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50) private String username;
@Column(length = 100) private String email;
@Column(length = 20) private String phone;
@Column(nullable = false) private Integer status; // 0-禁用 1-启用
@Column(nullable = false) private Integer gender; // 0-未知 1-男 2-女
private LocalDateTime birthday;
private Integer age;
@Column(nullable = false, updatable = false) private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "dept_id")
private Department department;
}
@Entity @Table(name = "sys_department")
public class Department {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(nullable = false, unique = true) private String name;
private Integer sortOrder;
private Boolean enabled;
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_id")
private Department parent;
}7.2 查询 DTO
import java.time.LocalDate;
public class UserQueryDTO {
private String keyword; // 用户名/邮箱/手机号模糊匹配
private Integer status; // 状态
private Integer gender; // 性别
private LocalDate startDate; // 创建开始日期
private LocalDate endDate; // 创建结束日期
private Long deptId; // 部门 ID
private String deptName; // 部门名称模糊匹配
private Integer minAge; // 最小年龄
private Integer maxAge; // 最大年龄
}7.3 Specifications 实现
import org.springframework.data.domain.*;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import javax.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.List;
@Service @Transactional(readOnly = true)
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public Page<User> searchUsers(UserQueryDTO dto, int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Order.desc("createdAt"), Sort.Order.asc("username")));
return userRepository.findAll(buildSpecification(dto), pageable);
}
private Specification<User> buildSpecification(UserQueryDTO dto) {
return (root, query, cb) -> {
if (Long.class != query.getResultType()) query.distinct(true);
List<Predicate> predicates = new ArrayList<>();
// 关键字模糊匹配(用户名、邮箱、手机号)
if (StringUtils.hasText(dto.getKeyword())) {
String p = "%" + dto.getKeyword().trim() + "%";
predicates.add(cb.or(
cb.like(root.get("username"), p),
cb.like(root.get("email"), p),
cb.like(root.get("phone"), p)));
}
if (dto.getStatus() != null)
predicates.add(cb.equal(root.get("status"), dto.getStatus()));
if (dto.getGender() != null)
predicates.add(cb.equal(root.get("gender"), dto.getGender()));
if (dto.getMinAge() != null)
predicates.add(cb.greaterThanOrEqualTo(root.get("age"), dto.getMinAge()));
if (dto.getMaxAge() != null)
predicates.add(cb.lessThanOrEqualTo(root.get("age"), dto.getMaxAge()));
if (dto.getStartDate() != null)
predicates.add(cb.greaterThanOrEqualTo(
root.get("createdAt"), dto.getStartDate().atStartOfDay()));
if (dto.getEndDate() != null)
predicates.add(cb.lessThanOrEqualTo(
root.get("createdAt"), dto.getEndDate().plusDays(1).atStartOfDay()));
if (dto.getDeptId() != null)
predicates.add(cb.equal(root.join("department",
javax.persistence.criteria.JoinType.LEFT).get("id"), dto.getDeptId()));
if (StringUtils.hasText(dto.getDeptName()))
predicates.add(cb.like(root.join("department",
javax.persistence.criteria.JoinType.LEFT).get("name"),
"%" + dto.getDeptName().trim() + "%"));
return cb.and(predicates.toArray(new Predicate[0]));
};
}
}7.4 QueryDSL 实现
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.data.domain.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.List;
@Service @Transactional(readOnly = true)
public class UserQuerydslService {
private final JPAQueryFactory queryFactory;
public UserQuerydslService(JPAQueryFactory queryFactory) {
this.queryFactory = queryFactory;
}
public Page<User> searchUsers(UserQueryDTO dto, int page, int size) {
QUser qUser = QUser.user;
QDepartment qDept = QDepartment.department;
BooleanExpression predicate = buildPredicate(dto);
long total = queryFactory.select(qUser.countDistinct()).from(qUser)
.leftJoin(qUser.department, qDept).where(predicate).fetchOne();
List<User> content = queryFactory.selectDistinct(qUser).from(qUser)
.leftJoin(qUser.department, qDept).fetchJoin()
.where(predicate)
.orderBy(qUser.createdAt.desc(), qUser.username.asc())
.offset((long) page * size).limit(size).fetch();
return new PageImpl<>(content, PageRequest.of(page, size), total);
}
private BooleanExpression buildPredicate(UserQueryDTO dto) {
QUser qUser = QUser.user;
QDepartment qDept = QDepartment.department;
BooleanExpression predicate = Expressions.asBoolean(true).isTrue();
if (StringUtils.hasText(dto.getKeyword())) {
String kw = dto.getKeyword().trim();
predicate = predicate.and(qUser.username.containsIgnoreCase(kw)
.or(qUser.email.containsIgnoreCase(kw))
.or(qUser.phone.containsIgnoreCase(kw)));
}
if (dto.getStatus() != null) predicate = predicate.and(qUser.status.eq(dto.getStatus()));
if (dto.getGender() != null) predicate = predicate.and(qUser.gender.eq(dto.getGender()));
if (dto.getMinAge() != null) predicate = predicate.and(qUser.age.goe(dto.getMinAge()));
if (dto.getMaxAge() != null) predicate = predicate.and(qUser.age.loe(dto.getMaxAge()));
if (dto.getStartDate() != null)
predicate = predicate.and(qUser.createdAt.goe(dto.getStartDate().atStartOfDay()));
if (dto.getEndDate() != null)
predicate = predicate.and(qUser.createdAt.lt(dto.getEndDate().plusDays(1).atStartOfDay()));
if (dto.getDeptId() != null) predicate = predicate.and(qDept.id.eq(dto.getDeptId()));
if (StringUtils.hasText(dto.getDeptName()))
predicate = predicate.and(qDept.name.containsIgnoreCase(dto.getDeptName().trim()));
return predicate;
}
}7.5 Controller 层
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController @RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) { this.userService = userService; }
@GetMapping
public ResponseEntity<Page<User>> listUsers(UserQueryDTO queryDTO,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
return ResponseEntity.ok(userService.searchUsers(queryDTO, page, size));
}
}7.6 最佳实践
关联查询去重:涉及 @OneToMany 或 @ManyToMany 时,必须去重。
// Specifications: query.distinct(true);
// QueryDSL: queryFactory.selectDistinct(qUser)...避免 N+1:列表查询使用 fetchJoin() 预先加载关联实体。
// Specifications: root.join("department", JoinType.LEFT);
// QueryDSL: queryFactory.leftJoin(qUser.department, qDept).fetchJoin()...日期范围边界处理:使用 < 第二天的 00:00:00 来包含 endDate 全天。
cb.lessThan(root.get("createdAt"), endDate.plusDays(1).atStartOfDay());排序字段白名单:防止 SQL 注入。
private static final List<String> SORTABLE_FIELDS = Arrays.asList(
"id", "username", "createdAt", "status", "age");
private Sort buildSort(String sortField, String sortOrder) {
if (!SORTABLE_FIELDS.contains(sortField)) sortField = "createdAt";
return Sort.by("asc".equalsIgnoreCase(sortOrder)
? Sort.Direction.ASC : Sort.Direction.DESC, sortField);
}8. 总结
Specifications 和 QueryDSL 是 Spring Data JPA 生态中两种主流的动态查询方案:
- Specifications 基于 JPA 标准 Criteria API,零额外依赖,适合查询场景相对简单的项目。配合工具类封装后,代码可读性和复用性都能得到较大提升。
- QueryDSL 通过编译期代码生成提供真正的类型安全,API 流畅简洁,适合查询逻辑复杂、实体模型频繁变更的中大型项目。
在实际项目中,两种方案也可以共存——简单查询使用 JpaSpecificationExecutor,复杂统计和报表查询使用 JPAQueryFactory。关键在于根据团队技术栈和业务复杂度做出合理选择。