引言
部门表通常是一种树状结构,使用id和parent_id两个字段就足以表达表中所有数据之间的层级关系,为了定义每个部门之间的顺序关系,通常还会添加一个seq字段。
虽然上述字段就足以体现整个公司各部门之间的层级关系,但在应用上还是有些费劲,比如要查找某个部门的所有子部门,就得遍历每个部门,看它的parent_id是不是此部门的id,然后还得递归查询下去以找到所有的下级。这样显然效率很低,所以还会有个字段充分体现每个部门的所有层级关系。
在若依项目中就使用了祖级列表ancestors表示每个部门的所有上级部门,如表中[0, 100, 101]就表示了该部门的上级部门分别若依科技-深圳总公司(表中并不存在id为0的部门,但是约定parent_id为0的部门就是根部门)。
若依的祖级列表ancestors
有了祖级列表之后,查找某个部门的所有子部门就相对简单些了,在项目中,使用了如下SQL语句用于查询若依科技及其所有子部门:
... d.deptId in (select dept_id from sys_dept where dept_id = 100 or find_in_set(100, ancestors));
- SQL语句中使用了
find_in_set函数,作用是查找以逗号分隔的字符串列表中是否存在要查找的字符串。这样就可以找到祖级列表中包含本id的所有部门。 - 获取到了所有子部门的
id后,再进行了一次查找,最后得到所有的子部门
使用find_in_set确实避免了递归查询,但是这个函数本身性能并不好,它不会利用索引,当数据里量大了后,查询速度就被拖慢了。
为了改善这个问题,我优化了若依中的部门表,改用字段level表示所有部门的层级关系。
我的层级字符串level
建表语句:
create table sys_dept
(
id bigint auto_increment comment '部门id' primary key,
name varchar(32) default '' null comment '部门名称',
parent_id bigint default 0 null comment '父部门id',
level varchar(32) null comment '部门级别字符串,以.分隔',
seq int null comment '部门本级排序',
)
comment '部门表';
数据示例:
字段level有如下特点:
- 只表示层级关系,如没有分隔符
.它就是一级部门,有1个它就是二级部门。 - 父部门的
level值会被其子部门全部继承。 - 每一级部门的
level值要保证唯一性。
⭐不要想着让字段level也具有排序的功能,这样会让增和改都变得非常麻烦。只要保证唯一性就可以了。
这样在查询大公司及其所有子部门时,只要使用like这个模糊查询功能就能做到:
... d.level like '1%'
要是查询大公司的所有子部门时(不包含大公司自己):
... d.level like '1.%'
是不是很灵活方便?
加上排序就是这样的:
order by length(level) - length(replace(level, '.', '')), seq is null, seq
length(level) - length(replace(level, '.', ''))表示点的个数- 这样就实现了先按层级排,再按
seq字段排,并且seq为空值的会被排在后面
增改要考虑的
有了level字段后,查询起来是方便了,但是维护却很麻烦,颇有功在当代,利在千秋的感觉,毕竟对于部门表来说,肯定是查询量比增改多多了。
项目中用到的其它字段说明:
status:部门状态,为1是正常,为0是停用del_flag:删除标记,为1是被删了,为0是没有
增和改要接收的对象:
public class SysDeptDTO {
/** 部门ID */
private Long id;
/** 部门名称 */
private String name;
/** 父部门ID */
private Long parentId;
/** 部门本级排序 */
private Integer seq;
/** 部门状态(1正常 0停用) */
@Max(value = 1, message = "dept.status.error")
private Integer status;
/** 备注信息 */
private String remark;
}
只有status这个字段对增和改的要求都是一样的,其它的都不一样,所以要嘛是用分组校验,要嘛直接在Controller层添加校验规则。这里我选择第二种。
增
增对字段的要求:
id,可以没有,有也用不到name,必须要有,还要满足一定规则(比如不能太长),还要求同一层级内的唯一性parentId,必须要有,且对应的父部门不能是被删除、被停用的状态,要额外处理parentId为0的特殊情况seq,可以没有status,可以没有,有的话不能超过1,默认值为1remark,可以没有
处理逻辑顺序:
name不能为空parentId不能为空- 校验
name是否满足规则,是否唯一 - 检查部门权限
- 判断用户是不是Admin,是就直接返回
- 判断
parentId是不是0,是就抛出异常,因为约定只有超级管理员才能添加最高级部门 - 判断父部门在不在查看的权限内
- 当
parentId不是0时,从数据库中查找父部门,检查有没有父部门,有没有被删,是不是禁用 - 获取父部门的所有子部门,也就是兄弟部门
- 根据兄弟部门的最后一位level值,得到一个唯一的level值,再将父部门的level值和这个唯一的level值做拼接(中间以
.分隔),得到这个新增部门的level值。 - 设置创建人
- 提交给mapper层执行添加
/**
* 新增部门
*
* @param dept 部门信息
* @return 结果
*/
@Override
public AjaxResult insert(SysDeptDTO dept) {
SysDept sysDept = converter.convert(dept, SysDept.class);
Long parentId = sysDept.getParentId();
// 校验部门名称,父部门相同的情况下,部门名称不能重复
checkDeptNameUnique(null, parentId, sysDept.getName());
// 检查权限
checkDeptDataScope(parentId);
// 处理同级部门之间的关系
List<SysDept> siblingDepts = sysDeptMapper.selectDeptsByParentId(parentId);
int uniqueLevel = getUniqueLevel(siblingDepts);
String level = String.valueOf(uniqueLevel);
if (parentId != 0) {
SysDept parentDept = sysDeptMapper.selectDeptById(parentId);
checkParentDept(parentDept);
level = parentDept.getLevel() + "." + level;
}
sysDept.setLevel(level);
sysDept.setCreateBy(SecurityUtils.getUsername());
int rows = sysDeptMapper.insert(sysDept);
return AjaxResult.toAjaxResult(rows, "post.success", "post.failed");
}
/**
* 获取唯一的部门层级数
*
* @param siblingDepts 同级部门列表
* @return 层级
*/
private int getUniqueLevel(List<SysDept> siblingDepts) {
if (CollectionUtils.isEmpty(siblingDepts)) {
return 1;
}
int[] levels = siblingDepts.stream()
.map(SysDept::getLevel) // 获取level字段
.map(level -> level.split("\\.")) // 按.分割
.map(parts -> parts[parts.length - 1]) // 取最后一个部分
.mapToInt(Integer::parseInt) // 转为整数
.toArray();
Arrays.sort(levels);
// 从2开始查找第一个不在数组中的数
int candidate = 2;
for (int level : levels) {
if (candidate < level) {
// 找到了不在数组中的最小数
break;
} else if (candidate == level) {
// 如果相等,候选数递增
candidate++;
}
// 如果candidate > level,继续循环
}
return candidate;
}
改
改就更费劲了
增对字段的要求:
id,必须要有name,可以没有,有就得满足规则,满足同一层级内的唯一性parentId,可以没有,有的话,对应的父部门不能是被删除、被停用的状态,要额外处理parentId为0的特殊情况seq,可以没有status,可以没有,有的话不能超过1,默认值为1remark,可以没有
处理逻辑顺序:
- 判断
id是不是空 - 判断
parentId和id是否相同 - 判断
name是不是空的,是空的直接赋值为null - 校验数据权限:
- 判断是不是Admin,是就直接返回
- 判断要修改的部门是否在查看的权限范围内
- 根据
id从数据库中获取原始部门信息- 原始部门如果是null,或者被删、被禁用,就拒绝更新
- 校验父部门
parentId如果为null,或者parentId和原始部门的parentId相同,就说明没有改变层级结构,因此可以不处理level值,直接更新。- 如果父部门变了
- 当
parentId不为0时,要检查新的父部门是否存在,是否被删,是否被禁用,还要检查新的父部门是不是当前部门的子部门 - 校验部门名称
- 根据
parentId获取同级部门,也就是兄弟部门 - 根据兄弟部门的最后一位level值,得到一个唯一的level值,再将父部门的level值和这个唯一的level值做拼接(中间以
.分隔),得到这个新增部门的level值。 - 获取该部门的所有子部门,根据原始信息的level值长度,把所有子部门的level都裁掉(因为这些都是从父部门完完整整继承过来的),再拼接上新的level。不能把这个过程当作替换,可能会不小心把后面的也都给替了。
- 批量更新所有子部门(只更新level值)
- 更新新的部门信息
- 当
/**
* 更新部门
*
* @param dept 部门信息
* @return 结果
*/
@Override
public AjaxResult update(SysDeptDTO dept) {
Long deptId = dept.getId();
// 校验数据权限
checkDeptDataScope(deptId);
// 获取数据库中存储的原始部门信息
SysDept oldDept = sysDeptMapper.selectDeptById(deptId);
if (oldDept == null || Constants.DELETED.equals(oldDept.getDelFlag())) {
throw new BaseException(ModuleConstants.SYSTEM, HttpStatus.FORBIDDEN, "找不到要更新的部门!");
}
// 校验父部门
if (dept.getParentId() == null || Objects.equals(dept.getParentId(), oldDept.getParentId())) {
// 父部门不变,直接更新
// 校验部门名称,父部门相同的情况下,部门名称不能重复
checkDeptNameUnique(deptId, oldDept.getParentId(), dept.getName());
SysDept sysDept = converter.convert(dept, SysDept.class);
return update(sysDept);
}
// 父部门变了
// 获取父部门信息
SysDept parentDept = null;
List<SysDept> children = sysDeptMapper.selectDeptsByLevel(oldDept.getLevel() + ".");
// 判断父部门id不为0的情况
if (dept.getParentId() != 0L) {
parentDept = sysDeptMapper.selectDeptById(dept.getParentId());
// 校验父部门
checkParentDept(parentDept);
// 父部门不能是子部门
if (children.stream().anyMatch(child -> child.getId().equals(dept.getParentId()))) {
throw new BaseException(ModuleConstants.SYSTEM, HttpStatus.PARA_ERROR, "父部门不能是当前部门的子部门!");
}
}
// 校验部门名称,父部门相同的情况下,部门名称不能重复
checkDeptNameUnique(deptId, dept.getParentId(), dept.getName());
SysDept sysDept = converter.convert(dept, SysDept.class);
// 处理这个要更新的部门和同级之间的关系
List<SysDept> siblingDepts = sysDeptMapper.selectDeptsByParentId(sysDept.getParentId());
int uniqueLevel = getUniqueLevel(siblingDepts);
String oldLevel = oldDept.getLevel();
String newLevel = parentDept == null ? String.valueOf(uniqueLevel) : parentDept.getLevel() + "." + uniqueLevel;
children.forEach(child -> {
String level = newLevel + child.getLevel().substring(oldLevel.length());
child.setLevel(level);
child.setUpdateBy(SecurityUtils.getUsername());
});
// 批量更新所有子部门
sysDeptMapper.updateLevelInBatch(children);
// 更新自己
sysDept.setLevel(newLevel);
return update(sysDept);
}
总结
level字段有效地帮助了查询,提高了查询效率,但是维护起来也相对复杂。文中重点梳理了增、改的处理逻辑,它们各有不同,相应的代码只是提供了大致的处理思路,还有一些其它不太相关的细节可以在我的项目中找到。我目前主要计划就是复现若依,抱着学习的态度学,作为一个初学者、入门者,遇到的问题很多,若依本身的问题也很多,希望在不断解决问题的过程中能不断提升自己的coding能力。文章里有说的不到位的,欢迎各位大佬批评指正!