别再重复编码了,这个通用树型节点工具类代码拿去用!

4,775 阅读8分钟

前言

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;
	}
}
  • 主要是抽象出每个节点通用的字段方法,一般会有 idparent_idnameweight等通用字段

最终返回的树形实体对象

/**
 * 通过转换器将你的实体转化为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年牛年大吉、万事如意!

参考:

更多原创文章会在公众号第一时间推送,欢迎扫码关注 张少林同学