在投入实际使用后,版本已经迭代,该方案已经弃用 !!!主要原因 : 无法处理数据出现循环问题、算法时间复杂度较高
新版功能 : 解决循环问题,支持直接弃用循环数据,支持自定义原始数据,算法采用 Hash 表
新版文章目前暂未更新,目前先更新源码到 GITEE : TreeNodeUtil.java
一、介绍
多级菜单的实现是许多人关注的主题,然而,大多数教程在解决这一问题时,往往忽视了代码的重复开发问题。比如,如果我们需要实现多级评论,是否又要重新编写相似的代码?
本文对原有内容进行了修改和改进,即使你没有阅读过原文,也能从中获得有益的见解。在您开发的时候,也有可能会遇到类似问题。
在实际项目中,我将之前编写的代码进行了应用,发现仍有部分需要优化和修复的地方。本文将分享我在实际项目中的一些经验与改进。原文请参考:多级菜单、多级评论、多级部门、多级分类统一工具类,数据库tree_path字段的考虑。
二、说说在开发中遇到的问题
1. 数据类型问题
如果采用 Collections.singletonList(0L) 为默认集合数据,会导致数据推断为Long类型,如下 :
// 使用 Collections.singletonList 创建的不可变列表,默认类型为 List<Long>
List<Number> ids = Collections.singletonList(0L);
// 创建一个包含相同数字的列表,类型为 List<Number>
List<Number> otherIds = Arrays.asList(0);
// 判断两个列表是否相等
boolean areEqual = ids.equals(otherIds);
// 输出结果
System.out.println("ids: " + ids); // 输出: [0]
System.out.println("otherIds: " + otherIds); // 输出: [0]
System.out.println("Are ids and otherIds equal? " + areEqual); // 输出: false
因为在项目中我们会使用到其他类型为主键来构建树形结构,所以这里是需要修改的,算是一个BUG了
2. generateTreePath 方法的实用性
generateTreePath() 方法主要的作用是生成一个tree_path字段,但是在实际使用中,也会出现默认数据为Long与其他Number不相等的情况,而且发现可以不需要对象中有tree_path属性,也能生成tree_path,修改后如下:
public static String generateTreePath(Number rootId, Number parentId, Function<Serializable, String> getTreePath) {
StringBuilder treePath = new StringBuilder();
if (rootId.equals(parentId)) {
// 1. 如果当前节点是父节点直接返回
treePath.append(parentId);
} else {
// 2. 调用者将当前元素的父元素查出来,方便后续拼接
String dbTreePath = getTreePath.apply(parentId);
// 3. 父元素的 treePath + "," + 父元素的id
if (StringUtils.hasText(dbTreePath)) {
// 原文 treePath.append(byId.getTreePath()).append(",").append(byId.getId());
treePath.append(dbTreePath).append(TREE_STRUCTURE_DELIMITER).append(parentId);
}
}
return treePath.toString();
}
这样编写后的好处:
- 不再依赖 TreeNode , Function<Serializable, String> 即可获取到之前的字段
- 类中tree_path不一定能获取到
3. TreeNode中 tree_path 字段问题
先看一下原本的TreeNode.java , 如下 :
public interface ITreeNode<T> {
/**
* @return 获取当前元素Id
*/
Object getId();
/**
* @return 获取父元素Id
*/
Object getParentId();
/**
* @return 获取当前元素的 children 属性
*/
List<T> getChildren();
/**
* ( 如果数据库设计有tree_path字段可覆盖此方法来生成tree_path路径 )
*
* @return 获取树路径
*/
default Object getTreePath() { return ""; }
}
在项目返回时出现的问题
在我们使用的时候,我们会实现这个类,比如 :
@Data
public class DeptPageVo implements ITreeNode<DeptPageVo> {
@Schema(description = "部门ID")
private Integer id;
@Schema(description = "父部门ID")
private Integer parentId;
@Schema(description = "部门名称")
private String name;
@Schema(description = "部门排序(数字越小排名越靠前)")
private Integer sort;
@Schema(description = "部门是否可见(0: 显示 ; 1: 隐藏)")
private Integer status;
@Schema(description = "子部门")
@JsonInclude(value = JsonInclude.Include.NON_EMPTY)
private List<DeptPageVo> children;
}
当我们实现了TreeNode类后,你会发现返回数据,咦?为什么多了一个字段呢?问题就出在实现类中含有getTreePath()方法,导致返回的时候多一个字段
{
"id": 1,
"parentId": 0,
"name": "技术部",
"sort": 1,
"status": 0,
"treePath":"",
"children": [
{
"id": 2,
"parentId": 1,
"name": "开发组",
"sort": 1,
"status": 0,
"treePath":"",
"children": []
}
]
}
遇到这种情况,要么让类不序列化这个属性,要么重写逻辑,经过我分析后,再加上之前的问题,以及原文评论的中说的可以优化的点。由于修改的部分比较多,所以编写本文重构一下。
三、重构
1. 编写 ITreeNode 固定树形结构属性
/**
* 固定树形结构属性
*
* @author: YiFei
*/
public interface ITreeNode<T> {
/**
* 获取当前元素Id
*
* @return 当前元素Id
*/
Number getId();
/**
* 获取父元素Id
*
* @return 父元素Id
*/
Number getParentId();
/**
* 获取当前元素的 children 属性
*
* @return 当前元素的 children 属性
*/
List<T> getChildren();
/**
* 返回值为 void 表示子类不再支持链式表达式
* 设置 children 值(注: 只有当 children 为 null时设置 new ArrayList())
*
* @param children children 列表
*/
void setChildren(List<T> children);
}
2. 编写具体工具类
/**
* 树形结构工具类
*
* @author : YiFei
*/
public class TreeNodeUtil {
/**
* 父子模块分组,父元素名称
*/
private static final String PARENT_NAME = "parent";
/**
* 父子模块分组,子元素名称
*/
private static final String CHILDREN_NAME = "children";
/**
* 树形结构拼接符号
*/
private static final String TREE_STRUCTURE_DELIMITER = ",";
private TreeNodeUtil() {
}
public static <T extends ITreeNode<T>> List<T> buildTree(List<T> dataList, Collection<? extends Number> ids) {
return buildTree(dataList, ids, (data) -> data, (item) -> true);
}
public static <T extends ITreeNode<T>> List<T> buildTree(List<T> dataList, Collection<? extends Number> ids, Function<T, T> map) {
return buildTree(dataList, ids, map, (item) -> true);
}
/**
* 数据集合构建成树形结构 ( 注: 如果最开始的 ids 不在 dataList 中,不会进行任何处理 )
*
* @param dataList 数据集合
* @param ids 父元素的 Id 集合
* @param map 调用者提供 Function<T, T> 由调用着决定数据最终呈现形势
* @param filter 调用者提供 Predicate<T> true 表示过滤 ( 注: 如果将父元素过滤掉等于剪枝 )
* @param <T> extends ITreeNode
* @return 树形结构对象
*/
public static <T extends ITreeNode<T>> List<T> buildTree(List<T> dataList, Collection<? extends Number> ids, Function<T, T> map, Predicate<T> filter) {
if (CollectionUtils.isEmpty(ids)) {
return Collections.emptyList();
}
// 1. 将数据分为 父子结构
Map<String, List<T>> nodeMap = dataList.stream()
.filter(filter)
.collect(Collectors.groupingBy(item -> ids.contains(item.getParentId()) ? PARENT_NAME : CHILDREN_NAME));
List<T> parent = nodeMap.getOrDefault(PARENT_NAME, Collections.emptyList());
List<T> children = nodeMap.getOrDefault(CHILDREN_NAME, Collections.emptyList());
// 1.1 如果未分出或过滤了父元素则将子元素返回
if (parent.isEmpty()) {
return children;
}
// 2. 使用有序集合存储下一次变量的 ids
List<Number> nextIds = new ArrayList<>(dataList.size());
// 3. 遍历父元素 以及修改父元素内容 ( 不能使用 map 优化 , 使用 map 优化会导致无序 [ 除非系统对树形结构不要求有序 ] )
List<T> collectParent = parent.stream().map(map).toList();
for (T parentItem : collectParent) {
List<T> itemChildren = parentItem.getChildren();
// 3.1 当 parentItem.getChildren() == null 时 , 设置默认值 new ArrayList<>()
if (itemChildren == null) {
itemChildren = new ArrayList<>();
parentItem.setChildren(itemChildren);
}
// 3.2 遍历子元素获取 child.parentId 与 parent.id 相等的值
for (T child : children) {
if (parentItem.getId().equals(child.getParentId())) {
// 3.3 添加子元素
itemChildren.add(child);
// 3.4 添加下一次遍历的 id
nextIds.add(child.getParentId());
}
}
}
buildTree(children, nextIds, map, filter);
return parent;
}
/**
* 根据 parentId 生成路径
*
* @param parentId 当前元素的 parentId
* @param getTreePath 返回父元素 treePath
* @return 当前需要插入的元素 树形路径
*/
public static String generateTreePath(Number rootId, Number parentId, Function<Serializable, String> getTreePath) {
StringBuilder treePath = new StringBuilder();
if (rootId.equals(parentId)) {
// 1. 如果当前节点是父节点直接返回
treePath.append(parentId);
} else {
// 2. 调用者将当前元素的父元素查出来,方便后续拼接
String dbTreePath = getTreePath.apply(parentId);
// 3. 父元素的 treePath + "," + 父元素的id
if (StringUtils.hasText(dbTreePath)) {
treePath.append(dbTreePath).append(TREE_STRUCTURE_DELIMITER).append(parentId);
}
}
return treePath.toString();
}
}
四、项目中测试
项目中有下拉列表功能,那我们用工具类来实现一下吧
定义 Option 类
@Schema(description = "下拉选项对象")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Option<T extends Number> implements ITreeNode<Option<T>> {
@Schema(name = "value", description = "选项的值")
@JsonProperty("value")
private T id;
@Schema(description = "父元素Id", hidden = true)
@JsonIgnore
private T parentId;
@Schema(description = "选项的标签")
private String label;
@Schema(description = "子选项列表")
@JsonInclude(value = JsonInclude.Include.NON_EMPTY)
private List<Option<T>> children;
}
在多级菜单中编写下拉列表接口
/**
* @return 菜单下拉列表
*/
@Override
public List<Option<Integer>> listMenuOptions() {
// 1. 查询所有数据
List<SysMenu> list = this.lambdaQuery()
.select(SysMenu::getId, SysMenu::getParentId, SysMenu::getName)
.orderByAsc(SysMenu::getSort)
.list();
// 2. list -> options 返回
return TreeNodeUtil.buildTree(menuConverter.list2options(list), Collections.singletonList(0));
}
查看最终返回结果
是不是非常简单,一行代码就能构建树形结构数据(^▽^)
五。源码 & 结束语
源码地址 | 欢迎大家start | 希望大家能共同进步
随着后续开发,可能还会更新,如果有什么编码不对的地方或者可以优化的地方,请大家提出来