基于 MPTT 简单实现部门树管理

·  阅读 264
基于 MPTT 简单实现部门树管理

前言

MPTTModified Preorder Tree Taversal,直译就是修改的先序树遍历。而实际也正是如此,MPTT就是在对树进行先序遍历的基础上在每个节点中增加了leftright属性,最终让树的查询操作(例如查询某个节点的所有子节点、查询所有的叶子节点)变得十分高效。接下来,本文就来介绍MPTT的一些基本概念及对应的数据据库中的表结构,并通过一个简单的基于SpringBoot + Vue框架的例子进行实战讲解,本文所使用的代码已上传到GitHub

先序遍历与MPTT

在介绍MPTT前,先来回顾一下树的先序遍历:遍历到一个节点后,立刻输出该节点的值,然后继续分别遍历该节点的左右节点,整体流程对应下图:

image-20211227152908543

最终遍历的顺序为A -> B -> D -> E -> C,对应代码操作如下:

public class Tree<T> {
    
	public T val;
    
	public Tree<T> left;
    
	public Tree<T> right;
    
	public Tree(T val) {
		this.val = val;
	}
    
	public static <T> void preOrder(Tree<T> tree, List<T> nodeList) {
		if (tree == null) {
			return;
		}
		nodeList.add(tree.val);
		preOrder(tree.left, nodeList);
		preOrder(tree.right, nodeList);
	}
    
	public static void main(String[] args) {
		Tree<String> tree = new Tree<>("A");
		tree.left = new Tree<>("B");
		tree.left.left = new Tree<>("D");
		tree.left.right = new Tree<>("E");
		tree.right = new Tree<>("C");
		List<String> nodeList = new ArrayList<>();
		preOrder(tree, nodeList);
		System.out.println(String.join(" -> ", nodeList));
	}
    
}
复制代码

MPTT则是在先序遍历的过程中在节点中新增了lft rgt两个属性值,其中lft在第一次访问该节点左节点前进行设置,rgt在右节点访问完毕后进行设置,对应下图流程:

image-20211227153417222

顺序vallftrgt
1A110
2B27
3D34
4E56
5C89

对应代码操作如下:

public class Tree<T> {
    
	public Node<T> node;
    
	public Tree<T> left;
    
	public Tree<T> right;
    
	public Tree(Node<T> node) {
		this.node = node;
	}
    
	public static class Node<T> {
		public T val;
		public long lft;
		public long rgt;
		public Node(T val) {
			this.val = val;
		}
	}
    
	public static <T> long preOrder(Tree<T> tree, long num, List<Node<T>> nodeList) {
		if (tree == null) {
			return num - 1;
		}
		tree.node.lft = num;
		nodeList.add(tree.node);
		long lft = preOrder(tree.left, num + 1, nodeList);
		long rgt = preOrder(tree.right, lft + 1, nodeList);
		tree.node.rgt = rgt + 1;
		return rgt + 1;
	}
    
	public static void main(String[] args) {
		Tree<String> tree = new Tree<>(new Node<>("A"));
		tree.left = new Tree<>(new Node<>("B"));
		tree.left.left = new Tree<>(new Node<>("D"));
		tree.left.right = new Tree<>(new Node<>("E"));
		tree.right = new Tree<>(new Node<>("C"));
		List<Node<String>> nodeList = new ArrayList<>();
		preOrder(tree, 1, nodeList);
		System.out.println("val | lft | rgt");
		for (Node<String> node : nodeList) {
			System.out.printf(" %s  |  %s  |  %s%n", node.val, node.lft, node.rgt);
		}
	}
    
}
复制代码

通过给节点增加lftrgt,我们可以很容易发现一个结论:一个节点的所有子节点的lftrgt值一定在该节点lftrgt值之间,例如节点B的所有子节点的lftrgt值均在2 ~ 7之间,至于原因则是上文中提到的,一个节点的lft在访问该节点的左节点前进行设置,所以该节点的所有子节点的lftrgt值一定大于该节点的lft值,而一个节点的rgt值在访问该节点的右节点之后进行设置,所以同理该节点的rgt 值一定大于其所有子节点的lftrgt值,而通过这个特性,我们便可以很方便的查询一个节点的所有子节点。

除此之外,如果仔细观察,可以发现所有的叶子节点的rgt值一定是其lft值加一,因此我们也很容易查找所有的叶子节点或者某个节点下的所有叶子节点。

数据库设计

在介绍完关于mptt的一些基本概念后,下面再通过将以上数据转换为数据库存储,来讲解其实际应用。

首先创建表:

create table mptt (
    id bigint auto_increment primary key,
    val varchar(20) not null,
    lft bigint not null,
    rgt bigint not null
);
复制代码

然后添加几条数据:

insert into mptt (id, val, lft, rgt) 
	values (1, 'A', 1, 10), (2, 'B', 2, 7), (3, 'D', 3, 4), (4, 'E', 5, 6), (5, 'C', 8, 9);
复制代码

这几条数据对应的仍然是上文的树结构:

image-20211227153417222

然后讲解几个常用的操作:

  1. 查询某个节点的所有子节点:

    -- 以下语句用于查询节点 B 及其所有子节点
    -- 若只想查询子节点, 去掉 = 号即可
    select id, val, lft, rgt from mptt where lft >= 2 and rgt <= 7
    复制代码
  2. 查询所有的叶子节点:

    select id, val, lft, rgt from mptt where lft = rgt - 1
    -- 若只想查询某个节点下的叶子节点, 加上对 lft 和 rgt 的限制即可
    -- 以下语句用于查询 B 节点下的所有叶子节点
    select id, val, lft, rgt from mptt where lft >= 2 and rgt <= 7 and lft = rgt - 1
    复制代码
  3. 查询指定节点的所有父节点:

    -- 以下语句用于查询节点 D 的所有父节点
    select id, val, lft, rgt from mptt where lft < 3 and rgt > 4
    复制代码
  4. 获取节点的深度/层级:

    select node.val, (count(parent.val) - 1) as deep
        from mptt as node, mptt as parent
        where node.lft between parent.lft and parent.rgt
        group by node.val
    复制代码

增删改操作

不同于<id, pid>的树形结构,mptt在增删改操作时比较复杂,涉及到增删改节点和受到影响节点的lftrgt值设置,所以mptt更适合查询操作,而不适合需要频繁进行增删改操作的树结构。下面分别介绍在进行增删改操作时如何更新节点的lftrgt值。

增加节点

为了方便操作,我们默认增加的节点总处于其父节点的所有子节点的最右侧,简单来说就是假设有一个节点A,有两个子节点BC,那么新增的节点就在C的右侧,即B -> C -> D,不过在实际业务中,我们可能想要控制新增节点在子节点中的顺序,例如B -> D -> C,那么只需要在表中增加一个sort排序字段进行控制即可,这里不再考虑。

如果基于以上的条件,那么新增一个节点可以分为以下三步操作,以E节点下新增F节点为例:

  1. 获取新增节点F父节点Ergt(6)值。
  2. 将所有lftrgt大于等于节点Ergt(6)值均加2。
  3. 插入新增的节点数据,其中新增节点Flft为其父节点Ergt(6),rgt为父节点Ergt加一(7)。

其中变动的情况如下:

val lft rgt
 E   5   8
 F   6   7
 C   10  11
 A   1   12
复制代码

对应的sql语句如下:

select @tmpRgt := rgt from mptt where val = 'E';

update mptt set lft = lft + 2 where lft >= @tmpRgt;
update mptt set rgt = rgt + 2 where rgt >= @tmpRgt;

insert into mptt(val, lft, rgt) values ('F', @tmpRgt, @tmpRgt + 1);
复制代码

删除节点

这里以删除叶子节点为例,其实删除节点可以看作是新增节点的逆操作,同样分为三步操作,以删除新增的F节点为例:

  1. 获取要删除节点Frgt(7)值。
  2. 删除F节点。
  3. 将所有lftrgt大于节点Frgt(7)值均减2。

对应sql语句(删除任意节点,如果特定为叶子节点,则@width固定为2)如下:

select @tmpLft := lft, @tmpRgt := rgt, @width := rgt - lft + 1 from mptt where val = 'F';

delete from mptt where lft between @tmpLft AND @tmpRgt;

update mptt set rgt = rgt - @width where rgt > @tmpRgt;
update mptt set lft = lft - @width where lft > @tmpRgt;
复制代码

修改节点

如果在修改节点时未修改节点的父节点,则只需要简单的将节点的字段对应修改即可,如果修改了节点的父节点,则可以简单的将其当作首先从其旧父节点中删除该节点,然后在新父节点下新增该节点,其中删除和新增的步骤参考上述两个步骤即可。不过,这里还隐藏着一个问题,如果表结构按照目前这样的设计id, val, lft, rgt,我们如果查询一个节点的直接父节点就会稍微麻烦一些,例如获取节点B的父节点,需要使用以下语句:

select id, val, lft, rgt from mptt where lft < 3 and rgt > 4 order by lft desc limit 1
复制代码

所以在下文的实际实现中,我还是选择了增加一个parentId字段来结合lftrgt进行使用(除了可以更方便地查找节点的直接父节点,还可以更加方便地查找某个节点的直接子节点,不需要再增加level字段或者更加的复杂的sql语句操作了)。

实际应用

在最后再来简单介绍一下基于以上思路,然后使用SpringBoot + Vue框架简单实现的一个部门树管理代码,这里不会再介绍具体的代码细节,想要了解直接查看前言中源码即可,这里主要展示最终效果和一些设计思路。

首先是数据库的表结构设计:

CREATE TABLE `sys_dept`  (
  `id` bigint(20) UNSIGNED NOT NULL COMMENT 'id',
  `parent_id` bigint(20) UNSIGNED NOT NULL COMMENT '父 id',
  `lft` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '左节点',
  `rgt` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '右节点',
  `code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '编码',
  `name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '名称',
  `gmt_create` datetime NULL DEFAULT NULL COMMENT '创建时间',
  `gmt_modified` datetime NULL DEFAULT NULL COMMENT '修改时间',
  `del_flag` int(2) NULL DEFAULT 0 COMMENT '是否已删除(0: 否 1: 是)',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
复制代码

然后是最终的效果图:

image-20211227225823513

image-20211227225851545

2022-01-11 补充

使用mptt实现懒加载树也十分方便,由于顶级节点的parentId0,所以查询第一层级的部门数据很方便,而判断节点是否为叶子节点,也只需要根据结点的lftrgt的差值是否为1即可判断(GitHub中代码已同步更新),后端部分代码如下:

/**
 * 根据 parentId 获取部门列表
 *
 * @param parentId parentId
 * @return 部门列表
 */
@GetMapping("/lazyTree/{parentId}")
public BaseResult<List<SysDeptLazyTreeVO>> lazyTree(@PathVariable("parentId") Long parentId) {
    List<SysDept> sysDeptList = sysDeptService.getByParentId(parentId);
    List<SysDeptLazyTreeVO> sysDeptLazyTreeVoList = new ArrayList<>();
    for (SysDept sysDept : sysDeptList) {
        SysDeptLazyTreeVO sysDeptLazyTreeVo = CopyBeanUtils.copy(
            sysDept, SysDeptLazyTreeVO::new);
        sysDeptLazyTreeVo.setIsLeaf(sysDeptService.isLeaf(sysDept));
        sysDeptLazyTreeVoList.add(sysDeptLazyTreeVo);
    }
    return BaseResult.success(sysDeptLazyTreeVoList);
}

/**
 * 判断部门是否为叶子节点
 *
 * @param sysDept 部门
 * @return 是否为叶子节点
 */
Boolean isLeaf(SysDept sysDept);

@Override
public Boolean isLeaf(SysDept sysDept) {
    return sysDept.getRgt() - sysDept.getLft() == 1L;
}
复制代码

前端代码如下:

<el-tree
          lazy
          :load="loadTreeNode"
          :props="{ label: 'name', isLeaf: 'isLeaf' }"/>

/**
 * 加载懒加载树节点
 */
async loadTreeNode(node, resolve) {
	let res = await lazyTreeSelect(node.data?.id || 0)
	return resolve(res.data)
}
复制代码

总结

本文简单介绍了mptt的基本概念及其实际应用场景,因个人水平有限,如有错误之处,欢迎批评指出。

参考资料

[1] Monika Handayani,Muhammad Hendra,Muhammad Bahit,Noor Safrina. Traversal Tree Implementation in Chart of Account Design[P]. 1st Annual Management, Business and Economic Conference (AMBEC 2019),2020.

分类:
后端
分类:
后端
收藏成功!
已添加到「」, 点击更改