基础的代码写的越多,就越不想再重复的去写。总有一天当你对所有Controller都厌烦的时候,就该静下来想想能否将代码更加的抽象,精炼,或者做成一个通用的功能,让我们不用增加一张数据表就需要去写一套接口。在这里,分享一个通用的Controller基类来集成这些通用的操作抛砖引玉,在这样的一个BaseController基类中,我们需要考虑的是在原来独立的Controller上所做的工作如何通用的在基类上实现。
1. 准备工作
通常一个数据表,开发过程中按照传统的三层结构,我们会在控制器中增加如下内容:
- 对表增删改查操作的基础接口,以及导入导出功能等
- 该方法的@RequesMapping定义
- 添加接口方法在API文档中的简介及描述
- 通用的权限码及权限校验
- 提供一些可供扩展的方法钩子
这里我们使用Mybtis-plus做持久化框架,那么实际想一想,以上这些内容其实都会在我们定义的每一个Controller中出现,但是现在我们不想在各个Controller里写这么多一样的东西了,怎么办?
2. BaseController父类
直接上实现。代码其实很简单,只是我们要考虑到在实际使用过程中需要自定义实现的点。
下面我们实现了增删改查,导入,导出以及下载导入模板的全部接口。
@Slf4j
public class BaseController<S extends ICommonService<T>, T extends CommonEntity> {
@Autowired
BaseCommonService<T> baseCommonService;
@Autowired
@SuppressWarnings("BaseServiceInject")
private S baseService;
public S getBaseService() {
return baseService;
}
private ICommonViewer commonViewer;
public BaseController() {
}
public BaseController(ICommonViewer commonViewer) {
this.commonViewer = commonViewer;
}
/**
* 分页查询
* @param request
* @param queryParamVo
* @return
*/
@CommonPermission("{T}Query")
@PostMapping("/getPage")
@Operation(summary = "分页查询数据", description = "分页查询数据")
protected Result<IPage<T>> findPage(HttpServletRequest request, @RequestBody QueryParamVo queryParamVo) {
//构造查询条件
QueryWrapper<T> queryWrapper = this.buildQueryWrapper(request,queryParamVo);
IPage<T> page = new Page<>(queryParamVo.getPageNo(), queryParamVo.getLimit());
if (queryWrapper != null) {
//获取所属模块
String module= !StringUtils.isEmpty(queryParamVo.getModule())?queryParamVo.getModule():this.getModuleName().getCode();
// 分页查询数据
page = baseService.page(page, queryWrapper);
// 对数据进行基础数据加工处理
baseCommonService.processingPageData(commonViewer,page.getRecords(), module);
// 分页查询数据后执行,可对数据进行个性化加工处理
this.afterPageListQuery(page.getRecords());
}
return Result.ok(page);
}
/**
* 构造查询条件
* @param request
* @param queryParamVo
* @return
*/
private QueryWrapper<T> buildQueryWrapper(HttpServletRequest request,QueryParamVo queryParamVo) {
QueryWrapper<T> queryWrapper = new QueryWrapper<>();
// 构造查询条件之前执行对参数进行加工
this.beforeBuilderFilter4Page(request, queryWrapper, queryParamVo);
// 构造查询条件
baseService.buildQueryWrapper(queryWrapper, queryParamVo);
// 构造查询条件之后执行对条件进行再加工
this.afterBuilderFilter4Page(request,queryWrapper);
return queryWrapper;
}
/**
* 构造查询条件之前执行
* @param request
* @param queryWrapper
* @param queryParamVo
*/
protected void beforeBuilderFilter4Page(HttpServletRequest request,QueryWrapper<T> queryWrapper, QueryParamVo queryParamVo) {
}
/**
* 构造查询条件之后执行
* @param request
* @param queryWrapper
*/
protected void afterBuilderFilter4Page(HttpServletRequest request,QueryWrapper<T> queryWrapper) {
}
/**
* 分页查询列表之后执行
* @param pageList
*/
protected void afterPageListQuery(List<T> pageList) {
}
/**
* 根据ID查询
* @param id
* @return
*/
@CommonPermission("{T}Query")
@Operation(summary = "根据ID查询详情", description = "根据ID查询详情,ID不能为空")
@GetMapping("/detail/{id}")
protected Result<List<T>> findById(@PathVariable String id) {
String[] ids = id.split(",");
List<T> list = baseService.list(new QueryWrapper<T>().in("id", ids));
//查询明细之后执行
list.forEach(item -> {
this.afterDetailQuery(item);
});
return Result.ok(list);
}
/**
* 对单个实例进行处理
*/
protected void afterDetailQuery(T entity) {
}
/**
* 新增/编辑
* @param entity
* @return
*/
@CommonPermission("{T}Save")
@Operation(summary = "新增或保存记录", description = "ID为空则新增,否则为更新操作")
@PostMapping("/save")
protected Result<Boolean> save(@RequestBody T entity) {
//保存之前执行
this.beforeSave(entity);
boolean result;
entity.setEditor(LoginContext.getLoginContext().getPin());
if (entity.getId() == null) {
entity.setCreator(entity.getEditor());
result = baseService.save(entity);
} else {
result = baseService.updateById(entity);
}
//保存之后执行
this.afterSave(entity);
return Result.ok(result);
}
/**
* 保存之前执行
* @param entity
*/
protected void beforeSave(T entity) {
}
/**
* 保存之后执行
* @param entity
*/
protected void afterSave(T entity) {
}
/**
* 根据ID删除记录
* @param id
* @return
*/
@CommonPermission("{T}Delete")
@Operation(summary = "根据ID删除记录", description = "根据ID删除记录,ID不能为空")
@DeleteMapping(value = "/delete/{id}")
protected Result<Boolean> delete(@PathVariable Long id) {
UpdateWrapper<T> updateWrapper = new UpdateWrapper<>();
updateWrapper.set("delete_flag", DeleteFlagEnum.FAIL.getCode());
updateWrapper.set("editor", LoginContext.getLoginContext().getPin());
updateWrapper.eq("id", id);
return Result.ok(baseService.update(updateWrapper));
}
@CommonPermission("{T}Save")
@Operation(summary = "批量修改状态", description = "批量修改状态")
@PostMapping("/updateStatusBatch")
protected Result updateStatusBatch(@RequestBody StatusBatchVo statusBatchVo) {
List<T> list = baseService.list((new QueryWrapper<T>())
.eq("delete_flag", DeleteFlagEnum.VALID.getCode())
.in("id", statusBatchVo.getIds())
);
if (!CollectionUtils.isEmpty(list) && list.get(0) instanceof CommonYnEntity) {
Boolean result = baseService.update((new UpdateWrapper<T>())
.set("yn_flag", statusBatchVo.getStatus())
.in("id", statusBatchVo.getIds())
);
return Result.ok(result);
} else {
return Result.error("此功能不支持该操作!");
}
}
@ResponseBody
@CommonPermission("{T}Import")
@Operation(summary = "下载导入Excel模板", description = "下载导入Excel模板")
@PostMapping("/downloadTemplate")
protected void downloadImportExcelTemplate(@RequestBody ModuleVo moduleVo, HttpServletResponse response) {
ModuleClassMappingEnum model = ModuleClassMappingEnum.getEnumByKey(moduleVo.getModule());
File file = baseCommonService.downloadTemplate(baseService, this.getEntityClass(), model);
HttpUtils.writeResponse(file, response);
//删除文件
FileUtils.deleteQuietly(file);
}
@FileSlotDisabled
@CommonPermission("{T}Import")
@Operation(summary = "导入Excel数据", description = "导入Excel数据")
@PostMapping("/import")
protected Result<String> importExcel(@RequestBody MultipartFile file) {
return baseCommonService.importExcel(baseService, file, this.getModuleName());
}
@CommonPermission("{T}Export")
@Operation(summary = "导出数据到Excel", description = "导出数据到Excel")
@PostMapping(value={"/export/{cate}","/export"})
protected Result<String> exportExcel(HttpServletRequest request,@RequestBody QueryParamVo queryParamVo,@PathVariable(name="cate",required = false) String cate) {
QueryWrapper<T> queryWrapper = this.buildQueryWrapper(request,queryParamVo);
ModuleClassMappingEnum module = !StringUtils.isEmpty(queryParamVo.getModule()) ? ModuleClassMappingEnum.getEnumByKey(queryParamVo.getModule()) : this.getModuleName();
return baseCommonService.exportExcel(baseService, queryWrapper, commonViewer, module);
}
/**
* 获取模块
* @return
*/
protected Class getEntityClass() {
Type type = ((ParameterizedType) this.getClass().getGenericSuperclass()).getActualTypeArguments()[1];
return (Class) type;
}
/**
* 获取模块
* @return
*/
protected ModuleClassMappingEnum getModuleName() {
ModuleClassMappingEnum module = ModuleClassMappingEnum.getEnumByClassName((this.getEntityClass()).getSimpleName());
return module;
}
/**
* 组装返回 结果信息
* @param resultVo 批量操作结果封装VO
* @return
*/
protected Result buildResult(BatchResultVo resultVo) {
String msg = "";
Boolean flag = Boolean.TRUE;
if (resultVo.getSuccNum() > 0) {
msg = resultVo.getSuccNum() + "条操作成功。";
}
if (!CollectionUtils.isEmpty(resultVo.getErrList())) {
flag = Boolean.FALSE;
msg = resultVo.getFailNum() + "条单据操作失败,失败原因:" + JSONObject.toJSONString(resultVo.getErrList());
}
if (flag)
return Result.ok(resultVo.getSuccList());
else
return Result.error(msg);
}
}
2.1 查询参数
我们统一了查询方法,那么查询条件必然要统一,但是每一个实体有不同的查询参数,又有很多个性化的处理,那么我们怎么能将查询参数统一呢?
我们对查询参数也进行了封装,定义如下:
@Data
@Schema(title = "查询参数")
public class QueryParamVo {
@Schema(title = "所属模块")
String module;
@Schema(title = "页码",defaultValue = "1")
Integer pageNo = 1;
@Schema(title = "分页大小",defaultValue = "10")
Integer limit = 10;
@Schema(title = "排序字段,字段名:升降序(desc,asc)",defaultValue = "{\"id\":\"desc\"}")
private Map<String, String> sorts;
@Schema(title = "条件",defaultValue = "[]")
private List<QueryConditionVo> conditions;
@Schema(title = "全选标记", defaultValue = "0")
private String isAll = "0";
@Schema(title = "id选择",defaultValue = "[]")
private Long[] ids;
}
QueryConditionVo是我们的一个查询条件定义,有多个查询项,那么这里就是一个List。
@Schema(title = "查询条件")
public class QueryConditionVo {
private String propName;
private String method;
private Object value;
private String type;
public QueryConditionVo() {
}
public QueryConditionVo(String propName, String method, Object value) {
this.propName = propName;
this.method = method;
this.value = value;
}
public QueryConditionVo(String propName, String method, Object value, String type) {
this.propName = propName;
this.method = method;
this.value = value;
this.type = type;
}
}
好了,来再看一个简单的封装之后查询参数的例子:
{
"sorts": {
"id": "desc"
},
"module": "bill_check",
"conditions": [
{
"propName": "id",
"value": "10001",
"method": "in",
"type": "text"
},
{
"propName": "billStatus",
"value": "3",
"method": "eq",
"type": "select"
},
{
"propName": "amount_GE",
"value": "100",
"method": "ge",
"type": "amount"
},
{
"propName": "amount_LE",
"value": "200",
"method": "le",
"type": "amount"
}
],
"pageNo": 1,
"limit": 20,
"total": 383
}
conditions中包含的是我们所有的查询条件。method定义为属性和值间的比较方式,而type属性则表示我们当前条件的值是一个文本text还是一个数字的金额等,这关系到我们后面对值的加工处理。
type属性,我们可以大致分为text,select,datetime,以及amount或者叫number都可以。而这里的method我们也可以看下具体的定义:
/**
* 查询方法
*/
public enum QueryMethodEnum {
GT("gt","大于",Boolean.FALSE),
LT("lt","小于",Boolean.FALSE),
GE("ge","大于等于",Boolean.FALSE),
LE("le","小于等于",Boolean.FALSE),
NE("ne","不等于",Boolean.FALSE),
LK("lk","LIKE",Boolean.FALSE),
IN("in","IN",Boolean.FALSE),
SIN("sin","FIND_IN_SET",Boolean.FALSE),
EQ("eq","等于",Boolean.FALSE),
SN("sn","IS NULL",Boolean.TRUE),
NN("nn","IS NOT NULL",Boolean.TRUE);
当我们们定义好了查询参数以后,接下来的就是如何去解析这个封装后的对象。可以具体看buildQueryWrapper这个方法:
@Slf4j
public class QueryParamUtils {
/**
* 根据查询条件参数生成查询条件QueryWrapper
* @param queryWrapper
* @param queryParamVo
* @return
*/
public static <T extends CommonEntity> QueryWrapper<T> buildQueryWrapper(QueryWrapper<T> queryWrapper, QueryParamVo queryParamVo) {
queryWrapper.eq("delete_flag", DeleteFlagEnum.VALID.getCode());
if (queryParamVo.getIds() != null && queryParamVo.getIds().length > 0) {
//ids 优先级最高
queryWrapper.in("id", convertToCollection(queryParamVo.getIds()));
} else {
//isAll 等同于 按条件
if (!CollectionUtils.isEmpty(queryParamVo.getConditions())) {
QueryMethodEnum method;
for (QueryConditionVo param : queryParamVo.getConditions()) {
method = QueryMethodEnum.getEnumByKey(param.getMethod().toLowerCase());
if (StringUtils.isNotEmpty(param.getPropName()) && (method.isNullable() || param.getValue() != null)) {
if (!method.isNullable() && param.getValue() instanceof String && StringUtils.isEmpty(param.getValue().toString())) {
continue;
}
//假定属性名格式如:amount,amount_GE 或者 extend_custType
String propName;
if(param.getPropName().indexOf("extend_")>=0){
String propNameGps =param.getPropName().substring("extend_".length());
propName = String.format("extend->'$.%s'",propNameGps.split("_")[0]);
}else{
propName = StringUtils.humpToLine(param.getPropName().split("_")[0]);
}
switch (method) {
case GT:
queryWrapper.gt(propName, getTypeValue(param));
break;
case LT:
queryWrapper.lt(propName, getTypeValue(param));
break;
case GE:
queryWrapper.ge(propName, getTypeValue(param));
break;
case LE:
queryWrapper.le(propName, getTypeValue(param));
break;
case NE:
queryWrapper.ne(propName, getTypeValue(param));
break;
case LK:
queryWrapper.like(propName, getTypeValue(param));
break;
case IN:
Collection coll = convertToCollection(param.getValue());
if (!CollectionUtils.isEmpty(coll)) {
queryWrapper.in(propName, coll);
}
break;
case SIN:
queryWrapper.apply(!StringUtils.isEmpty(String.valueOf(param.getValue())), String.format(" FIND_IN_SET(%s,%s)", param.getValue(), propName));
break;
case EQ:
queryWrapper.eq(propName, getTypeValue(param));
break;
case SN:
queryWrapper.isNull(propName);
break;
case NN:
queryWrapper.isNotNull(propName);
break;
default:
throw new AppException("未知的条件查询方法!");
}
}
}
}
}
//处理排序字段,格式如 "id": "asc"
if (!CollectionUtils.isEmpty(queryParamVo.getSorts())) {
for (Map.Entry<String, String> entry : queryParamVo.getSorts().entrySet()) {
String order = entry.getValue();
String propName = StringUtils.humpToLine(entry.getKey());
if (StringUtils.isEmpty(order) || SqlKeyword.ASC.name().equals(order.toUpperCase())) {
queryWrapper.orderByAsc(propName);
} else {
queryWrapper.orderByDesc(propName);
}
}
}
return queryWrapper;
}
/**
* 获取查询条件相应的类型值
* @param param
* @return
*/
private static Object getTypeValue(QueryConditionVo param){
if(!StringUtils.isEmpty(param.getType())) {
switch (param.getType()) {
case "amount":
return new BigDecimal(param.getValue().toString());
case "number":
return Long.parseLong(param.getValue().toString());
}
}
return param.getValue();
}
/**
* Array,Objec转Collection
* 逗号分隔的String转Collection
* @param value
* @return
*/
private static Collection convertToCollection(Object value) {
if (value.getClass().isArray()) {
Object[] objs = (Object[]) value;
return convertObjArrayToList(objs);
} else if(value instanceof Collection){
return (Collection) value;
}else{
String[] arr= value.toString().split(",");
return convertObjArrayToList(arr);
}
}
/**
* Object[]转List
* @param objects
* @return
*/
private static Collection convertObjArrayToList(Object[] objects){
List values = new ArrayList<>(objects.length);
for (Object obj : objects) {
values.add(obj);
}
return values;
}
}
仔细看代码还是比较简单的,使用过mybatis-plus的人都能看懂,就不再过多的赘述了。其中特别的一点是,这里增加了对Mysql中Json类型字段的查询处理。举个简单的例子,假如在Mysql中,extend字段的类型是Json,那么我们可以这样去查询这个字段
select id, cust_type, extend->'$.payType' as payType, extend from b_biz_table
where extend->'$.payType'='1'
select id, cust_type, extend->'$.payType' as payType, extend from b_biz_table
where json_contains(extend,json_object('payType','1'))
select id, cust_type, extend->'$.payType' as payType, extend from b_biz_table
group by extend->'$.payType'
同样,在这里,我们也是对字段进行了特殊的处理,凡是以"extend_"开头的查询条件,我们都将它作为Josn字段的查询,处理成如上的格式,最后会拿到解析出来的QueryWrapper去做查询操作。
3. 权限校验
再来说说权限的校验,通常如果我们使用shiro等,都会在每一个对外暴露的方法上增加一个注解用来做该方法的功能权限控制。
但这里我们已经全部抽象到BaseController中就不可能一个一个去加,而是应该在外部去做校验。
在上面BaseController中可以看到有一个自定义的@CommonPermission注解,而我们也正式用这个注解去实现我们的功能权限控制的。
/**
* 通用功能权限
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CommonPermission {
String value() default "";
}
我们通过AOP来对Controller上的方法进行权限的校验。
@Aspect
@Order(5)
@Configuration
@Slf4j
public class CommonPermissionAspect {
@Autowired
CacheClient cacheClient;
@Pointcut("@annotation(com.story.admin.config.aspect.CommonPermission)")
public void baseCommonPointCut() {
}
/**
* 以环绕方式进行拦截
*
* @param joinPoint
* @return
*/
@Around("baseCommonPointCut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
//获取当前调用的通用功能的权限码
String permissionCode= this.getPermissionCode(joinPoint);
//获取按钮权限
String cacheKey = String.format(Constants.RedisCache.PMS_USER_BTNS,LoginContext.getLoginContext().getPin());
if(cacheClient.sismember(cacheKey,permissionCode)){
return joinPoint.proceed();
}else{
return Result.error("您无权限做此操作!");
}
}
/**
* 根据Controller名称以及方法名生成权限码
* @param joinPoint
* @return
*/
private String getPermissionCode(ProceedingJoinPoint joinPoint){
//获取动态代理的目标类型
Class<?> targetCls=joinPoint.getTarget().getClass();
//获取方法签名信息从而获取方法名和参数类型
Signature signature=joinPoint.getSignature();
//将方法签名强转成MethodSignature类型,方便调用
MethodSignature ms= (MethodSignature)signature;
//通过字节码对象以及方法签名获取目标方法对象
Method targetMethod=ms.getMethod();
//获取目标类上的注解
CommonPermission mapperType = targetMethod.getAnnotation(CommonPermission.class);
String permissionCode = mapperType.value();
return permissionCode.replace("{T}",targetCls.getSimpleName().replace("Controller",""));
}
@AfterReturning(returning = "rvt", pointcut = "baseCommonPointCut()")
public void doAfterReturning(Object rvt){
}
@AfterThrowing(throwing = "ex", pointcut = "baseCommonPointCut()")
public void afterThrowing(Throwable ex){
}
}
逻辑还是比较简单,这样就对我们定义的权限编码有一个基本的格式要求,即凡是继承BaseController并使用@CommonPermission注解的Controller方法,在定义权限编码时都需要使用实体名+Query/Save/Delete/Import/Export的格式,诸如Query,Save,*Delete这样的编码。这样,我们就可以针对这部分从BaseController继承而来的Controller,做统一的权限处理了。
4. 总结
其实上面的思路很简单,只要明白了其中的想法,自己根据实际情况实现也是比较简单的。很多时候我们会通过修改模板并使用代码生成器自动去生成我们的基础代码,其实也不乏是一种解决方案。只是每一个方法都有自己的优点和劣势,因此如何选择和权衡,真的就掌握在你的手中,想清楚,做就好了。