1. 问题提出–动态条件查询
最近在用Spring JPA写查询的时候遇到一个问题,接口传入参数是可选的,如果为每种不同的参数组合各写一个查询,势必非常浪费而且没有必要,经过资料查找,发现应该使用JPA的动态条件查询,即使用CriteriaBuilder和Predicate对象来进行条件组合。
具体需求是:
传入参数有四个,分别是开始时间start
,结束时间end
,昵称nickname
以及关键词keyword
,检查的数据库字段分别是send_time
,nickname
以及message
。在start
与end
都不为空时,对send_time
进行范围查询(范围为start-end
),否则在start
不为空时,对send_time
进行等值查询(start
),否则忽略该条件;对于nickname
,只要不传入空,就做等值查询,对于keyword
,只要不传入空,就对message
做模糊查询。
最开始的想法是,为每个参数组合写一个JPQL
,这样总共就有3 * 2 * 2 = 12
个组合,要写12
个查询,非常没有必要,因此考虑使用JPA
自带的CriteriaQuery
来进行条件组合,首先需要一个Specification
对象,这是一个函数接口,有三个参数:代表Root
类型的代表查询对象的root
,描述条件查询的CriteriaQuery
对象query
,以及条件查询构造器criteriaBuilder
。该函数接口返回一个Predicate
对象,这个对象中包含了查询的条件组合。
由于我们需要可变数量的条件,因此使用一个List<Predicate>
来存放所需条件,得到的List
转化为Predicate[]
后传入criteriaBuilder.and
来完成Predicate
条件的构建,这里的转换有个小技巧,因为toArray
的源码如下:
public <T> T[] toArray(T[] a) {
if (a.length < size)
// Make a new array of a's runtime type, but my contents:
return (T[]) Arrays.copyOf(elementData, size, a.getClass());
System.arraycopy(elementData, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
}
从这里我们可以看出,List.toArray
方法的唯一参数是一个同类型数组a
,如果传入的这个数组a
的长度是小于当前的List
的长度的话,会调用Array.copyOf
方法,这个方法的作用是动态创建一个大小和当前List
相等的数组,然后把当前List
内容全部copy
进去(里面也是调用System.arraycopy
);否则的话,直接将当前List
里的全部内容拷贝到传入的数组a
中去,并且如果a
的长度大于List
长度size
的话,把a
数组索引size
处的值设为null
。最后返回数组a
。
那么为什么要传入new Predicate[0]
呢,主要的原因是避免额外的空间浪费。
- 如果创建并传入一个大小
>0
但<size
的数组,那么toArray
方法内部还是会创建新的大小为size
的数组,这样传入数组开辟的空间就浪费了,会加重GC
压力; - 如果创建并传入一个大小刚好
=size
的数组,那么在高并发情况下,一旦size
在toArray
执行过程中变大,就有可能导致这种情况fallback到情况一; - 如果创建并传入一个大小
>size
的数组,那么虽然不再需要重新开辟数组,但这也造成了空间浪费,并且由于toArray
会将a[size]
处设置为null
,可能造成NPE
问题。
综上所述,List.toArray
传入一个空数组是最合适的选择,实际上就相当于传入了一个类型,这也算一个常用的技巧,记录一下。
在完成Predicate
的构建之后,就可以把specification
和封装的分页对象pageable
(PageRequest.of(page, size)
)传入dao.findAll
来执行查询。
public Page<ReportInfo> findReportInfosByCond(int page, int size, Date begin, Date end, String nickname, String keyword) {
Specification<ReportInfo> specification = (root, query, criteriaBuilder) -> {
List<Predicate> predicates = new ArrayList<>();
Expression<Date> date = criteriaBuilder.function("date", Date.class, root.get("send_time"));
if (begin != null && end != null) {
predicates.add(criteriaBuilder.between(date, begin, end));
} else if (begin != null) {
predicates.add(criteriaBuilder.equal(date, begin));
}
// keyword过滤
if (keyword != null && !keyword.isBlank()) {
predicates.add(criteriaBuilder.like(root.get("message"), "%" + keyword.strip() + "%"));
}
// nickname过滤
if (nickname != null && !nickname.isBlank()) {
predicates.add(criteriaBuilder.equal(root.get("nickname"), nickname));
}
return criteriaBuilder.and(predicates.toArray(new Predicate[0]));
};
// 执行查询
return reportInfoDao.findAll(specification, PageReqeust.of(page, size));
}
但是发现了一个问题,就是使用findAll查询比较慢。
2. 新的问题–替换findAll查询
在上述查询中,发现使用dao.findAll
会比较慢,为了找出原因,选择查看了JpaSpecificationRepository.findAll(Specification<T>, Pageable)
方法的调用链,发现其主要流程如下:
通过specification
和pageable
拼装实体查询语句 -> 查询实体对象 -> 通过specification
(此处没有pageable
)拼装数量查询语句 -> 将实体查询结果以及数量查询语句组合到分页对象Page
中并返回
我尝试将各层函数调用展开,得到如下代码:
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
List<ReportInfo> results;
{
CriteriaQuery<ReportInfo> cq = cb.createQuery(ReportInfo.class);
Root<ReportInfo> root = cq.from(ReportInfo.class);
cq.where(specification.toPredicate(root, cq, cb));
TypedQuery<ReportInfo> query = entityManager.createQuery(cq);
query.setFirstResult(page * size).setMaxResults(size);
results = query.getResultList();
}
TypedQuery<Long> lenQuery;
{
CriteriaQuery<Long> lenCq = cb.createQuery(Long.class);
Root<ReportInfo> root = lenCq.from(ReportInfo.class);
lenCq.select(cb.count(root)).where(specification.toPredicate(root, lenCq, cb));
lenQuery = entityManager.createQuery(lenCq);
}
return PageableExecutionUtils.getPage(results, PageRequest.of(page, size), lenQuery::getSingleResult);
上述代码中,CriteriaBuilder.createQuery
是创建某种类型的查询,数量查询就传入Long
类型,实体查询就传入实体类型;CriteriaQuery.from
为创建的查询指定要查询的实体类(或者说数据表),返回查询根对象root
,这里都指定为实体类;CriteriaQuery.select
为查询指定查询字段,在构建数量查询时需要指定为CriteriaBuilder.count(root)
,where
放法指定查询条件,这里可以使用我们刚才构建的specification
对象(其抽象接口为toPredicate
),并为其传入root
query
和builder
对象来构建查询条件predicate
。之后便可以使用EntityManager.createQuery
即可从CriteriaQuery
来构建完整的查询语句。最后按照上述流程,将实体查询的执行结果results
和数量查询语句lenCq
传入(按照他的接口定义这里还要传入一个Pageable
对象)PageableExecutionUtils.getPage
即可得到Page<Entity>
类型的分页查询结果。
将展开的流程代替原来的dao.findAll
方法,发现速度变快了很多,但是令我疑惑的是,这个流程明明就是findAll
的复刻,为什么比findAll
还要快?受限于知识水平,我暂时还没有找到答案。网上所说的findAll
会查询数量在这里并不成立,因为我复刻的过程中同样也进行了数量查询。下面提出几个猜想:
- findAll的调用链太长,并且有一些冗余的判断。因为我这里将所有调用都展开了,并且没有任何判断。
- findAll中存在一些关于反射的操作,使得运行速度更慢。因为我这里直接指定了类型参数,没有进行
getClass
之类的操作。
当然,这只是鄙人的一点猜想,有机会以后一定要把这个问题弄清楚。
3. 扩展–直接用JPQL进行动态条件查询
经过进一步的搜索,发现可以直接在JPQL
中去写动态条件查询,利用的是查询语句中的逻辑判断符,比如在start
和end
都不为空的时候我希望在start
和end
中间做范围查询,那么我可以写
select r from ReportInfo r where (start is not null) and (end is not null) and (r.send_time between start and end)
其他也是类似的,不过这里由于有多个条件,逻辑稍显复杂,首先是三个字段的查询用and
连接,对于nickname
和message
的查询,只需判空,写成xx is null or r.xx = xx
即可。但对于send_time
的查询,除了start is null
以外,还需要or
两种情况,即start
和end
均不为空(范围查询),以及start
不为空但end
为空的情况(等值查询)。最终整个JPQL
如下:
@Query("select r from ReportInfo r where " +
"((:start is null) or (:rangeQuery = true and DATE(r.send_time) between :start and :end) or " +
"(:equalQuery = true and DATE(r.send_time) = :start)) and " +
"(:nicknameBlank = true or r.nickname = :nickname) and " +
"(:keywordBlank = true or r.message like :keyword)")
Page<ReportInfo> findReportInfosByCond(Pageable pageable,
@Param("rangeQuery") boolean rangeQuery,
@Param("equalQuery") boolean equalQuery,
@Param("nicknameBlank") boolean nicknameBlank,
@Param("keywordBlank") boolean keywordBlank,
@Param("start") Date start,
@Param("end") Date end,
@Param("nickname") String nickname,
@Param("keyword") String keyword);
四个boolean
值为service
层计算的参数,rangeQuery
表示是否对send_time
范围查询,equalRange
表示是否对send_time
等值查询,nicknameBlank
表示nickname
是否为空,keywordBlank
表示keyword
是否为空:
public Page<ReportInfo> findReportInfosByCond(int page, int size, Date begin, Date end, String nickname, String keyword) {
boolean rangeQuery = begin != null && end != null;
boolean equalQuery = !rangeQuery && begin != null;
boolean nicknameBlank = nickname == null || nickname.isBlank();
boolean keywordBlank = keyword == null || keyword.isBlank();
if (!keywordBlank) keyword = "%" + keyword.strip() + "%";
return reportInfoDao.findReportInfosByCond(PageRequest.of(page, size), rangeQuery, equalQuery,
nicknameBlank, keywordBlank, begin, end, nickname, keyword);
}
4. 总结
最开始,我希望在JPA
中写动态条件查询,于是使用了specification
查询与dao.findAll
方法,但是发现findAll
方法比较慢,于是查看了其执行流程,并将其展开,提高了查询速度。最后提供了一种扩展,即直接在JPQL
中写动态条件查询,形式上比上一种方法要更加优雅,但是逻辑上更为复杂。