扁平表+递归拼树思想
引入
面试题:在数据库中存储省-市-区三级行政区域信息时,你会选用「一张表」还是「三张表」?为什么?
方案 A:三张表
-
province(id,name)
-
city(id,name,province_id)
-
district(id,name,city_id)
-
优点:严格分层,结构清晰;外键约束直观。
-
缺点:查询时要三表联查,代码冗长;如果将来要加第 4 级或支持灵活深度,就得再加表和改代码。
方案 B:一张表(邻接表模型)
CREATE TABLE region (id BIGINT PRIMARY KEY,parent_id BIGINT NOT NULL DEFAULT 0, -- 顶级省 parent_id=0name VARCHAR(100) NOT NULL,sort_order INT NOT NULL DEFAULT 0
);
优点
-
一张表搞定所有层级,无需维护多表;
-
增删改直观,新增一层级不改表结构;
-
只要一次全表查询,加上一次递归,就能生成任意深度的树。
缺点
-
递归逻辑要写,但模式简单;
-
数据量极大时性能要做缓存或数据库层优化。
接下来,我们就以「方案 B:一张表」为例,演示如何只用一条 SQL 拉取扁平列表,再用一段 Java 递归代码,快速拼出省→市→区的树形数据,让前端「开箱即用」。
若依AI动态菜单思想
这种思想可以在若依AI动态菜单中体会(一级菜单和二级菜单)
首先查看数据库的字段
parent_id:父级菜单ID,顶级菜单 parent_id = 0
。通过它可以串起多级树形结构。
order_num:同级菜单排序号,值越小显示越靠前。
Controller层和Service层
// controller /*** 获取菜单下拉树列表*/
@GetMapping("/treeselect")
public AjaxResult treeselect(SysMenu menu) {// ① 查询扁平列表List<SysMenu> menus = menuService.selectMenuList(menu, getUserId());// ② 递归拼树,并转换为 TreeSelect VOreturn success(menuService.buildMenuTreeSelect(menus));
}// serviceImpl/*** 查询系统菜单列表* * @param menu 菜单信息* @return 菜单列表*/
@Override
public List<SysMenu> selectMenuList(SysMenu menu, Long userId) {if (SysUser.isAdmin(userId)) {// 管理员:不走权限,查询所有return menuMapper.selectMenuList(menu);} else {// 普通用户:带上 userId,走按角色过滤的查询menu.getParams().put("userId", userId);return menuMapper.selectMenuListByUserId(menu);}
}
selectMenuList的SQL分析:
<select id="selectMenuList" parameterType="SysMenu" resultMap="SysMenuResult"><include refid="selectMenuVo"/><where><if test="menuName != null and menuName != ''">AND menu_name like concat('%', #{menuName}, '%')</if><if test="visible != null and visible != ''">AND visible = #{visible}</if><if test="status != null and status != ''">AND status = #{status}</if></where>order by parent_id, order_num</select>
这段 MyBatis 的 <select>
配置,整体功能就是从 sys_menu
表(或视图)里查询一批菜单记录,并支持按名称、可见性、状态三个字段做可选过滤,最后按父菜单和排序号输出一个“扁平”的、已经排好序的列表。
构建前端所需的下拉树结构通过递归获得
/*** 构建前端所需要下拉树结构* * @param menus 菜单列表* @return 下拉树结构列表*/@Overridepublic List<TreeSelect> buildMenuTreeSelect(List<SysMenu> menus){List<SysMenu> menuTrees = buildMenuTree(menus);return menuTrees.stream().map(TreeSelect::new).collect(Collectors.toList());}/*** 构建前端所需要树结构* * @param menus 菜单列表* @return 树结构列表*/
public List<SysMenu> buildMenuTree(List<SysMenu> menus) {List<SysMenu> result = new ArrayList<>();// 取所有 menuId,方便判断哪些是“顶级”节点List<Long> allIds = menus.stream().map(SysMenu::getMenuId).collect(Collectors.toList());for (SysMenu m : menus) {// 如果 m.parentId 不在所有菜单的 id 列表里,说明 m 是顶级if (!allIds.contains(m.getParentId())) {// 从这个顶级节点开始,递归挂子节点recursionFn(menus, m);result.add(m);}}// 如果没查到顶级,直接把原列表当“平铺树”返回return result.isEmpty() ? menus : result;
}
buildMenuTree 方法
做了两件事:
-
找出所有顶层菜单;
-
对每个顶层菜单,调用
recursionFn
,给它“挂”完整的子树。
查找顶级举个简单例子
假设 menus
里有三条记录:
menuId | parentId | menuName |
---|---|---|
1 | 0 | 根菜单A |
2 | 1 | 子菜单B |
3 | 1 | 子菜单C |
allIds = [1,2,3]
遍历时:
- 对于
m.menuId=1
,它的parentId=0
。0
不在[1,2,3]
里 ⇒ 认为它是顶级,对菜单A 再调用recursionFn,
递归挂上它的 B、C 子节点,加入result
。 - 对于
m.menuId=2
,它的parentId=1
。1
在[1,2,3]
里 ⇒ 不是顶级,跳过。 - 对于
m.menuId=3
,同理parentId=1
,也跳过。
recursionFn 递归方法
思路:给当前节点 node
找到所有 parent_id=node.id 的记录,挂到 node.children
,再对每个子节点重复同样操作。
/*** 递归列表* * @param list 分类表* @param t 子节点*/
private void recursionFn(List<SysMenu> list, SysMenu t) {// 3.1 先给 t 找到它的所有一级子节点List<SysMenu> childList = getChildList(list, t);// 3.2 把这个子列表挂到 t.children 上t.setChildren(childList);// 3.3 对每一个直接子节点,再看它有没有自己的子节点for (SysMenu tChild : childList) {if (hasChild(list, tChild)) {// 如果有,就递归调用,把所有更深层次的节点都挂上去recursionFn(list, tChild);}}
}
recursionFn方法参数解释
-
list
:包含了当前用户所有可见的菜单项,一份排好序的“平铺”列表,每个元素都有menuId
和parentId
。 -
t
:从外层传进来的某个菜单节点(如顶级菜单),我们要为它“挂”上所有后代子节点。
找直接子节点:getChildList(list, t)
/*** 得到子节点列表*/
private List<SysMenu> getChildList(List<SysMenu> list, SysMenu t) {List<SysMenu> tlist = new ArrayList<>();Iterator<SysMenu> it = list.iterator();while (it.hasNext()) {SysMenu n = it.next();// 如果 n.parentId == t.menuId,就说明 n 是 t 的一级子节点if (n.getParentId().longValue() == t.getMenuId().longValue()) {tlist.add(n);}}return tlist;
}
目的:扫描整张扁平列表,挑出所有 parentId == t.getMenuId()
的项,返回给上层。
示例:
-
假设
t.menuId=1
(广东省),list
包含[1(GD), 2(GZ), 3(SZ), 4(TH)]
,其中 2、3 的parentId=1
,4 的parentId=2
。 -
getChildList(list, t)
会返回[2(广州市), 3(深圳市)]
这两个直接子节点,不会返回天河区(它是孙级)。
步骤拆解
1. getChildList
得到 t
的第一层子节点列表 childList
。
2. t.setChildren(childList)
把这批子节点保存到 t
对象里,建立父子关联。
3. for (tChild : childList)
遍历每一个刚找到的子节点。
4. hasChild(list, tChild)
判断这个子节点 tChild
是否还有“孙节点”。
(内部实现通常是看 getChildList(list, tChild).isEmpty()
)
当某个节点 t
没有子节点时,getChildList
返回空列表,hasChild
为 false,递归自然停止,不会继续往下。
5. recursionFn(list, tChild)
如果 tChild
还有孩子,就把它当成新的 “当前节点” 再次执行整个流程:
查它的直接子节点 → 挂到它的 children
→ 继续往下……
如何存储省-市-区三级行政区域信息(照葫芦画瓢)
表结构设计
直接贴个简单表:
CREATE TABLE region (id BIGINT PRIMARY KEY, -- 每条记录唯一IDparent_id BIGINT NOT NULL DEFAULT 0, -- 父节点ID,顶层用0name VARCHAR(100) NOT NULL, -- 区域名称sort_order INT NOT NULL DEFAULT 0 -- 同级排序用的
);
-
parent_id:指自己属于哪个上级。
-
sort_order:数字越小,排得越靠前。
一条 SQL 拿到扁平列表
<select id="selectAllRegions" resultType="Region">SELECT id, parent_id, name, sort_orderFROM regionORDER BY parent_id, sort_order
</select>
这一步把所有数据一次性拉出来,按
parent_id
和sort_order
排好序。
Java 端拼树
1. 定义两个类
R1egion
:跟表字段一一对应。
RegionVo
:多一个 List<RegionVo> children
用来装子节点。
2. 递归方法
List<RegionVo> buildTree(List<Region> all, Long pid) {return all.stream()// ① 找到所有 parentId == pid 的节点.filter(r -> Objects.equals(r.getParentId(), pid))// ② 对每个这样的节点,做两件事:// a) 把 Region 转成 RegionVo// b) 递归调用 buildTree 去找它的 children.map(r -> {RegionVo vo = toVo(r);vo.setChildren(buildTree(all, r.getId()));return vo;})// ③ 收集成 List<RegionVo> 返回.collect(Collectors.toList());
}
这样,buildTree
就能从顶层一直“钻”到最底层,层层挂起 children
,最后构造出完整的树形结构。
-
递归入口:
buildTree(all, 0L)
-
递归条件:对任意
r
,只要有parentId == r.id
的元素,都会进入下一层递归;否则该层返回空列表,递归停止。 -
终止:当某层
filter
结果为空(没有子节点)时,map
不会执行,方法直接返回[]
,向上回溯。直接对每个节点都调用一次buildTree(all, r.getId())
,如果它根本没有子节点,那么那一层的.filter(...)
就会立刻返回一个空流,.map(...)
一次都不跑,最终.collect(...)
出来的是一个空列表[]
。然后你把这个空列表赋给它的children
,也就相当于“它没有孩子”。
最终效果
拼好的树,形如:
[{"id":1, "name":"广东省", "children":[{"id":2,"name":"广州市","children":[{"id":4,"name":"天河区"},{"id":5,"name":"越秀区"}]},{"id":3,"name":"深圳市","children":[]}]}
]