Spring Boot中利用JPA Specification实现管理后台查询列表条件通用筛选的统一封装

发布时间:2022年04月23日 // 分类:代码 // 暂无评论

在实现管理后台业务时,基本上一定会遇到对列表进行筛选操作(如下图)。将筛选条件逐个硬编码到服务端代码中,一定是又臭又长。因此在成熟的项目中,一般都会对列表查询筛选条件进行封装。这里就提供一个简单的封装方案。

table-filter-demo.png

简单演示

如果硬编码了筛选条件,后台查询接口的Payload一般会是这样:

{
  page:1,
  size:10,
  where:{
    name:"xxx",
    mobileNumber:"111222333",
  }
}

我们为了应付灵活变化的需求,可以调整成这样:

{
    "page":1,
    "size":10,
    "where":[
        {
            "field":"nickname",  // 字段名称
            "matchType":"like",  // 对应SQL里的操作符
            "val":"%静%"         // 查询值
        },
        {
            "field":"mobileNumber",
            "matchType":"eq",
            "val":"18600000000"
        }
    ]
}

这样,前端只要传入不同的操作符,就可以自定义实现大于小于模糊查询等操作。

后端具体实现

定义一个表格查询的通用Payload对象,用于接收接口的@RequestBody
这里我们作为GeneralTablePayload.java

package com.gentleltd.todo965.backend.controller.dashboard.dto.common;

import com.alibaba.fastjson.JSON;
import com.gentleltd.todo965.backend.bo.QueryConditions;
import com.gentleltd.todo965.backend.infra.queryutil.QueryUtil;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import org.springframework.data.jpa.domain.Specification;


@Getter
@Setter
@Accessors(chain = true)
public class GeneralTablePayload {
    private int page = 1;  // 分页参数
    private int size = 20;  // 分页参数
    private Object where;   // 接收前端传来的筛选条件

    // 将where解析成预定义的筛选条件对象(代码在下文给出)
    public QueryConditions parseWhere() {
        System.out.println(JSON.toJSONString(where));
        return JSON.parseObject(JSON.toJSONString(where), QueryConditions.class);
    }

    // 将where解析成JPA支持的Specification对象
    public <T> Specification<T> parseSpecification() {
        return QueryUtil.parse(parseWhere()); // QueryUtil是关键代码,下文亦给出
    }
}

QueryConditions 对象的定义如下

package com.gentleltd.todo965.backend.bo;

import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;

import java.util.ArrayList;

/**
 * 通用查询条件
 */
@Accessors(chain = true)
@Getter
@Setter
public class QueryConditions extends ArrayList<QueryConditionItem> {
}

QueryConditions 附属的 QueryConditionItem 就是与前端交互的关键格式,定义如下:

package com.gentleltd.todo965.backend.bo;

import com.gentleltd.todo965.backend.enums.QueryMatchTypeEnum;
import io.swagger.annotations.ApiModelProperty;
import lombok.Getter;
import lombok.Setter;

/**
 * 通用查询条件 - 单条条件
 */
@Getter
@Setter
public class QueryConditionItem {
    @ApiModelProperty("字段名称")
    private String field;
    @ApiModelProperty("条件类型")
    private QueryMatchTypeEnum matchType;
    @ApiModelProperty("条件取值")
    private String val;
}

而关键的,实现前端where结构转换为JPA Specification对象的关键代码如下:

package com.gentleltd.todo965.backend.infra.queryutil;

import com.gentleltd.todo965.backend.bo.QueryConditions;
import org.springframework.data.jpa.domain.Specification;

import javax.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.List;

public class QueryUtil {
    public static <T> Specification<T> parse(QueryConditions conditions) {
        return (root, query, criteriaBuilder) -> {
            List<Predicate> predicateItems = new ArrayList<>();
            conditions.forEach(item -> {
                Predicate predicateItem;
                switch (item.getMatchType()) {
                    case eq:
                        predicateItem = criteriaBuilder.equal(root.get(item.getField()).as(String.class), item.getVal());
                        break;
                    case neq:
                        predicateItem = criteriaBuilder.notEqual(root.get(item.getField()).as(String.class), item.getVal());
                        break;
                    case gt:
                        predicateItem = criteriaBuilder.greaterThan(root.get(item.getField()).as(String.class), item.getVal());
                        break;
                    case gte:
                        predicateItem = criteriaBuilder.greaterThanOrEqualTo(root.get(item.getField()).as(String.class), item.getVal());
                        break;
                    case lt:
                        predicateItem = criteriaBuilder.lessThan(root.get(item.getField()).as(String.class), item.getVal());
                        break;
                    case lte:
                        predicateItem = criteriaBuilder.lessThanOrEqualTo(root.get(item.getField()).as(String.class), item.getVal());
                        break;
                    case like:
                        predicateItem = criteriaBuilder.like(root.get(item.getField()).as(String.class), item.getVal());
                        break;
                    case notLike:
                        predicateItem = criteriaBuilder.notLike(root.get(item.getField()).as(String.class), item.getVal());
                        break;
                    case isNull:
                        predicateItem = criteriaBuilder.isNull(root.get(item.getField()).as(String.class));
                        break;
                    case isNotNull:
                        predicateItem = criteriaBuilder.isNotNull(root.get(item.getField()).as(String.class));
                        break;
                    default:
                        throw new RuntimeException("筛选条件MatchType不合法");
                }
                predicateItems.add(predicateItem);
            });
            return criteriaBuilder.and(predicateItems.toArray(new Predicate[0]));
        };
    }
}

当然,其中用到了QueryMatchTypeEnum是我们定义的操作符,对应SQL里的操作符。这些枚举需要与前端约定好。如果需要使用in等操作符,可以自行扩展QueryUtil实现。

package com.gentleltd.todo965.backend.enums;

/**
 * 查询条件类型
 */
public enum QueryMatchTypeEnum {
    eq, // =
    neq, // <>
    gt, // >
    gte, // >=
    lt, // <
    lte, // <=
    like, // like
    notLike, // not like
    isNull, // is null
    isNotNull, // is not null
}

这样在控制器中只需要类似这样调用就可以自动对接筛选条件了:

@RequestMapping("list")
public PlainResponse<GeneralTableResponse<MemberPortrait>> list(@RequestBody GeneralTablePayload payload) {
    var res = memberProfileService.listPortrait(
            payload.parseSpecification(),
            payload.getPage() - 1,
            payload.getSize()
    );
    return PlainResponse.success(GeneralTableResponse.byPage(res));
}

在JPA的Repository层,继承 JpaSpecificationExecutor<T> 即可。具体不再赘述,查相关资料即可。

前端对接

前端针对这种业务场景,其实也可以做针对性的封装。甚至最好可以将整个筛选、表格、分页整合到一起封装到一个组件里去方便复用。在页面中,只需要定义一下条件,就可以自动生成筛选区域代码,岂不美哉。比如下面这个基于VUE的简单的DEMO。具体就不再细说。

table-filter-demo-2.png

table-filter-demo-3.png

更近一步思考

这种对筛选的通用封装,其实也会带来安全问题,在安全要求比较高的场景下是需要特别注意的。
比如,如果攻击者通过猜测字段名称,或许就可以实现原本并不希望提供的查询功能。轻则造成可能的CC场景,重则造成数据越权访问甚至泄漏。
要解决这个问题,可以用字段白名单字段+操作符白名单进行过滤。
这样服务端在实现接口时,就可以从繁琐的编码中解放出来,只需要做好白名单限制的编码即可。

本文固定链接
https://www.ywlib.com/archives/185.html

标签
Spring Boot, Spring Data JPA, Specification, 列表筛选封装

添加新评论 »