在实现管理后台业务时,基本上一定会遇到对列表进行筛选操作(如下图)。将筛选条件逐个硬编码到服务端代码中,一定是又臭又长。因此在成熟的项目中,一般都会对列表查询筛选条件进行封装。这里就提供一个简单的封装方案。
简单演示
如果硬编码了筛选条件,后台查询接口的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。具体就不再细说。
更近一步思考
这种对筛选的通用封装,其实也会带来安全问题,在安全要求比较高的场景下是需要特别注意的。
比如,如果攻击者通过猜测字段名称,或许就可以实现原本并不希望提供的查询功能。轻则造成可能的CC场景,重则造成数据越权访问甚至泄漏。
要解决这个问题,可以用字段白名单
或字段+操作符白名单
进行过滤。
这样服务端在实现接口时,就可以从繁琐的编码中解放出来,只需要做好白名单限制的编码即可。