第七章. common-core 工具类(五):TreeBuildUtils 二开实战

第七章. common-core 工具类(五):TreeBuildUtils 二开实战

摘要:本章我们将从“二次开发”的实战视角,深入 TreeBuildUtils。我们将模拟“新增商品分类”的需求,学习如何将数据库查出的“扁平列表”高效转换为前端 UI 所需的“树形 JSON”,并重点掌握 RVP 针对 Hutool TreeUtil 的核心增强。

在上一章中,我们深入剖析了 StringUtils,理解了 RVP 是如何通过“聚合”(继承 Apache Commons Lang3)和“增强”(内聚 Hutool StrUtil 和自研 isMatch)来构建其字符串工具集的。我们重点实战了 splitToisMatch 这两个 RVP 独有的核心功能。

现在,我们来看 utils 包的下一个内容,也是最具“后端特色”的工具类——TreeBuildUtils。在管理后台中,树形结构(如部门、菜单、商品分类)是无处不在的需求。TreeBuildUtils 就是 RVP 提供的,将“扁平”的数据库列表一键转换为“树形”JSON 结构的“神器”。

本章,我们 完全从“二次开发”的视角 出发,而不是罗列 API。我们的目标是模拟一个真实场景:“如果我们想为系统增加一个‘商品分类’功能,该如何使用 TreeBuildUtils 将数据库查出的 List<ProductCategory> 转换为前端 <el-tree> 需要的 JSON 结构?”

注意: 阅读此章需要您有对于前端 el-tree 的使用经验,如果没有,我们可以跳转至 Tree 树形控件 | Element Plus 进行基础预览

本章学习路径

common-core 工具类(五):TreeBuildUtils


7.1. 【二开场景】从“扁平列表”到“前端树”的鸿沟

ruoyi-demo 模块中,我们已经学习了如何做“商品”的 CRUD,但“商品分类”这种典型的树形结构该如何实现呢?

7.1.1. 需求:我们要开发一个新的“商品分类”管理模块

我们的需求很明确:在前端页面左侧显示一个“商品分类树”,用户可以点击树节点,右侧刷新该分类下的商品列表。

7.1.2. 痛点:数据库 List<CategoryEntity> 是扁平的,而前端 <el-tree> 需要 children 嵌套

这个需求立刻带来了一个经典的数据转换问题。

  • 在数据库(后端):我们为了方便存储和查询,通常使用“父 ID”(parent_id)来表示层级关系。当我们从数据库中查询商品分类时,得到的是一个扁平的列表结构。

  • 在前端 UI:像 Element Plus 的 <el-tree> 或 Ant Design 的 Tree 这样的组件,它们的数据结构通常是需要嵌套的 children 数组来表示层级关系。


数据库 List<CategoryEntity> (我们拥有的)

从后端数据库中查询出来的商品分类列表,通常是下面这样的扁平化数组结构。每个对象都代表一个分类,通过 pId (父 ID) 来标识其父级分类。

1
2
3
4
5
6
7
8
9
10
[
{ "id": 1, "pId": 0, "name": "数码" },
{ "id": 2, "pId": 1, "name": "手机" },
{ "id": 3, "pId": 0, "name": "图书" },
{ "id": 4, "pId": 1, "name": "电脑" },
{ "id": 5, "pId": 2, "name": "智能手机" },
{ "id": 6, "pId": 3, "name": "文学" },
{ "id": 7, "pId": 3, "name": "经管" },
{ "id": 8, "pId": 5, "name": "5G手机" }
]

前端 <el-tree> JSON (我们想要的)

为了让前端的树形组件能够正确渲染出层级关系,我们需要将上面的扁平数组转换成下面这种嵌套的树形结构。每个对象中通过一个 children 数组来存放其所有的子分类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
[
{
"id": 1,
"pId": 0,
"name": "数码",
"children": [
{
"id": 2,
"pId": 1,
"name": "手机",
"children": [
{
"id": 5,
"pId": 2,
"name": "智能手机",
"children": [
{
"id": 8,
"pId": 5,
"name": "5G手机",
"children": null
}
]
}
]
},
{
"id": 4,
"pId": 1,
"name": "电脑",
"children": null
}
]
},
{
"id": 3,
"pId": 0,
"name": "图书",
"children": [
{
"id": 6,
"pId": 3,
"name": "文学",
"children": null
},
{
"id": 7,
"pId": 3,
"name": "经管",
"children": null
}
]
}
]

这个从“扁平”到“层级”的数据转换过程,是开发中常遇到的一个典型问题,可以通过递归或迭代等方式在后端或前端进行处理。

7.1.3. 目标:TreeBuildUtils 如何帮我们跨越这个鸿沟

要实现这个转换,我们需要自己写一套复杂的递归算法:

  1. 遍历列表,找到所有 pId == 0 的根节点。
  2. 对每个根节点,再次遍历整个列表,找到所有 pId == 根节点ID 的子节点。
  3. 对每个子节点,再递归执行第 2 步…

这个过程非常繁琐且极易出错。TreeBuildUtils 的核心价值,就是将这套复杂的递归算法封装成了一个黑盒。我们只需要把“扁平列表”丢给它,它就能自动吐出“嵌套树”。


7.2. RVP 增强(一):DEFAULT_CONFIGname vs label

在我们开始“二开”之前,必须先理解 RVP TreeBuildUtils 相比于 Hutool TreeUtil 做出的 第一个关键增强

7.2.1. 分析:RVP 的 SysDept 树(data/tree 接口)JSON 结构

我们不妨碍学习一下 RVP 是怎么做的。打开 RVP 后台,F12 打开开发者工具,点击“系统管理” -> “用户管理”,在网络请求中找到 deptTree(或 treeselect)接口。

查看它的 JSON 响应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"code": 200,
"msg": "查询成功",
"data": [
{
"id": 100,
"parentId": 0,
"label": "叉叉叉科技", // 重点
"weight": 0,
"children": [
{
"id": 101,
"parentId": 100,
"label": "深圳总公司", // 重点
"weight": 1,
"children": [...]
}
]
}
]
}

我们发现,RVP 系统中(Element Plus UI)约定俗成的 显示字段是 label,而不是 name

7.2.2. Hutool TreeUtil 的“水土不服”:Hutool 默认 Key 是 name

RVP 的 TreeBuildUtils 继承自 cn.hutool.core.lang.tree.TreeUtil。我们按住 Ctrl 点击 TreeUtil,下载源码后会发现 Hutool 的默认配置 TreeNodeConfig.DEFAULT_CONFIG

1
2
3
4
5
6
7
8
9
10
// 位于 Hutool 的 TreeNodeConfig.java
public class TreeNodeConfig {
// ...
private String nameKey = "name"; // Hutool 默认的显示字段是 "name"
private String idKey = "id";
private String parentIdKey = "parentId";
private String weightKey = "weight";
private String childrenKey = "children";
// ...
}

这就是“水土不服”的根源:如果我们直接使用 Hutool 的 TreeUtil.build(),它会生成一个 name 字段。而我们的前端 <el-tree :props="{ label: 'label' }"> 需要的是 label 字段。这将导致前端树显示为 undefined

7.2.3. RVP 的解决方案:DEFAULT_CONFIGnameKey 强制映射为 label

RVP 的 TreeBuildUtils 优雅地解决了这个问题。我们打开 TreeBuildUtils.java 源码:

文件路径ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/TreeBuildUtils.java

1
2
3
4
5
6
7
8
public class TreeBuildUtils extends TreeUtil {

/**
* 根据前端定制差异化字段
*/
public static final TreeNodeConfig DEFAULT_CONFIG = TreeNodeConfig.DEFAULT_CONFIG
.setNameKey("label");
}

这就是 RVP 的第一个核心增强

  1. 它创建了一个 自己的 静态 DEFAULT_CONFIG
  2. 它调用了 Hutool DEFAULT_CONFIGsetNameKey("label") 方法,创建了一个新配置对象。
  3. RVP TreeBuildUtils 封装的 build 方法,会强制使用这个 DEFAULT_CONFIG

这就保证了 RVP 体系内,所有通过 TreeBuildUtils 生成的树,其显示字段 永远是 label,完美适配前端 UI。


7.3. 测试准备:创建 TreeBuildUtilsTest 与“二开”实体

理解了 RVP 的“良苦用心”后,我们开始模拟“二开”商品分类。

7.3.1. 创建 utils.test.TreeBuildUtilsTest.java (main 方法)

StreamUtils 一样,TreeBuildUtils 也不依赖 Spring 容器,我们使用 main 方法测试。

文件路径ruoyi-modules/ruoyi-demo/src/main/java/org/dromara/demo/utils/test/TreeBuildUtilsTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package org.dromara.demo.utils.test;

import cn.hutool.core.lang.tree.Tree;
import cn.hutool.log.Log;
import cn.hutool.log.LogFactory;
// 导入 RVP 的 TreeBuildUtils
import org.dromara.common.core.utils.TreeBuildUtils;

/**
* TreeBuildUtils 工具类“二开”实战
* (这是一个纯 Java 测试,无需启动 Spring)
*/
public class TreeBuildUtilsTest {

private static final Log console = LogFactory.get();

// psvm
public static void main(String[] args) {
console.info("TreeBuildUtils 二开模拟测试开始...");

// ... 我们将在这里调用测试方法 ...
}

// ...
}

7.3.2. 【二开模拟】创建 MyCategory 实体类

我们不能(也不该)在 demo 模块里直接依赖 system 模块的 SysDept。为了模拟我们的“商品分类”实体 (ProductCategory),我们直接在 TreeBuildUtilsTest 类中创建一个 静态内部类 MyCategory 来充当我们的 POJO。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ...
import java.util.List;
import java.util.ArrayList;
// ...
public class TreeBuildUtilsTest {

private static final Log console = LogFactory.get();

// ... main ...

/**
* 模拟“二开”的实体类(例如:ProductCategory.java)
* 字段名可以随意,不一定非要叫 "id" 或 "parentId"
*
* 这里用 record,就不用写构造函数和 getter/setter 了
*/
static record MyCategory(
Long categoryId, // 分类ID
Long parentCategoryId, // 父分类ID
String categoryName, // 分类名称
Integer orderNum // 排序
) {}


// ...
}

关键点:我们的实体字段是 categoryIdparentCategoryId,这与 Hutool 默认的 idparentId 完全不同。我们将在下一节展示如何处理这个“不匹配”。

7.3.3. 编写 listCategories() 静态方法

最后,我们模拟 myCategoryMapper.selectAll() 从数据库查出的“扁平列表”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ...
public class TreeBuildUtilsTest {

// ... main, MyCategory class ...

/**
* 模拟 myCategoryMapper.selectAll()
* @return 一个扁平的列表
*/
private static List<MyCategory> listCategories() {
return List.of(
// 根节点
new MyCategory(1L, 0L, "电子产品", 0),
new MyCategory(2L, 0L, "图书音像", 1),

// 电子产品 - 子节点
new MyCategory(10L, 1L, "手机", 0),
new MyCategory(11L, 1L, "笔记本电脑", 1),

// 手机 - 子节点
new MyCategory(100L, 10L, "智能手机", 0),

// 图书音像 - 子节点
new MyCategory(20L, 2L, "科技图书", 0)
);
}
}

至此,我们的测试环境和“二开”模拟数据已全部准备就绪。


7.4. 【二开核心】NodeParser:实体与树的“映射器”

我们面临的第一个问题是:Hutool 的 TreeUtil 根本不认识我们的 MyCategory 类。它怎么知道 MyCategory.getCategoryId() 应该对应树节点的 id?它又怎么知道 MyCategory.getCategoryName() 应该对应 label

NodeParser (节点解析器) 就是我们实现这个“映射关系”的“二开”接口。

NodeParser<T, K> 是 Hutool TreeUtil 中定义的一个函数式接口,T 是我们的原始数据类型(MyCategory),K 是 ID 的类型(Long)。

7.4.1. NodeParser<T, K> 详解

它只有一个需要我们实现的方法:
void parse(T object, Tree<K> treeNode)

这个方法告诉我们:

  • “我会遍历 List<MyCategory>,把每一个 MyCategory 对象(object)传给你。”
  • “我还会为你创建好一个 空的 Tree<K> 节点(treeNode。”
  • 你的工作:就是从 object 中取值,然后 settreeNode 中。”

7.4.2. 实战:编写 MyCategory 专属的 NodeParser

TreeBuildUtilsTest.java 中,我们来定义这个映射器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// ...
import cn.hutool.core.lang.tree.Tree;
import cn.hutool.core.lang.tree.TreeUtil; // 导入 Hutool TreeUtil
import cn.hutool.core.lang.tree.parser.NodeParser; // 导入 NodeParser
// ...
public class TreeBuildUtilsTest {
// ... main, MyCategory class, listCategories() ...

public static void testHutoolBuild() {
// 1. 获取"二开"的扁平数据
List<MyCategory> list = listCategories();

// 2. 【核心】定义“二开”映射器 (NodeParser)
// 我们直接使用 Lambda 表达式来实现
NodeParser<MyCategory, Long> parser = (category, treeNode) -> {
// 关键:把 MyCategory 的值,映射到 Tree 节点的标准 Key 上
treeNode.setId(category.categoryId());
treeNode.setParentId(category.parentCategoryId);
treeNode.setName(category.categoryName);
// Weight 代表顺序
treeNode.setWeight(category.orderNum);
// ... 稍后在这里添加额外字段 ...
};

// 3. 调用 Hutool 原生 build 方法
// 参数:(列表, 根ID, Hutool默认Config, 我们的解析器)
List<Tree<Long>> treeList = TreeUtil.build(
list,
0L,
TreeNodeConfig.DEFAULT_CONFIG,
parser
);

// 使用 Hutool 的 JSONUtil(不会依赖 Spring 环境)
console.info("Hutool 原生 build 方法结果:{}", JSONUtil.toJsonStr(treeList));
}
// ...
}

运行 main 方法,控制台输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
... INFO ... --- 1. 测试 Hutool Build (NodeParser) ---
... INFO ... 【Hutool Build 结果】:
[
{
"id": 1, "parentId": 0, "name": "电子产品", "weight": 0, // 字段是 name
"children": [
{"id": 10, "parentId": 1, "name": "手机", "weight": 0, ...},
{"id": 11, "parentId": 1, "name": "笔记本电脑", "weight": 1, ...}
]
},
{
"id": 2, "parentId": 0, "name": "图书音像", "weight": 1, ...
}
]

分析:成功了!我们通过 NodeParser 成功将 MyCategory 转换为了 Tree。但是,正如 7.2 节分析的,Hutool 默认生成的是 name 字段,而不是 RVP 前端需要的 label

7.4.3. 进阶:使用 tree.putExtra() 携带额外业务字段

如果我们希望前端树节点上 附带一些 id/parentId/label 之外的业务字段(比如 orderNum 本身,或者 categoryType),NodeParser 也能做到。

我们修改 testHutoolBuild 中的 parser

1
2
3
4
5
6
7
8
9
10
11
12
// ...
NodeParser<MyCategory, Long> parser = (category, treeNode) -> {
treeNode.setId(category.categoryId());
treeNode.setParentId(category.parentCategoryId);
treeNode.setName(category.categoryName);
treeNode.setWeight(category.orderNum);
// 【二开增强】使用 putExtra 携带任意额外字段
// 这会在 JSON 中生成一个 "extra" 对象
treeNode.putExtra("order", category.orderNum());
treeNode.putExtra("myCustomKey", "自定义值");
};
// ...

putExtra 的作用:它会在生成的 Tree 对象(本质上是个 Map)中,添加一个 extra 字段,extra 内部再存放你 put 进去的 K-V。(注:Hutool Tree 对象本身就是 extends LinkedHashMapsetId, setName 只是在 put("id", ...)putExtra 则是 put(key, value),但 Hutool 5.x 后 putExtra 是存入 extra Map 中)。

image-20251116114248301


7.5. 【二开决策】build vs buildSingle:我该用哪个?

Hutool TreeUtilTreeBuildUtils 也继承了)提供了两个核心的构建方法:

  1. buildSingle(List<T> list, K rootId, ...):返回 Tree<K> (一个对象)
  2. build(List<T> list, K rootId, ...):返回 List<Tree<K>> (一个列表)

这在“二开”时会造成困惑:我该用哪个?我的 Controller 应该返回 Tree 还是 List<Tree>

答案是:99% 的场景下,你都应该使用 build()

7.5.1. TreeUtil.buildSingle() 实战:返回“虚拟根节点”

buildSingle 会创建一个 虚拟的根节点(你传入的 0L),然后把你真正的根节点(“电子产品”、“图书音像”)作为它的 children

我们在 TreeBuildUtilsTest.java 中添加 testBuildSingle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// ...
public static void testBuildSingle() {
console.info("--- 2. 测试 buildSingle (返回对象) ---");
List<MyCategory> list = listCategories();

// NodeParser 复用 7.4 的
NodeParser<MyCategory, Long> parser = (c, t) -> {
t.setId(c.getCategoryId());
t.setParentId(c.getParentCategoryId());
t.setName(c.getCategoryName());
t.setWeight(c.getOrderNum());
};

// 调用 buildSingle
Tree<Long> singleTree = TreeUtil.buildSingle(
list,
0L, // 根 ID
TreeNodeConfig.DEFAULT_CONFIG,
parser
);

console.info("【buildSingle 结果】: \n{}",
cn.hutool.json.JSONUtil.toJsonStr(singleTree));
}

public static void main(String[] args) {
// ...
// testHutoolBuild();
testBuildSingle();
}
// ...

运行 main 方法,控制台输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
... INFO ... --- 2. 测试 buildSingle (返回对象) ---
... INFO ... 【buildSingle 结果】:
{
"id": 0, // 这是虚拟根
"parentId": null,
"name": null,
"weight": null,
"children": [ // 真实数据在 children 里
{
"id": 1, "parentId": 0, "name": "电子产品", ...
},
{
"id": 2, "parentId": 0, "name": "图书音像", ...
}
]
}

7.5.2. TreeUtil.build() 实战:返回“多根列表”

build不会 创建那个虚拟根节点。它会直接返回一个 只包含顶级节点(parentId == 0L)的列表

7.5.3. 结论:为什么前端 UI 永远选择 build()

buildSingle 返回的对象结构 {"id": 0, "children": [...]} 并不是前端 <el-tree> 想要的。前端 <el-tree :data="treeData"> 想要的 treeData 是一个 数组 (List),就像 RVP deptTree 接口返回的 data: [ ... ] 一样。

结论:在“二开”中,我们应该总是调用 build() 方法,它返回的 List<Tree<K>> 才能被前端 UI 直接消费。


7.6. RVP 增强(二):build 终极封装(与“二开”陷阱)

build 方法我们选定了,但是 7.4 节的调用还是太复杂了:TreeUtil.build(list, 0L, TreeNodeConfig.DEFAULT_CONFIG, parser)
我们需要传 4 个参数,包括 0L(根 ID)和 Hutool 的 Config。

RVP TreeBuildUtils 提供了一个 两参数build 方法,试图简化这个调用。

7.6.1. 源码解析:RVP build 方法(与“二开”陷阱)

我们来看 RVP TreeBuildUtils.java 的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 位于 RVP 的 TreeBuildUtils.java
public static <T, K> List<Tree<K>> build(List<T> list, NodeParser<T, K> nodeParser) {
if (CollUtil.isEmpty(list)) {
return CollUtil.newArrayList();
}

// 【“二开”陷阱】:
// 它试图通过反射,获取 list 第一个元素的 "parentId" 字段
// 这在 RVP 内部(SysDept, SysMenu)是统一的,所以能用
K k = ReflectUtils.invokeGetter(list.get(0), "parentId");

// 它自动应用了 RVP 的 DEFAULT_CONFIG (label)
return TreeUtil.build(list, k, DEFAULT_CONFIG, nodeParser);
}

分析 RVP 的 build 封装:

  • 优点:自动应用了 DEFAULT_CONFIGlabel 映射)。
  • 【“二开”陷阱】:它通过 硬编码反射 list.get(0).getParentId() 来自动猜测根 ID。

这对我们的“二开”意味着什么?
我们的 MyCategory 实体,父 ID 字段叫 parentCategoryId,不叫 parentId!如果我们调用 RVP 这个两参数的 build 方法,ReflectUtils.invokeGetter(...) 会因找不到 getParentId() 方法而 崩溃

7.6.2. 【二开最佳实践】

RVP 还提供了另一个 三参数build 方法,它没有那个“自作聪明”的反射:

1
2
3
4
5
6
7
8
// 位于 RVP 的 TreeBuildUtils.java
public static <T, K> List<Tree<K>> build(List<T> list, K parentId, NodeParser<T, K> nodeParser) {
if (CollUtil.isEmpty(list)) {
return CollUtil.newArrayList();
}
// 它自动应用了 RVP 的 DEFAULT_CONFIG,但允许我们手动传入 parentId
return TreeUtil.build(list, parentId, DEFAULT_CONFIG, nodeParser);
}

这就是“二开”的最佳实践

  1. 我们 使用 RVP 的两参数 build 方法,因为它有硬编码。
  2. 我们 使用 RVP 的三参数 build 方法,它同时满足了:
    • 自动应用 DEFAULT_CONFIGname -> label)(RVP 增强)。
    • 允许我们安全地传入 0L 作为根 ID(Hutool 功能)。
    • 允许我们传入自定义的 NodeParser(“二开”核心)。

我们在 TreeBuildUtilsTest.java 中添加 testRvpBuild

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// ...
import org.dromara.common.core.utils.TreeBuildUtils; // 导入 RVP
import cn.hutool.core.lang.tree.TreeNodeConfig; // 导入 Hutool Config
// ...
public static void testRvpBuild() {
List<MyCategory> list = listCategories();
// 1. 定义 NodeParser (注意:RVP 强制了 label,所以我们也要用 setName)
NodeParser<MyCategory, Long> parser = (category, treeNode) -> {
treeNode.setId(category.categoryId());
treeNode.setParentId(category.parentCategoryId());
// 关键:RVP 的 config 会把 setName() 映射到 "label" key
treeNode.setName(category.categoryName());
treeNode.setWeight(category.orderNum());
treeNode.putExtra("order", category.orderNum());
};

List<Tree<Long>> rvpTreeList = TreeBuildUtils.build(
list,
0L,
parser
);

console.info("RVP 构建结果:{}", JSONUtil.toJsonPrettyStr(rvpTreeList));
}

public static void main(String[] args) {
// ...
// testBuildSingle();
testRvpBuild();
}
// ...

运行 main 方法,控制台输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
... INFO ... --- 3. 测试 RVP Build (最佳实践) ---
... INFO ... 【RVP Build 结果】:
[
{
"id": 1, "parentId": 0, "label": "电子产品", "weight": 0, "extra": {"order": 0},
"children": [
{"id": 10, "parentId": 1, "label": "手机", "weight": 0, ...},
{"id": 11, "parentId": 1, "label": "笔记本电脑", "weight": 1, ...}
]
},
{
"id": 2, "parentId": 0, "label": "图书音像", "weight": 1, ...
}
]

完美!

  • 根节点是 List<Tree> (✔)
  • 显示字段是 label (✔)
  • 额外字段 extra 也成功代入 (✔)
  • 我们避开了 RVP build 两参数方法的反射陷阱 (✔)

7.7. RVP 增强(三):buildMultiRootgetLeafNodes

这两个方法提供了在特定“二次开发”场景下非常有用的高级能力,专门用于处理不规则的树形数据。

7.7.1. 场景一:buildMultiRoot (构建多根树)

如果我的数据压根没有统一的 0 作为根节点怎么办?

想象一个场景,你的 MyCategory 列表可能是这样的:

  • [ {id: 1, pId: -1, name: "数码"}, {id: 2, pId: -1, name: "图书"}, {id: 10, pId: 1, name: "手机"} ]
    这里有两个根节点(它们的 pId 都是 -1)。如果调用 build(list, -1L, ...),它会正确返回一个包含“数码”和“图书”的列表。

但如果数据是下面这样混乱的情况呢?

  • [ {id: 1, pId: -1, name: "数码"}, {id: 2, pId: -2, name: "图书"}, {id: 10, pId: 1, name: "手机"} ]
    此时,根节点的 parentId 都不统一!build(list, ???, ...) 方法直接“抓瞎”了,我们无法提供一个固定的 rootId

buildMultiRoot 就是为了解决这个痛点而生。我们来看它的源码(TreeBuildUtils.java):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static <T, K> List<Tree<K>> buildMultiRoot(List<T> list, Function<T, K> getId, Function<T, K> getParentId, NodeParser<T, K> parser) {
if (CollUtil.isEmpty(list)) {
return CollUtil.newArrayList();
}

// 1. 获取所有 parentId 的集合
Set<K> rootParentIds = StreamUtils.toSet(list, getParentId);
// 2. 获取所有 id 的集合
Set<K> ids = StreamUtils.toSet(list, getId);
// 3. (核心) 用所有 parentId 减去所有 id
rootParentIds.removeAll(ids);

// 4. 剩下的就是真正的“根 ParentId”(比如-1, -2),因为它们在 id 集合中找不到对应的节点
// 5. 对每个根 ParentId 都构建一棵树,最后将所有树的结果合并成一个列表
return rootParentIds.stream()
.flatMap(rootParentId -> TreeUtil.build(list, rootParentId, parser).stream())
.collect(Collectors.toList());
}

逻辑分析
buildMultiRoot 的逻辑非常巧妙。它基于一个核心假设:如果一个 parentId 在所有节点的 id 列表里都找不到,那它必定是一个“虚拟根节点的 ParentId”。通过这个方法,它能自动帮你找出 -1-2,然后分别执行 build(list, -1L, ...)build(list, -2L, ...),最后把两棵树的结果合并(flatMap)成一个 List 返回给你。

结论:当你的数据源不规范,存在多个不同 parentId 的根节点时,buildMultiRoot 是你的“救星”,但我们一般不会用这个方法,这是一个亡羊补牢的补救方法


7.7.2. 场景二:getLeafNodes (获取所有叶子节点)

我如何只获取树的“最后一级”节点?

例如,在“商品分类”管理中,我们常常需要一个“只看叶子类目”的筛选功能。因为业务规定,商品 只能 挂在叶子类目下(例如,你不能把商品挂在“电子产品”上,必须挂在“智能手机”这个具体的、最底层的分类上)。

getLeafNodes 就为此而生。它会递归遍历一棵(或多棵)树,将所有 children 属性为空(或 null)的节点筛选出来,汇总成一个列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    public static void testGetLeafNodes() {
// 1. 先用“最佳实践”构建一棵完整的树
List<MyCategory> list = listCategories();
NodeParser<MyCategory, Long> parser = (c, t) -> {
t.setId(c.categoryId());
t.setParentId(c.parentCategoryId());
t.setName(c.categoryName());
};
List<Tree<Long>> rvpTreeList = TreeBuildUtils.build(list, 0L, parser);
// 2. 调用 getLeafNodes
List<Tree<Long>> leafNodes = TreeBuildUtils.getLeafNodes(rvpTreeList);
// 3. 打印叶子节点 (我们只打印 name 方便观察)
List<CharSequence> leafLabels = StreamUtils.toList(leafNodes, Tree::getName);
console.info("叶子节点名称 {}", leafLabels);
}

public static void main(String[] args) {
// ...
// testBuildMultiRoot();
testGetLeafNodes();
}

运行 main 方法,控制台输出

1
叶子节点名称 [智能手机, 笔记本电脑, 科技图书]

分析:从结果中可以看到,“电子产品”、“图书音像”、“手机” 这些非叶子节点都被成功过滤掉了。getLeafNodes 精准地找到了所有“最后一级”的分类。


7.8. 本章总结

在本章中,我们 严格地从“二次开发”的视角 而非罗列 API 的视角,完成了对 TreeBuildUtils 的深度实战。

我们以“新增商品分类”的需求为背景,推导并验证了一套在 RVP 体系下的 树构建最佳实践

  1. 定义实体 (MyCategory)
    你的实体类(POJO/VO)字段可以 任意命名,例如 categoryId, parentCategoryId 等,无需遵循 Tree 对象的字段名。

  2. 定义映射器 (NodeParser)
    这是“二开”的 核心。我们必须提供一个 NodeParser 实现,它负责将 MyCategorygetCategoryId()getParentCategoryId()getCategoryName() 等方法的值,setTree 对象的 setId()setParentId()setName() 等标准字段上。

  3. 携带自定义数据 (putExtra)
    NodeParser 的实现中,使用 treeNode.putExtra("key", value) 来携带任意前端需要的 额外业务字段,如 orderNum, status 等。

  4. 调用三参数 build 方法
    在 Controller 或 Service 中,始终调用 TreeBuildUtils.build(list, rootId, parser)

    • 为什么用 RVP 的 build 因为它内置了 DEFAULT_CONFIG,能自动将 setName() 映射为前端组件(如 Element UI)普遍需要的 label 字段。
    • 为什么用三参数(带 rootId)? 因为 RVP 的两参数 build 方法存在 硬编码反射 parentId 字段的陷阱,不适用于我们“二开”的自定义实体。

最后,我们还掌握了 RVP 提供的两个高级工具:buildMultiRoot(用于处理不规范的多根数据)和 getLeafNodes(用于提取所有叶子节点),它们能在特定场景下极大地提升开发效率。

掌握了 TreeBuildUtils,你就掌握了 RVP 后台管理系统中所有“树形结构”的构建秘诀。