1. 问题提出–动态条件查询

最近在用Spring JPA写查询的时候遇到一个问题,接口传入参数是可选的,如果为每种不同的参数组合各写一个查询,势必非常浪费而且没有必要,经过资料查找,发现应该使用JPA的动态条件查询,即使用CriteriaBuilder和Predicate对象来进行条件组合。
具体需求是:
传入参数有四个,分别是开始时间start,结束时间end,昵称nickname以及关键词keyword,检查的数据库字段分别是send_timenickname以及message。在startend都不为空时,对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]呢,主要的原因是避免额外的空间浪费。

  1. 如果创建并传入一个大小>0<size的数组,那么toArray方法内部还是会创建新的大小为size的数组,这样传入数组开辟的空间就浪费了,会加重GC压力;
  2. 如果创建并传入一个大小刚好=size的数组,那么在高并发情况下,一旦sizetoArray执行过程中变大,就有可能导致这种情况fallback到情况一;
  3. 如果创建并传入一个大小>size的数组,那么虽然不再需要重新开辟数组,但这也造成了空间浪费,并且由于toArray会将a[size]处设置为null,可能造成NPE问题。

综上所述,List.toArray传入一个空数组是最合适的选择,实际上就相当于传入了一个类型,这也算一个常用的技巧,记录一下。
在完成Predicate的构建之后,就可以把specification和封装的分页对象pageablePageRequest.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)方法的调用链,发现其主要流程如下:
通过specificationpageable拼装实体查询语句 -> 查询实体对象 -> 通过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 querybuilder对象来构建查询条件predicate。之后便可以使用EntityManager.createQuery即可从CriteriaQuery来构建完整的查询语句。最后按照上述流程,将实体查询的执行结果results和数量查询语句lenCq传入(按照他的接口定义这里还要传入一个Pageable对象)PageableExecutionUtils.getPage即可得到Page<Entity>类型的分页查询结果。
将展开的流程代替原来的dao.findAll方法,发现速度变快了很多,但是令我疑惑的是,这个流程明明就是findAll的复刻,为什么比findAll还要快?受限于知识水平,我暂时还没有找到答案。网上所说的findAll会查询数量在这里并不成立,因为我复刻的过程中同样也进行了数量查询。下面提出几个猜想:

  1. findAll的调用链太长,并且有一些冗余的判断。因为我这里将所有调用都展开了,并且没有任何判断。
  2. findAll中存在一些关于反射的操作,使得运行速度更慢。因为我这里直接指定了类型参数,没有进行getClass之类的操作。

当然,这只是鄙人的一点猜想,有机会以后一定要把这个问题弄清楚。

3. 扩展–直接用JPQL进行动态条件查询

经过进一步的搜索,发现可以直接在JPQL中去写动态条件查询,利用的是查询语句中的逻辑判断符,比如在startend都不为空的时候我希望在startend中间做范围查询,那么我可以写

select r from ReportInfo r where (start is not null) and (end is not null) and (r.send_time between start and end)

其他也是类似的,不过这里由于有多个条件,逻辑稍显复杂,首先是三个字段的查询用and连接,对于nicknamemessage的查询,只需判空,写成xx is null or r.xx = xx即可。但对于send_time的查询,除了start is null以外,还需要or两种情况,即startend均不为空(范围查询),以及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中写动态条件查询,形式上比上一种方法要更加优雅,但是逻辑上更为复杂。