前言
在后端的开发中,经常碰到一些树状结构的模型,比如:公司的部门结构组织、国家的行政区划、产品目录,等等。
当我们需要将这些数据持久化到关系型数据库中,就没有像通用性编程语言一样,可以利用指针或者引用的方式得到一种可以递归的树状结构。
本文简单讨论树状结构在 MySQL 这种关系型数据库的存储方式,尽量保持简洁,通用。
框架和数据库选择
- SpringBoot
- SpringMVC
- Spring Data JPA
- MySQL
项目用例选择
在本文中,选择中国的行政区划作为案例,因为行政区划依照省-市-区划分,是一棵完整的分层的树状结构。
中国行政区划的数据源,参考国家标准:《 GB/T 2260-2007 中华人民共和国行政区划代码 》
Entity
本文的 Entity 类非常简单,仅仅包含构造树结构必要的字段,在实际业务场景中,可以随意增加业务字段。
id 的生成,考虑到通用性,我使用 UUID 作为主键生成策略。
Area.java
import lombok.Data;
import org.hibernate.annotations.GenericGenerator;
import javax.persistence.*;
@Data
@Entity
@Table(name = "t_area")
public class Area {
@Id
@GeneratedValue(generator = "uuid-generator")
@GenericGenerator(name = "uuid-generator", strategy = "uuid")
private String id;
@Column(name = "name")
private String name;
@Column(name = "parent_id")
private String parentId;
@Column(name = "code")
private String code;
}
JPA 自动生成的 DDL:
-- auto-generated definition
create table t_area
(
id varchar(255) not null
primary key,
code varchar(255) null,
name varchar(255) null,
parent_id varchar(255) null
);
邻接表
这是最常见的一种存储树结构的方式,每个元素节点持有一个字段,该字段保存着父节点的 id,在本文中,我将其命名为 parent_id。
例如,一个简单的行政区划表:
id | name | code | parent_id |
---|---|---|---|
1 | 中国 | -1 | |
2 | 浙江省 | 330000 | 1 |
3 | 江苏省 | 320000 | 1 |
4 | 宁波市 | 330200 | 2 |
5 | 杭州市 | 330100 | 2 |
首先我们需要明晰一些术语,父节点,字节点,叶子节点,祖先节点和后代节点:
在这 5 条数据中,浙江省和江苏省的 parent_id 都为 1,所以中国作为这两个省的父节点(parent node),这两个省就是中国的子节点(children node)。
同样的,宁波市和杭州市的 parent_id 都为 2,所以它们都属于浙江省的子节点。
中国是其他所有结点的祖先节点(ancestors),其他所有节点都是中国的后代(descendants)。
宁波市和杭州市在该表中没有后代,所以它们被称为叶子节点(leaf node)。
带有子节点引用列表的 VO 类
我们假设 MySQL 中已经存在了像上面一样的表和数据,我们需要构造一棵完整的树返回给前端。
拿到数据库中的原始数据很简单:
List<Area> areas = areaRepository.findAll();
现在需要做的就是将扁平的 area list 转成树结构,为此需要另外编写一个带有子节点列表的类,返回给前端:
AreaVO.java
import lombok.Data;
import java.util.List;
@Data
public class AreaVO {
private String id;
private String name;
private String parentId;
private String code;
private List<AreaVO> children;
}
AreaVO 和 Area 不同点在于,AreaVO 作为返回给前端的对象,比面向数据库的 Area 多了一个 children 字段,里面存储着下一级的子节点的引用。
在构造 children 字段前,我们可以使用 BeanUtils.copyProperties(),将除了 children 之外的字段,从原始 Area 复制到 AreaVO 中。
// 从数据库中提取的原始数据
List<Area> areas = areaRepository.findAll();
// 返回给前端的类,包含 children 字段,可以被构造成完整的树结构
List<AreaVO> vos = new ArrayList<>();
// 将业务字段值拷贝到 VO 类中
areas.forEach(area -> {
AreaVO vo = new AreaVO();
BeanUtils.copyProperties(area, vo);
vos.add(vo);
});
利用队列构造树结构
观察可以发现,每个节点可能即是父节点,也可能是子节点,根节点和叶子节点是例外,因外根节点只有子节点,没有父节点,而叶子节点只有父节点,没有子节点。
可以使用一种自顶向下,逐层构造的方式,最开始分成两部分,根节点和除了根节点之外的后代节点。
根节点去遍历后代节点,找到自己的直系后代(即,子节点),判断某个节点是不是自己的子节点很简单:
// 如果 children 的 parentId 字段和当前父节点的 id 字段值相同,说明该 children 是 parent 的子节点之一
if (Objects.equals(childrenNode.getParentId(), parentNode.getId())) {
parentNode.getChildren().add(childrenNode);
}
第一轮应该是根节点遍历找到所有自己的直接子节点,然后根节点就完成了自己的任务,它 children 字段中应该有了一些子节点的引用。
举个具体的例子,当“中国”开始寻找子节点时,第一轮遍历,它的 children 应该是北京市,天津市,河北省,山西省...,而杭州市,宁波市则不在它的 children list 当中。
而北京市,天津市,河北省,山西省...这些二级节点(我们姑且将中国的层级定为 1),它们也需要找到自己的 children(省下面是市)。
所以很自然的,想到了队列这个数据结构,一开始“中国”节点进入队列,当它找到所有子节点之后,出队列,中国的子节点们进入队列,开始寻找它们的子节点,以此类推,直到整个队列为空,说明没有节点需要找子节点了,整个循环结束。
下面是我的方法,过程很简单,基本实现了上面的思路:
private void treeHelper(Queue<AreaVO> parentNodes, List<AreaVO> childrenNodes) {
// 循环终止条件是队列为空
while (parentNodes.size() > 0) {
// 取出队列中首个父节点
AreaVO parentNode = parentNodes.poll();
// 迭代遍历子节点列表,寻找当前父节点下的子节点
// 这里使用迭代器遍历子节点列表,是因为循环中涉及到了 remove 的操作
Iterator<AreaVO> iterator = childrenNodes.iterator();
while (iterator.hasNext()) {
AreaVO childrenNode = iterator.next();
// 如果 children 的 parentId 字段和当前父节点的 id 字段值相同,说明该 children 是 parent 的子节点之一
if (Objects.equals(childrenNode.getParentId(), parentNode.getId())) {
// 父节点的 list 可能为 null,表示是叶子节点
// 如果找到了子节点,就不再是叶子节点了,需要构造一个空的 list 存放子节点
if (parentNode.getChildren() == null) {
parentNode.setChildren(new ArrayList<>());
}
// 将 children 放到 parent 的 children list 中
parentNode.getChildren().add(childrenNode);
// 迭代器移除当前的子节点,如果不进行该操作的话,childrenNodes 的大小永远不变,会增加后面循环的时间开销
// 因为每个节点只可能有一个父节点,children 找到了自己的父节点之后,它就不可能再是别人的子节点了,所以
// 可以直接从 childrenNodes 中移除,每次循环,childrenNodes 都会少一些,增加后面循环的效率
iterator.remove();
// 当前子节点,也可能是别的节点的父节点,所以需要进入队列
parentNodes.add(childrenNode);
}
}
}
}
完整的 service 层代码
import cn.korilweb.demoadministrativedivisions.model.entity.Area;
import cn.korilweb.demoadministrativedivisions.model.vo.AreaVO;
import cn.korilweb.demoadministrativedivisions.repository.AreaRepository;
import cn.korilweb.demoadministrativedivisions.service.AreaService;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.function.Predicate;
import java.util.stream.Collectors;
@Service
public class AreaServiceImpl implements AreaService {
@Autowired
private AreaRepository areaRepository;
@Override
public AreaVO areaTree(String id) {
// 数据库原始数据
List<Area> areas = areaRepository.findAll();
// 返回给前端树形结构 VO 类
List<AreaVO> vos = new ArrayList<>();
// 拷贝业务字段值
areas.forEach(area -> {
AreaVO vo = new AreaVO();
BeanUtils.copyProperties(area, vo);
vos.add(vo);
});
// 找到根节点,约定 id = 0 是根节点
Predicate<AreaVO> isRoot = areaVO -> Objects.equals(areaVO.getId(), "0");
Queue<AreaVO> parentNodes = new ArrayDeque<>();
// 将根节点放到队列中
AreaVO rootNode = vos.stream().filter(isRoot).findFirst().get();
parentNodes.offer(rootNode);
// 除了根节点之外的所有子节点
List<AreaVO> childrenNodes = vos.stream().filter(isRoot.negate()).collect(Collectors.toList());
// 构造树方法
treeHelper(parentNodes, childrenNodes);
return rootNode;
}
private void treeHelper(Queue<AreaVO> parentNodes, List<AreaVO> childrenNodes) {
// 循环终止条件是队列为空
while (parentNodes.size() > 0) {
// 取出队列中首个父节点
AreaVO parentNode = parentNodes.poll();
// 迭代遍历子节点列表,寻找当前父节点下的子节点
Iterator<AreaVO> iterator = childrenNodes.iterator();
while (iterator.hasNext()) {
AreaVO childrenNode = iterator.next();
// 如果 children 的 parentId 字段和当前父节点的 id 字段值相同,说明该 children 是 parent 的子节点之一
if (Objects.equals(childrenNode.getParentId(), parentNode.getId())) {
// 父节点的 list 可能为 null,表示是叶子节点
// 如果找到了子节点,就不再是叶子节点了,需要构造一个空的 list 存放子节点
if (parentNode.getChildren() == null) {
parentNode.setChildren(new ArrayList<>());
}
// 将 children 放到 parent 的 children list 中
parentNode.getChildren().add(childrenNode);
// 迭代器移除当前的子节点,如果不进行该操作的话,childrenNodes 的大小永远不变,会增加后面循环的时间开销
// 因为每个节点只可能有一个父节点,children 找到了自己的父节点之后,它就不可能再是别人的子节点了,所以
// 可以直接从 childrenNodes 中移除,每次循环,childrenNodes 都会少一些,增加后面循环的效率
iterator.remove();
// 当前子节点,也可能是别的节点的父节点,所以需要进入队列
parentNodes.add(childrenNode);
}
}
}
}
}
可以看到 service 方法中,我额外放了一个 id 字段,这是为了应付这样的需求:前端不想要一棵以中国为根节点的树,而是需要一颗以杭州市为根节点的树。
理想情况就是,前端传一个它所期望的根节点的 id,我们后端返回以该 id 的节点为根节点构造的一棵树。
// 找到根节点,约定 id = 0 是根节点
Predicate<AreaVO> isRoot = areaVO -> Objects.equals(areaVO.getId(), "0");
只需要把上面的 isRoot 换成前端传来的 id 值即可。
// 找到根节点,根据前端传来的 id 值判断
Predicate<AreaVO> isRoot = areaVO -> Objects.equals(areaVO.getId(), id);
这种方式有个很大的缺点,比如前端传来一个杭州市的 id 值,那么杭州市的父节点们也会进入 childrenNodes 中,而且在之后的每一次遍历都不会被删除。
我能想到的一个简单的办法是,在数据库表中增加一个字段——level,事实上很多实现也是这么做的,它表示该节点所处的层级,中国是1 级,leve = 1,直辖市,省,自治区,特别行政区在 2 级,level = 2,以此类推。
如果传来杭州市,杭州市的 level = 3,在数据库查询的时候,就可以通过 leve 把杭州市的父节点们都排除,只查询出 level > 3 的节点。
利用递归构造树结构
树节点的新增操作
树节点的更新操作
树节点的删除操作
节点的排序
参考
- https://mp.weixin.qq.com/s/tJ5m4BWYeZEiCjbxtwN1qA
- https://mp.weixin.qq.com/s/yzy1EbzSS5Pb7boQ-jgPFw
- https://mp.weixin.qq.com/s/sRivTVFKYYTzKXh9dQv1mw