版权说明: 本文由博主keep丶原创,转载请注明出处。
原文地址: https://blog.csdn.net/qq_38688267/article/details/112675319
代码已同步更新到:https://gitee.com/zengzefeng/easy_frame
文章目录
场景介绍
在系统开发过程中,树状结构对象时非常常见的,比如组织架构等,一般前端展示时都是以树状图的形式展示:
而这种数据通常伴随着移动节点
、删除节点
等功能,这些功能虽然逻辑不复杂,但是实现起来细节还是蛮多的,而且一般一个系统都会有很多树状结构的对象,每个都有这些功能,写起来还是挺麻烦的。
所以封装了一个通用的树状结构对象基础功能实现,极大的简化了后续对树状结构对象基础功能的开发工作,一起来看看吧:
封装类
BaseTreePO
树状结构对象基类
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModelProperty;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.io.Serializable;
import java.util.Date;
/**
* 树状结构对象基类
*
* @author zzf
* @date 2020/7/30 11:09
*/
@Getter
@Setter
@ToString
public abstract class BaseTreePO implements Serializable {
@ApiModelProperty("名字")
private String name;
@ApiModelProperty("短编码")
private String path;
@ApiModelProperty("父编码")
private String parentPath;
@ApiModelProperty("层级")
private Integer level;
@ApiModelProperty("排序")
private Integer orderNum;
@ApiModelProperty("是否叶节点 [0不是 1是]")
private Boolean leafNode;
@ApiModelProperty("是否启用 [0禁用 1启用]")
private Integer enabled;
@ApiModelProperty("备注")
private String remark;
@ApiModelProperty("是否删除 [0:未删除 1:已删除]")
private Integer deleted;
@TableField(fill = FieldFill.INSERT)
@ApiModelProperty("创建人")
private Integer createBy;
@TableField(fill = FieldFill.INSERT)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", locale = "zh", timezone = "GMT+8")
@ApiModelProperty("创建时间")
private Date createTime;
@TableField(fill = FieldFill.UPDATE)
@ApiModelProperty("修改人")
private Integer updateBy;
@TableField(fill = FieldFill.UPDATE)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", locale = "zh", timezone = "GMT+8")
@ApiModelProperty("修改时间")
private Date updateTime;
}
TreeService
树状对象业务接口(全文核心)
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import com.baomidou.mybatisplus.extension.service.IService;
import com.copm.ifm.base.basic.constant.DbConstants;
import com.copm.ifm.base.basic.constant.NumberConstants;
import com.copm.ifm.base.basic.exception.ServiceException;
import com.copm.ifm.base.basic.pojo.BaseTreePO;
import com.copm.ifm.base.basic.util.ObjectUtils;
import com.copm.ifm.base.service.util.TreeUtil;
import org.apache.commons.lang.StringUtils;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import static com.copm.ifm.base.basic.constant.DbConstants.DEFAULT_YES;
/**
* 树状图相关接口
*
* @author zzf
* @date 2021/1/11 13:56
*/
public interface TreeService<T extends BaseTreePO> extends IService<T> {
/**
* 添加树状对象
*
* @param entity 树状对象
*/
@Transactional(rollbackFor = Exception.class)
default void addTree(T entity) {
if (StringUtils.isBlank(entity.getParentPath())) {
entity.setParentPath(null);
} else {
if (entity.getParentPath().length() % TreeUtil.DEFAULT_PATH_LENGTH > 0) {
throw new ServiceException("父路径取值错误!");
}
}
//前置任务
beforeTreeAddProcessor(entity);
//添加
entity.setPath(this.getNewChildPath(entity, entity.getParentPath()));
entity.setLevel(TreeUtil.getLevel(entity.getPath()));
entity.setEnabled(DEFAULT_YES);
this.save(entity);
//后置任务
afterTreeAddProcessor(entity);
//判断并修改父级 是否叶子节点
if (StrUtil.isNotEmpty(entity.getParentPath())) {
modifyParentLeafNodeToFalse(entity);
}
}
/**
* 移动节点
*
* <p>
* 1.更新原父节点(注意无原父节点情况)
* 2.更新新父节点(注意无新父节点情况)
* 3.遍历所有子节点,批量更新
* 4.更新自己(前面的更新需要用到 entity 的 path 和 parentPath)
*
* @param movedNodeId 需要移动的节点id
* @param newParentNodeId 新父级节点id
*/
@Transactional(rollbackFor = Exception.class)
default void moveNode(Integer movedNodeId, Integer newParentNodeId) {
if (ObjectUtil.isEmpty(movedNodeId)) {
throw new ServiceException("movedNodeId不能为空。");
}
// 记录需要修改的对象
List<T> batchUpdateList = new ArrayList<>();
T entity = getBaseMapper().selectById(movedNodeId);
// 1.更新原父节点的叶子节点(注意无原父节点情况)
this.checkAndModifyParentLeafNodeToYes(entity);
String newParentPath;
if (ObjectUtils.isNull(newParentNodeId)) {
newParentPath = "";
} else {
T parentNode = getBaseMapper().selectById(newParentNodeId);
newParentPath = parentNode.getPath();
// 2.更新新父节点
if (parentNode.getLeafNode()) {
parentNode.setLeafNode(false);
getBaseMapper().updateById(parentNode);
}
}
String oldChildPath = entity.getPath();
String newChildPath = this.getNewChildPath(entity, newParentPath);
// 3.遍历所有子节点,批量更新
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<T>()
// 这里要用likeRight,将其所有子节点全部修改
.likeRight(T::getParentPath, entity.getPath());
List<T> childrenList = this.getBaseMapper().selectList(this.doAppend(wrapper, entity));
if (CollectionUtil.isNotEmpty(childrenList)) {
for (T s : childrenList) {
s.setParentPath(s.getParentPath().replaceFirst(oldChildPath, newChildPath));
s.setPath(s.getPath().replaceFirst(oldChildPath, newChildPath));
s.setLevel(TreeUtil.getLevel(s.getPath()));
}
batchUpdateList.addAll(childrenList);
}
// 4.更新自己
entity.setParentPath(newParentPath);
entity.setPath(newChildPath);
entity.setLevel(TreeUtil.getLevel(newChildPath));
batchUpdateList.add(entity);
this.updateBatchById(batchUpdateList);
}
/**
* 删除节点
* 1.判断存在未删除的孩子节点,不允许删除
* 2.更新自己的 deleted 字段
* 3.判断是否更新父节点
*
* @param id 需要删除的节点id
*/
default void deleteNode(Integer id) {
T entity = getBaseMapper().selectById(id);
if (!entity.getLeafNode()) {
throw new ServiceException("该节点下存在未删除的孩子节点,不允许删除。");
}
// 2.更新自己的 deleted 字段
entity.setDeleted(DEFAULT_YES);
this.getBaseMapper().updateById(entity);
// 3.判断是否更新父节点
this.checkAndModifyParentLeafNodeToYes(entity);
}
/**
* 获取 parentPath下的 最大的子节点路径
*
* @param entity 树状对象,提供其他查询条件,
* @param parentPath 指定父路径,可能和 entity.getParentPath 不一致
* @return 当前最大的子节点路径
*/
default String getCurrentMaximumChildPath(T entity, String parentPath) {
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<T>()
.orderByDesc(T::getPath)
.last("limit 1");
if (StrUtil.isEmpty(parentPath)) {
wrapper.isNull(T::getParentPath);
} else {
wrapper.eq(T::getParentPath, parentPath);
}
T maxPathChild = this.getBaseMapper().selectOne(this.doAppend(wrapper, entity));
return ObjectUtil.isEmpty(maxPathChild) ? null : maxPathChild.getPath();
}
/**
* 获取 parentPath 下的新子路径
*
* @param entity 提供其他查询条件
* @param parentPath 父节点路径
* @return parentPath 下的最新子路径
*/
default String getNewChildPath(T entity, String parentPath) {
String newChildPath;
String currentMaximumChildPath = this.getCurrentMaximumChildPath(entity, parentPath);
if (StrUtil.isEmpty(currentMaximumChildPath)) {
newChildPath = TreeUtil.getFirstChildPath(parentPath);
} else {
newChildPath = TreeUtil.getNextChildPath(currentMaximumChildPath);
}
return newChildPath;
}
/**
* 查询父节点
*
* @param entity 节点对象
* @return 父节点
*/
default T doSelectParent(T entity) {
// default
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
if (StringUtils.isBlank(entity.getParentPath())) {
throw new ServiceException("该节点为一级节点,不存在父节点!");
} else {
wrapper.eq(T::getPath, entity.getParentPath());
}
List<T> list = this.getBaseMapper().selectList(this.doAppend(wrapper, entity));
if (CollectionUtil.isEmpty(list)) {
throw new ServiceException("未找到父节点或数据传输错误,请联系管理员。");
}
if (list.size() > 1) {
throw new ServiceException("查询到的父节点个数大于1,数据异常,请联系管理员。");
}
return list.get(0);
}
/**
* 拼接 wrapper 条件后查询返回查询集合
*
* @param wrapper 默认 wrapper
* @param entity 提供其他查询条件
* @return 返回查询集合
*/
default LambdaQueryWrapper<T> doAppend(LambdaQueryWrapper<T> wrapper, T entity) {
wrapper.eq(T::getDeleted, DbConstants.DEFAULT_NO);
List<SFunction<T, ?>> selectConditionFunctions = this.appendSelectConditionFunctions();
if (ObjectUtils.isNotNull(selectConditionFunctions)) {
selectConditionFunctions.forEach(
tsFunction -> wrapper.eq(tsFunction, tsFunction.apply(entity)));
}
return wrapper;
}
/**
* 添加查询参数
* <p>
* 这个是为了适应不同实体类的树状结构条件:
* 如果一个实体类只会有一个树状结构,则不需要添加条件,反之则需要传相关条件来确定是那颗树
* <p>
* 举个例子:
* 假如我们的菜单是针对用户的,即每个用户都有一个菜单树,都保存在SysMenu表中
* 那么我们在编辑一棵树时就需要传相关用户id来唯一确定一棵树。
* <p>
* 换句话说,在只有一棵树的情况下,path和parentPath都是唯一的,
* 当存在一个或多个维度(上例中的用户)来区分树时,path和parentPath就不是唯一的了。
* <p>
* 所以这里需要传入对应的条件使得根据该条件能够查询到唯一的数据。
* 按上述例子来说,就应该传入Arrays.asList(SysMenu::getUserId);
*
* @return 用于Wrapper中的参数方法集合
*/
List<SFunction<T, ?>> appendSelectConditionFunctions();
/**
* 修改父节点的是否叶子节点为 false
*
* @param entity 树状对象
*/
default void modifyParentLeafNodeToFalse(T entity) {
T parent = this.doSelectParent(entity);
parent.setLeafNode(false);
this.getBaseMapper().updateById(parent);
}
/**
* 判断是否修改父节点的是否叶子节点为 true
*
* @param entity 树状对象
*/
default void checkAndModifyParentLeafNodeToYes(T entity) {
if (StrUtil.isNotEmpty(entity.getParentPath())) {
T parent = this.doSelectParent(entity);
if (ObjectUtil.isNotEmpty(parent)) {
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<T>()
.eq(T::getParentPath, entity.getParentPath());
Integer childCount = this.getBaseMapper().selectCount(this.doAppend(wrapper, entity));
parent.setLeafNode(childCount.equals(1));
this.getBaseMapper().updateById(parent);
}
}
}
/**
* 前置任务,比如名字判重等工作
*
* @param entity 树状对象
*/
default void beforeTreeAddProcessor(T entity) {
}
/**
* 后置任务
*
* @param entity 树状对象
*/
default void afterTreeAddProcessor(T entity) {
}
TreeUtil
工具类
import cn.hutool.core.util.ObjectUtil;
import com.copm.ifm.base.basic.exception.ServiceException;
import com.copm.ifm.base.basic.pojo.BaseTreePO;
import com.copm.ifm.base.basic.pojo.vo.TreeVO;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.BeanUtils;
import java.text.DecimalFormat;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
/**
* 树状结构工具类
*
* @author zzf
* @date 2020/7/30 11:14
*/
public class TreeUtil {
public static final int DEFAULT_PATH_LENGTH = 4;
private static final DecimalFormat format = new DecimalFormat("0000");
public static final String FIRST_NODE = "0001";
/**
* 递归生成树状结构
*/
public static TreeVO getTree(Collection<? extends BaseTreePO> list) {
TreeVO treeVO = TreeVO.head();
TreeUtil.getTree(list, treeVO);
return treeVO;
}
/**
* 递归生成树状结构
*/
private static void getTree(Collection<? extends BaseTreePO> list, TreeVO parentNode) {
List<TreeVO> childList = new ArrayList<>();
list.forEach(s -> {
if (isChild(s, parentNode)) {
TreeVO treeVO = doToDTO(s);
if (!s.getLeafNode()) {
getTree(list, treeVO);
}
childList.add(treeVO);
}
});
//排序
if (ObjectUtil.isNotEmpty(childList)) {
childList.sort(Comparator.comparingInt(TreeVO::getOrderNum));
}
parentNode.setChildList(childList);
}
/**
* 对象转换
*/
private static TreeVO doToDTO(BaseTreePO treeDO) {
TreeVO treeVO = new TreeVO();
BeanUtils.copyProperties(treeDO, treeVO);
return treeVO;
}
/**
* 判断是否为其子节点
*
* @param treeDO 树状实体对象
* @param parentNode 父级节点对象
*/
private static boolean isChild(BaseTreePO treeDO, TreeVO parentNode) {
if (StringUtils.isEmpty(parentNode.getPath())) {
return StringUtils.isEmpty(treeDO.getParentPath());
} else {
return parentNode.getPath().equals(treeDO.getParentPath());
}
}
/**
* 获得第一个子集的编号
*/
public static String getFirstChildPath(String parentPath) {
return (parentPath == null ? "" : parentPath) + format.format(1);
}
/**
* 获得下一个子集的编号
*
* @param prevChildPath 上一个子集的编号
*/
public static String getNextChildPath(String prevChildPath) {
Number num;
try {
num = format.parse(prevChildPath.substring(prevChildPath.length() - DEFAULT_PATH_LENGTH));
} catch (ParseException e) {
throw new ServiceException("编码生成错误,请联系管理员~");
}
return getParentPath(prevChildPath) + format.format(num.intValue() + 1);
}
/**
* 获得父路径
*/
public static String getParentPath(String childPath) {
int level = childPath.length() / DEFAULT_PATH_LENGTH;
return childPath.substring(0, (level - 1) * DEFAULT_PATH_LENGTH);
}
/**
* 根据路径获得层级
*/
public static Integer getLevel(String path) {
if (path == null) {
return 0;
}
return path.length() / DEFAULT_PATH_LENGTH;
}
/**
* 获取路径当层的序号
*/
public static String getPathEnd(String path) {
return path.substring(path.length() - DEFAULT_PATH_LENGTH);
}
}
使用
SysMenu
实体类
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.copm.ifm.base.basic.pojo.BaseTreePO;
import com.copm.ifm.base.basic.pojo.MenuContentList;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
/**
* 系统菜单表
*
* @author zzf
* @date 2020-12-14
*/
@Getter
@Setter
@ToString
@TableName(value = "sys_menu", autoResultMap = true)
@ApiModel("系统菜单表")
public class SysMenu extends BaseTreePO {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@ApiModelProperty("名称 - 繁体")
private String ftName;
@ApiModelProperty("名称 - 英文")
private String enName;
@ApiModelProperty("路径")
private String url;
@ApiModelProperty("操作权限")
@TableField(typeHandler = JacksonTypeHandler.class)
private MenuContentList operations;
}
SysMenuService
服务类
import com.copm.ifm.base.service.TreeService;
/**
* 系统菜单表 服务类
*
* @author zzf
* @since 2020-12-14
*/
public interface SysMenuService extends TreeService<SysMenu> {
}
SysMenuServiceImpl
服务实现类
/**
* 系统菜单表 服务实现类
*
* @author zzf
* @since 2020-12-14
*/
@Service
public class SysMenuServiceImpl extends ServiceImpl<SysMenuMapper, SysMenu> implements SysMenuService {
@Override
public List<SFunction<SysMenu, ?>> appendSelectConditionFunctions() {
return null;
}
}
SysMenuController
接口类
import com.copm.ifm.base.basic.ServiceResponse;
import com.copm.ifm.base.service.base.BaseController;
import com.copm.ifm.servers.sys.client.SysMenuClient;
import com.copm.ifm.servers.sys.entity.SysMenu;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* 菜单表相关接口类
*
* @author zzf
* @date 2020/12/21 15:18
*/
@Slf4j
@Api(tags = "菜单/sys-menu")
@RestController
@RequestMapping("/sys-menu")
public class SysMenuController extends BaseController implements SysMenuClient {
@Autowired
SysMenuService service;
@ApiOperation("移动节点")
@PutMapping("/move-node")
public ServiceResponse<?> testMove(@ApiParam("需要移动的节点id")
@RequestParam Integer movedNodeId,
@ApiParam("新的父节点的id")
@RequestParam(required = false) Integer newParentNodeId) {
service.moveNode(movedNodeId, newParentNodeId);
return success();
}
@ApiOperation("删除节点")
@DeleteMapping("/by-id")
public ServiceResponse<?> testDelete(@RequestParam Integer id) {
service.deleteNode(id);
return success();
}
}
注意
作者在实现过程中踩了个坑,就是在TreeService
中执行T::getPath
时,会报错:MybatisPlusException: can not find lambda cache for this entity [com.copm.ifm.base.basic.pojo.BaseTreePO]
解决方案是为BaseTreePO
创建个Mapper类,原因也写在了代码中,更多请看作者的另外一篇博客:MybatisPlusException: can not find lambda cache for this entity[]异常解决
package com.copm.ifm.base.service.base.mapper;
import com.baomidou.mybatisplus.core.MybatisMapperAnnotationBuilder;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.copm.ifm.base.basic.pojo.BaseTreePO;
/**
* 因为MP3.2+之后不会缓存实体类的父类字段信息,所以在使用泛型的Lambda表达式时会报错:
* {@code MybatisPlusException: can not find lambda cache for this entity [com.copm.ifm.base.basic.pojo.BaseTreePO]}
* 原因是在执行{@link com.baomidou.mybatisplus.core.toolkit.LambdaUtils#getColumnMap(Class)}时
* {@code COLUMN_CACHE_MAP}中没有{@link BaseTreePO}的信息
*
* <p>
* 根据源码
* {@link com.baomidou.mybatisplus.core.MybatisMapperRegistry#addMapper(Class)}
* {@link MybatisMapperAnnotationBuilder#parse()}方法的加载逻辑
* <p>
* 他会将所有扫描到的mapper中的泛型({@link BaseMapper<Class>}中的Class,即实体类)的字段信息缓存到
* {@link com.baomidou.mybatisplus.core.toolkit.LambdaUtils}中的{@code COLUMN_CACHE_MAP}中。
* 但是MP3.2+之后不会加载父类的信息,所以{@code COLUMN_CACHE_MAP}中没有相关缓存,就报错了。
* <p>
* 因此我们单独为{@link BaseTreePO}添加一个的Mapper类,这样他就会缓存该类的信息了。
*/
public interface BaseTreeMapper extends BaseMapper<BaseTreePO> {
}
总结
其实作者最开始只封装了BaseTreePO
和TreeUtil
两个,后面发现重复的工作还是特别多,就花了些封装了最核心的TreeService
,TreeService
里面的代码还有很多可以优化的地方,比如查询时只查询有用的几列,修改时创个新对象,只修改变动的列,这样性能方面就更好了。
不过刚通过测试就怀着激动的心情波不急待的想分享出来,还没来得及优化这些,后续有优化会同步更新到代码仓库里。
最后还想说一句,大家在编码过程中一定要想尽办法“偷懒”,想办法封装,一步步的抽离共性,这样会让我们的编码效率/质量得到极大的提升!