前言
Hutool 源码解析系列第三篇,把目光聚焦在 cn.hutool.core.lang.tree 这个包的源码:
该包的源码整体功能实现在 封装返回通用的树形结构数据工具代码 文章目录大纲如下:
-
项目中常见的树形数据场景举例
- 概要简述项目中常见的场景
-
树形结构数据表结构设计
- 通用的数据表设计
-
返回树形结构数据通用工具类
- 根据通用的树形数据结构验证工具类代码的使用
-
源码解析
-
思考
- 基于这种场景为什么要设计这样一种通用的代码工具类
常见树形数据场景
做过后端开发的同学应该或多或少的接触过树形结构数据返回,这种场景几乎每个项目都会涉及到,比如部门层级结构、商品分类层级结构、标签层级结构等等诸如此类的场景,最终给用户呈现的数据结构是跟文件系统类型的一种树形结构,来看下图直观的感受下:
可以看到上图中的部门层级结构就像一颗树一样,有一个根级节点,其下有多个子节点,子节点之后又有子节点,这种层级的树形结构可以是无限层级的,也可以限制它的层级深度。
树形结构表结构设计
针对以上所描述的树形数据场景,我们在日常的业务表设计通常会在数据表中加一个parentId字段,字段值为对应的父节点的主键ID,用于描述对应的父子节点关系,所以通用的表结构可以设计如下:
CREATE TABLE `sys_dept` (
`id` varchar(36) NOT NULL COMMENT '主键ID',
`parent_id` varchar(36) DEFAULT '0' COMMENT '父节点ID',
`name` varchar(255) DEFAULT '' COMMENT '名称',
`weight` int(11) DEFAULT NULL COMMENT '权重(排序)',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门表';
对应的测试数据:
有一定开发经验的后端开发人员从如上数据一眼就能看出 parent_id 数据列的值包含特殊含义: 都是对应父节点的主键ID,如果是根级节点,则 parent_id 的值默认为0,这样就形成了如下树形数据结构:
- 软件部
- Java 工程师
- PHP 工程师
- 行政部
- 财务管理
树形工具类使用介绍
根据上述树形结构数据使用通用的工具类来实现数据的查询返回,测试代码如下:
public class TreeSearchTest {
static List<TreeNode<Long>> all_menu=new ArrayList<>();
static {
//构造数据源,这个数据可以是来自数据库查询列表
all_menu.add(new TreeNode<>(1L, 0L, "软件部", 0L));
all_menu.add(new TreeNode<>(2L,1L,"Java 工程师",0L));
all_menu.add(new TreeNode<>(3L,1L,"PHP 工程师",0L));
all_menu.add(new TreeNode<>(4L,0L,"行政部",0L));
all_menu.add(new TreeNode<>(5L,4L,"财务管理",0L));
}
@Test
public void searchNode() {
List<Tree<Long>> treeItems=TreeUtil.build(all_menu, 0L);
Tree<Long> tree=treeItems.get(0);
Tree<Long> searchResult=tree.getNode(3L);
Assert.assertEquals("PHP 工程师", searchResult.getName());
}
}
debug 运行结果如下:
可以看到返回的列表treeItems就是我们常见的树形数据结构,其中children就是每个节点下对应的子节点列表。
树形工具类源码解析
树形通用返回对象设计
节点接口,提供节点相关的的方法定义
/**
* 节点接口,提供节点相关的的方法定义
*
* @param <T> ID类型
* @author looly
* @since 5.2.4
*/
public interface Node<T> extends Comparable<Node<T>>, Serializable {
/**
* 获取ID
*
* @return ID
*/
T getId();
/**
* 设置ID
*
* @param id ID
* @return this
*/
Node<T> setId(T id);
/**
* 获取父节点ID
*
* @return 父节点ID
*/
T getParentId();
/**
* 设置父节点ID
*
* @param parentId 父节点ID
* @return this
*/
Node<T> setParentId(T parentId);
/**
* 获取节点标签名称
*
* @return 节点标签名称
*/
CharSequence getName();
/**
* 设置节点标签名称
*
* @param name 节点标签名称
* @return this
*/
Node<T> setName(CharSequence name);
/**
* 获取权重
*
* @return 权重
*/
Comparable<?> getWeight();
/**
* 设置权重
*
* @param weight 权重
* @return this
*/
Node<T> setWeight(Comparable<?> weight);
@SuppressWarnings({"unchecked", "rawtypes", "NullableProblems"})
@Override
default int compareTo(Node node) {
final Comparable weight = this.getWeight();
if (null != weight) {
final Comparable weightOther = node.getWeight();
return weight.compareTo(weightOther);
}
return 0;
}
}
- 主要是抽象出每个节点通用的字段方法,一般会有
id、parent_id、name、weight等通用字段
最终返回的树形实体对象
/**
* 通过转换器将你的实体转化为TreeNodeMap节点实体 属性都存在此处,属性有序,可支持排序
*
* @param <T> ID类型
* @author liangbaikai
* @since 5.2.1
*/
public class Tree<T> extends LinkedHashMap<String, Object> implements Node<T> {
private static final long serialVersionUID = 1L;
private final TreeNodeConfig treeNodeConfig;
private Tree<T> parent;
public Tree() {
this(null);
}
/**
* 构造
*
* @param treeNodeConfig TreeNode配置
*/
public Tree(TreeNodeConfig treeNodeConfig) {
super();
this.treeNodeConfig = ObjectUtil.defaultIfNull(
treeNodeConfig, TreeNodeConfig.DEFAULT_CONFIG);
}
/**
* 获取父节点
*
* @return 父节点
* @since 5.2.4
*/
public Tree<T> getParent() {
return parent;
}
/**
* 获取ID对应的节点,如果有多个ID相同的节点,只返回第一个。<br>
* 此方法只查找此节点及子节点,采用广度优先遍历。
*
* @param id ID
* @return 节点
* @since 5.2.4
*/
public Tree<T> getNode(T id) {
return TreeUtil.getNode(this, id);
}
/**
* 获取所有父节点名称列表
*
* <p>
* 比如有个人在研发1部,他上面有研发部,接着上面有技术中心<br>
* 返回结果就是:[研发一部, 研发中心, 技术中心]
*
* @param id 节点ID
* @param includeCurrentNode 是否包含当前节点的名称
* @return 所有父节点名称列表
* @since 5.2.4
*/
public List<CharSequence> getParentsName(T id, boolean includeCurrentNode) {
return TreeUtil.getParentsName(getNode(id), includeCurrentNode);
}
/**
* 获取所有父节点名称列表
*
* <p>
* 比如有个人在研发1部,他上面有研发部,接着上面有技术中心<br>
* 返回结果就是:[研发一部, 研发中心, 技术中心]
*
* @param includeCurrentNode 是否包含当前节点的名称
* @return 所有父节点名称列表
* @since 5.2.4
*/
public List<CharSequence> getParentsName(boolean includeCurrentNode) {
return TreeUtil.getParentsName(this, includeCurrentNode);
}
/**
* 设置父节点
*
* @param parent 父节点
* @return this
* @since 5.2.4
*/
public Tree<T> setParent(Tree<T> parent) {
this.parent = parent;
if (null != parent) {
this.setParentId(parent.getId());
}
return this;
}
@Override
@SuppressWarnings("unchecked")
public T getId() {
return (T) this.get(treeNodeConfig.getIdKey());
}
@Override
public Tree<T> setId(T id) {
this.put(treeNodeConfig.getIdKey(), id);
return this;
}
@Override
@SuppressWarnings("unchecked")
public T getParentId() {
return (T) this.get(treeNodeConfig.getParentIdKey());
}
@Override
public Tree<T> setParentId(T parentId) {
this.put(treeNodeConfig.getParentIdKey(), parentId);
return this;
}
@Override
public CharSequence getName() {
return (CharSequence) this.get(treeNodeConfig.getNameKey());
}
@Override
public Tree<T> setName(CharSequence name) {
this.put(treeNodeConfig.getNameKey(), name);
return this;
}
@Override
public Comparable<?> getWeight() {
return (Comparable<?>) this.get(treeNodeConfig.getWeightKey());
}
@Override
public Tree<T> setWeight(Comparable<?> weight) {
this.put(treeNodeConfig.getWeightKey(), weight);
return this;
}
@SuppressWarnings("unchecked")
public List<Tree<T>> getChildren() {
return (List<Tree<T>>) this.get(treeNodeConfig.getChildrenKey());
}
public void setChildren(List<Tree<T>> children) {
this.put(treeNodeConfig.getChildrenKey(), children);
}
/**
* 扩展属性
*
* @param key 键
* @param value 扩展值
*/
public void putExtra(String key, Object value) {
Assert.notEmpty(key, "Key must be not empty !");
this.put(key, value);
}
}
- 继承自
LinkedHashMap<String, Object>对象,主要是为了字段名的定义灵活性,同时扩展性也会更好一些,在源码中也可以看到有个putExtra方法就是用来返回一些扩展属性用的 - 实现了
Node<T>节点接口,表示改类是一个节点实体类,包含节点的通用字段方法 - 构造方法可传入
TreeNodeConfig对象初始化当前节点实体类的节点配置类,用于动态注入节点字段名称,在每个字段获取或者设置的时候会通过该节点配置类进行动态配置,当然如果不传的话,构造器会使用默认的TreeNodeConfig.DEFAULT_CONFIG默认字段名进行使用,如下:// 属性名配置字段 private String idKey = "id"; private String parentIdKey = "parentId"; private String weightKey = "weight"; private String nameKey = "name"; private String childrenKey = "children";
抽象数据源对象转换树形对象Tree的接口转换器
通常我们自己编写生成树形对象的时候,会先把数据源对象的列表平铺转换成要返回的树形结构对象列表,在这里把该转换逻辑抽象出函数式接口,源码中提供的默认的转换器实现,当然我们也可以随时自定义实现该接口进行转换
/**
* 树节点解析器 可以参考{@link DefaultNodeParser}
*
* @param <T> 转换的实体 为数据源里的对象类型
* @author liangbaikai
*/
@FunctionalInterface
public interface NodeParser<T, E> {
/**
* @param object 源数据实体
* @param treeNode 树节点实体
*/
void parse(T object, Tree<E> treeNode);
}
默认的转换器如下,主要是使用默认的字段名进行转换逻辑,设置一些扩展属性字段
/**
* 默认的简单转换器
*
* @param <T> ID类型
* @author liangbaikai
*/
public class DefaultNodeParser<T> implements NodeParser<TreeNode<T>, T> {
@Override
public void parse(TreeNode<T> treeNode, Tree<T> tree) {
//一些树形通用字段的转换设置
tree.setId(treeNode.getId());
tree.setParentId(treeNode.getParentId());
tree.setWeight(treeNode.getWeight());
tree.setName(treeNode.getName());
//扩展字段
final Map<String, Object> extra = treeNode.getExtra();
if(MapUtil.isNotEmpty(extra)){
extra.forEach(tree::putExtra);
}
}
}
树形数据列表返回最终入口
/**
* 树构建
*
* @param <T> 转换的实体 为数据源里的对象类型
* @param <E> ID类型
* @param list 源数据集合
* @param parentId 最顶层父id值 一般为 0 之类
* @param treeNodeConfig 配置
* @param nodeParser 转换器
* @return List
*/
public static <T, E> List<Tree<E>> build(List<T> list, E parentId, TreeNodeConfig treeNodeConfig, NodeParser<T, E> nodeParser) {
//1.先把源数据列表转换成最终返回的树形数据对象实体 Tree
final List<Tree<E>> treeList = CollUtil.newArrayList();
Tree<E> tree;
for (T obj : list) {
//初始化树形节点对象,传入节点配置对象
tree = new Tree<>(treeNodeConfig);
//使用自定义的转换器进行对象转换
nodeParser.parse(obj, tree);
//把转换后的数据对象放入列表
treeList.add(tree);
}
//初始化最终返回的树形节点列表
List<Tree<E>> finalTreeList = CollUtil.newArrayList();
//遍历树形节点列表,递归进行组装树形数据,同时支持每层节点列表中的节点根据权重进行排序
for (Tree<E> node : treeList) {
if (ObjectUtil.equals(parentId,node.getParentId())) {
finalTreeList.add(node);
innerBuild(treeList, node, 0, treeNodeConfig.getDeep());
}
}
// 内存每层已经排过了 这是最外层排序
finalTreeList = finalTreeList.stream().sorted().collect(Collectors.toList());
return finalTreeList;
}
- 先把源数据列表转换成最终返回的树形数据对象实体
Tree,转换过程使用可自定义节点配置对象TreeNodeConfig和自定义的NodeParser转换器 - 可转入根级处理节点参数
parentId,通常该参数根据自己业务定义的值去传,比如业务表设计根级节点的parentId定义为0,则该参数就是传0 - 遍历树形节点列表,递归进行组装树形数据,同时支持每层节点列表中的节点根据权重进行排序
递归生成节点方法
/**
* 递归处理
*
* @param treeNodes 数据集合
* @param parentNode 当前节点
* @param deep 已递归深度
* @param maxDeep 最大递归深度 可能为null即不限制
*/
private static <T> void innerBuild(List<Tree<T>> treeNodes, Tree<T> parentNode, int deep, Integer maxDeep) {
if (CollUtil.isEmpty(treeNodes)) {
return;
}
//maxDeep 可能为空
if (maxDeep != null && deep >= maxDeep) {
return;
}
// 每层排序 TreeNodeMap 实现了Comparable接口
treeNodes = treeNodes.stream().sorted().collect(Collectors.toList());
for (Tree<T> childNode : treeNodes) {
if (parentNode.getId().equals(childNode.getParentId())) {
List<Tree<T>> children = parentNode.getChildren();
if (children == null) {
children = CollUtil.newArrayList();
parentNode.setChildren(children);
}
children.add(childNode);
// childNode.setParentId(parentNode.getId());
childNode.setParent(parentNode);
innerBuild(treeNodes, childNode, deep + 1, maxDeep);
}
}
}
思考:基于这种场景为什么要设计这样一种通用的代码工具类
-
代码复用性:不想偷懒的程序员不是好码农,这种通用的数据场景的代码只需要写一遍就够了
-
重复利用现有的工具类代码,应对各种类型的树形结构数据显得更得心应手,容错率更高
最后
祝大家2021年牛年大吉、万事如意!
参考:
Hutool官网:hutool.cn/github:github.com/looly/hutoo…
更多原创文章会在公众号第一时间推送,欢迎扫码关注 张少林同学