一行代码搞定平面数据转树形结构 | 适用于多级菜单、多级评论、多级部门、多级分类、下拉列表

1,181 阅读6分钟

在投入实际使用后,版本已经迭代,该方案已经弃用 !!!

主要原因 : 无法处理数据出现循环问题、算法时间复杂度较高

新版功能 : 解决循环问题,支持直接弃用循环数据,支持自定义原始数据,算法采用 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();
}

这样编写后的好处:

  1. 不再依赖 TreeNode , Function<Serializable, String> 即可获取到之前的字段
  2. 类中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));
}

查看最终返回结果

是不是非常简单,一行代码就能构建树形结构数据(^▽^)

image.png

五。源码 & 结束语

源码地址 | 欢迎大家start | 希望大家能共同进步

随着后续开发,可能还会更新,如果有什么编码不对的地方或者可以优化的地方,请大家提出来