⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 juejin.cn/post/7301909270907748378 「翼飞」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

一、介绍

你能看到很多人都在介绍如何实现多级菜单的效果,但是都有一个共同的缺点,那就是没有解决代码会重复开发的问题。如果我需要实现多级评论呢,是否又需要自己再写一遍?

为了简化开发过程并提高代码的可维护性,我们可以创建一个统一的工具类来处理这些需求。在本文中,我将介绍如何使用SpringBoot创建一个返回多级菜单、多级评论、多级部门、多级分类的统一工具类。

介绍数据库字段设计

数据库设计

「主要是介绍是否需要tree_path字段。」

多级节点的数据库大家都知道,一般会有id,parentId字段,但是对于tree_path字段,这个需要根据设计者来定。

优点:

  • 如果你对数据的读取操作比较频繁,而且需要快速查询某个节点的所有子节点或父节点,那么使用tree_path 字段可以提高查询效率。
  • tree_path 字段可以使用路径字符串表示节点的层级关系,例如使用逗号分隔的节点ID列表。这样,可以通过模糊匹配tree_path 字段来查询某个节点的所有子节点或父节点,而无需进行递归查询。
  • 你可以使用模糊匹配的方式,找到所有以该节点的 tree_path 开头的子节点,并将它们删除。而无需进行递归删除。

缺点:

  • 每次插入时,需要更新tree_path 字段,这可能会导致性能下降。
  • tree_path 字段的长度可能会随着树的深度增加而增加,可能会占用更多的存储空间。

因此,在设计数据库评论字段时,需要权衡使用treepath字段和父评论ID字段的优缺点,并根据具体的应用场景和需求做出选择。如果你更关注读取操作的效率和查询、删除的灵活性,可以考虑使用tree_path 字段。如果你更关注写入操作的效率和数据一致性,并且树的深度不会很大,那么使用父评论ID字段来实现多级评论可能更简单和高效。

二、统一工具类具体实现

1. 定义接口,统一规范

对于有 lombok 的小伙伴,实现这个方法很简单,只需要加上@Data即可

/**
* @Description: 固定属性结构属性
* @Author: yiFei
*/
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 ""; }
}

2. 编写工具类TreeNodeUtil

其中我们需要实现能将一个List元素构建成熟悉结构

我们需要实现生成tree_path字段

我们需要优雅的实现该方法

/**
* @Description: 树形结构工具类
* @Author: yiFei
*/
public class TreeNodeUtil {

private static final Logger log = LoggerFactory.getLogger(TreeNodeUtil.class);

public static final String PARENT_NAME = "parent";

public static final String CHILDREN_NAME = "children";

public static final List<Object> IDS = Collections.singletonList(0L);

public static <T extends ITreeNode> List<T> buildTree(List<T> dataList) {
return buildTree(dataList, IDS, (data) -> data, (item) -> true);
}

public static <T extends ITreeNode> List<T> buildTree(List<T> dataList, Function<T, T> map) {
return buildTree(dataList, IDS, map, (item) -> true);
}

public static <T extends ITreeNode> List<T> buildTree(List<T> dataList, Function<T, T> map, Predicate<T> filter) {
return buildTree(dataList, IDS, map, filter);
}

public static <T extends ITreeNode> List<T> buildTree(List<T> dataList, List<Object> ids) {
return buildTree(dataList, ids, (data) -> data, (item) -> true);
}

public static <T extends ITreeNode> List<T> buildTree(List<T> dataList, List<Object> 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> false 表示过滤 ( 注: 如果将父元素过滤掉等于剪枝 )
* @param <T> extends ITreeNode
* @return
*/
public static <T extends ITreeNode> List<T> buildTree(List<T> dataList, List<Object> 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.size() == 0) {
return children;
}
// 2. 使用有序集合存储下一次变量的 ids
List<Object> nextIds = new ArrayList<>(dataList.size());
// 3. 遍历父元素 以及修改父元素内容
List<T> collectParent = parent.stream().map(map).collect(Collectors.toList());
for (T parentItem : collectParent) {
// 3.1 如果子元素已经加完,直接进入下一轮循环
if (nextIds.size() == children.size()) {
break;
}
// 3.2 过滤出 parent.id == children.parentId 的元素
children.stream()
.filter(childrenItem -> parentItem.getId().equals(childrenItem.getParentId()))
.forEach(childrenItem -> {
// 3.3 这次的子元素为下一次的父元素
nextIds.add(childrenItem.getParentId());
// 3.4 添加子元素到 parentItem.children 中
try {
parentItem.getChildren().add(childrenItem);
} catch (Exception e) {
log.warn("TreeNodeUtil 发生错误, 传入参数中 children 不能为 null,解决方法: \n" +
"方法一、在map(推荐)或filter中初始化 \n" +
"方法二、List<T> children = new ArrayList<>() \n" +
"方法三、初始化块对属性赋初值\n" +
"方法四、构造时对属性赋初值");
}
});
}
buildTree(children, nextIds, map, filter);
return parent;
}


/**
* 生成路径 treePath 路径
*
* @param currentId 当前元素的 id
* @param getById 用户返回一个 T
* @param <T>
* @return
*/
public static <T extends ITreeNode> String generateTreePath(Serializable currentId, Function<Serializable, T> getById) {
StringBuffer treePath = new StringBuffer();
if (SystemConstants.ROOT_NODE_ID.equals(currentId)) {
// 1. 如果当前节点是父节点直接返回
treePath.append(currentId);
} else {
// 2. 调用者将当前元素的父元素查出来,方便后续拼接
T byId = getById.apply(currentId);
// 3. 父元素的 treePath + "," + 父元素的id
if (!ObjectUtils.isEmpty(byId)) {
treePath.append(byId.getTreePath()).append(",").append(byId.getId());
}
}
return treePath.toString();
}

}

这样我们就完成了 TreeNodeUtil 统一工具类,首先我们将元素分为父子两类,让其构建出一个小型树,然后我们将构建的子元素和下次遍历的父节点传入,递归的不断进行,这样就构建出了我们最终的想要实现的效果。

三、测试

定义一个类实现 ITreeNode

/**
* @Description: 测试子元素工具类
* @Author: yiFei
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@AllArgsConstructor
public class TestChildren implements ITreeNode<TestChildren> {

private Long id;

private String name;

private String treePath;

private Long parentId;

public TestChildren(Long id, String name, String treePath, Long parentId) {
this.id = id;
this.name = name;
this.treePath = treePath;
this.parentId = parentId;
}

@TableField(exist = false)
private List<TestChildren> children = new ArrayList<>();
}

测试基本功能

测试基本功能代码:

public static void main(String[] args) {
List<TestChildren> testChildren = new ArrayList<>();
testChildren.add(new TestChildren(1L, "父元素", "", 0L));
testChildren.add(new TestChildren(2L, "子元素1", "1", 1L));
testChildren.add(new TestChildren(3L, "子元素2", "1", 1L));
testChildren.add(new TestChildren(4L, "子元素2的孙子元素", "1,3", 3L));

testChildren = TreeNodeUtil.buildTree(testChildren);

System.out.println(JSONUtil.toJsonStr(Result.success(testChildren)));
}

返回结果:

{
"code": "00000",
"msg": "操作成功",
"data": [{
"id": 1,
"name": "父元素",
"treePath": "",
"parentId": 0,
"children": [{
"id": 2,
"name": "子元素1",
"treePath": "1",
"parentId": 1,
"children": []
}, {
"id": 3,
"name": "子元素2",
"treePath": "1",
"parentId": 1,
"children": [{
"id": 4,
"name": "子元素2的孙子元素",
"treePath": "1,3",
"parentId": 3,
"children": []
}]
}]
}]
}

测试过滤以及重构数据

测试代码:

public static void main(String[] args) {
List<TestChildren> testChildren = new ArrayList<>();
testChildren.add(new TestChildren(1L, "父元素", "", 0L));
testChildren.add(new TestChildren(2L, "子元素1", "1", 1L));
testChildren.add(new TestChildren(3L, "子元素2", "1", 1L));
testChildren.add(new TestChildren(4L, "子元素2的孙子元素", "1,3", 3L));

testChildren = TreeNodeUtil.buildTree(testChildren);

System.out.println(JSONUtil.toJsonStr(Result.success(testChildren)));
}

返回结果 :

{
"code": "00000",
"msg": "操作成功",
"data": [{
"id": 1,
"name": "父元素",
"treePath": "",
"parentId": 0,
"children": [{
"id": 2,
"name": "子元素1",
"treePath": "1",
"parentId": 1,
"children": []
}, {
"id": 3,
"name": "子元素2",
"treePath": "1",
"parentId": 1,
"children": [{
"id": 4,
"name": "子元素2的孙子元素",
"treePath": "1,3",
"parentId": 3,
"children": []
}]
}]
}]
}

测试过滤以及重构数据

测试代码:

// 对 3L 进行剪枝,对 1L 进行修改
testChildren = TreeNodeUtil.buildTree(testChildren, (item) -> {
if (item.getId().equals(1L)) {
item.setName("更改了 Id 为 1L 的数据名称");
}
return item;
}, (item) -> item.getId().equals(3L));

返回结果:

{
"code": "00000",
"msg": "操作成功",
"data": [{
"id": 1,
"name": "更改了 Id 为 1L 的数据名称",
"treePath": "",
"parentId": 0,
"children": [{
"id": 2,
"name": "子元素1",
"treePath": "1",
"parentId": 1,
"children": []
}]
}]
}

接下来的测试结果以口述的方式讲解

测试传入错误的 ids

  • 返回传入的 testChildren

测试传入具有父子结构,但是 ids 传错的情况 (可以根据实际需求更改是否自动识别父元素)

  • 返回传入的 testChildren

测试 testChildren 中 children元素为 null

  • 给出提示,不构建树

测试 generateTreePath 生成路径

  • 返回路径
文章目录
  1. 1. 一、介绍
  2. 2. 介绍数据库字段设计
  3. 3. 二、统一工具类具体实现
    1. 3.1. 1. 定义接口,统一规范
    2. 3.2. 2. 编写工具类TreeNodeUtil
  4. 4. 三、测试