需求分析
数据字典(Data Dictionary)是一种用户可以访问的记录数据库和应用程序元数据的目录。数据字典在系统也是必备的组件,对此我们想想要怎么做:
- 基本功能:Key/Value 方式的存储结构,外加其他属性如”说明“,尽可能设计为通用一点
- 树状结构,也就是说带分类功能的。换句话说,这个数据字典也是一个分类表(数据库层面就是加一个
parentId
字段,当然其他 逻辑较为复杂,而且也不是pId
这唯一一种树的存储方法) - 可以租户/门户隔离,也就是多个门户或者租户。
基本设计
表设计如下。
CREATE TABLE `sys_datadict` (
`id` INT(11) NOT NULL AUTO_INCREMENT COMMENT '主键 id,自增',
`name` VARCHAR(20) NULL DEFAULT NULL COMMENT '名称、自定义编码、相当于 key。可选的' COLLATE 'utf8mb4_bin',
`value` VARCHAR(50) NOT NULL COMMENT '值' COLLATE 'utf8mb4_bin',
`content` VARCHAR(200) NULL DEFAULT NULL COMMENT '简介、描述' COLLATE 'utf8mb4_bin',
`parentId` INT(11) NULL DEFAULT NULL COMMENT '父 id',
`type` INT(11) NULL DEFAULT NULL COMMENT '类型 id',
`sortNo` TINYINT(4) NULL DEFAULT NULL COMMENT '顺序、序号',
`stat` TINYINT(4) NULL DEFAULT NULL COMMENT '数据字典:状态',
`uid` BIGINT(20) NULL DEFAULT NULL COMMENT '唯一 id,通过 uuid 生成不重复 id',
`tenantId` INT(11) NOT NULL DEFAULT '0' COMMENT '租户 id。0 = 不设租户',
`createByUser` INT(11) NULL DEFAULT NULL COMMENT '创建者 id',
`createDate` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updateDate` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`) USING BTREE,
INDEX `tenantId` (`tenantId`) USING BTREE
)
COMMENT='数据字典'
COLLATE='utf8mb4_bin';
Java Bean:
import java.util.Date;
import com.ajaxjs.framework.IBaseModel;
/**
* 数据字典
*
* @author Frank Cheung<sp42@qq.com>
* @date 2021-11-07
*/
public class DataDict implements IBaseModel {
/**
* 主键 id,自增
*/
private Long id;
/**
* 名称、自定义编码、相当于 key。可选的
*/
private String name;
/**
* 值
*/
private String value;
/**
* 简介、描述
*/
private String desc;
/**
* 父 id
*/
private Long parentId;
/**
* 类型 id
*/
private Long type;
/**
* 顺序、序号
*/
private Integer sortNo;
/**
* 数据字典:状态
*/
private Integer stat;
/**
* 唯一 id,通过 uuid 生成不重复 id
*/
private Long uid;
/**
* 租户 id。0 = 不设租户
*/
private Long tenantId;
/**
* 创建者 id
*/
private Long createByUser;
/**
* 创建时间
*/
private Date createDate;
/**
* 修改时间
*/
private Date updateDate;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
public String getDesc() {
return desc;
}
public void setDesc(String desc) {
this.desc = desc;
}
public Long getParentId() {
return parentId;
}
public void setParentId(Long parentId) {
this.parentId = parentId;
}
public Long getType() {
return type;
}
public void setType(Long type) {
this.type = type;
}
public Integer getSortNo() {
return sortNo;
}
public void setSortNo(Integer sortNo) {
this.sortNo = sortNo;
}
public Integer getStat() {
return stat;
}
public void setStat(Integer stat) {
this.stat = stat;
}
public Long getUid() {
return uid;
}
public void setUid(Long uid) {
this.uid = uid;
}
public Long getTenantId() {
return tenantId;
}
public void setTenantId(Long tenantId) {
this.tenantId = tenantId;
}
public Long getCreateByUser() {
return createByUser;
}
public void setCreateByUser(Long createByUser) {
this.createByUser = createByUser;
}
public Date getCreateDate() {
return createDate;
}
public void setCreateDate(Date createDate) {
this.createDate = createDate;
}
public Date getUpdateDate() {
return updateDate;
}
public void setUpdateDate(Date updateDate) {
this.updateDate = updateDate;
}
}
界面:
UI 设计也是大头问题,我们后面再讲。
实现难点
树状结构存储
如何在关系型数据库里面存储树状结构一直是需要思考的问题,尤其对于数据结构掌握不好的同学来说,更是难点。这个知识点我前期探索过,参考博文《SQL 双亲节点查找所有子节点》。现在回过头来看,结合大家的一些经验,决定这样子做。
- MySQL 还是老老实实用 parentId 的存储方法
- MySQL 8.0 虽然支持 CTE 递归,但老版本就无能为力了。对此,比较简单省力的方式是,直接上 MySQL 函数。
MySQL 函数为 getAllChildren
,源码如下:
CREATE DEFINER=`root`@`%` FUNCTION `getAllChildren`(
`rootId` INT,
`tableName` VARCHAR(50)
)
RETURNS varchar(4000) CHARSET latin1
BEGIN
DECLARE P_TEMP VARCHAR(1000);
DECLARE C_TEMP VARCHAR(1000);
SET P_TEMP = '0';
SET C_TEMP = CAST(rootId AS CHAR);
WHILE C_TEMP IS NOT NULL DO
SET P_TEMP = CONCAT(P_TEMP, ',', C_TEMP);
IF tableName = 'sys_datadict' THEN
SELECT GROUP_CONCAT(id) INTO C_TEMP FROM sys_datadict WHERE FIND_IN_SET(parentId, C_TEMP) > 0;
ELSEIF tableName = 'sys_datadict1' THEN
SELECT GROUP_CONCAT(id) INTO C_TEMP FROM sys_datadict WHERE FIND_IN_SET(parentId, C_TEMP) > 0;
ELSE
SET C_TEMP = NULL;
END IF; -- if结束
END WHILE;
RETURN P_TEMP;
END
调用例子:
SELECT * FROM sys_datadict WHERE FIND_IN_SET(id, getAllChildren(2, 'sys_datadict')) ORDER BY parentId ASC
关键原理是 MySQL 的 find_in_set
函数,顺带学习一下。当然了——换了别的数据库就不是这样子做,通通要改。
getAllChildren()
第一个参数为 INT
,是父亲 id,第二个参数是表名,——为什么有表名呢?因为 MySQL 函数不支持动态传入表名作为参数,于是只能写死,因为当初我想这个函数支持多张表而不是一张表写一个函数的。不过我采取代价相对较低的”写死“,在函数里面写死表名,通过 IF ELSE
判断,便可达到支持多张表的目的。
调用上述 SQL 成功返回如下。
获取节点深度
传入节点 id,返回其深度(depth)。深度即表示节点在树中位于第几层次。
需要一个存储过程和函数来完成。如果你要拿去用,可能要改下 表名、字段名或者出参/入参的类型。
CREATE DEFINER=`root`@`%` PROCEDURE `countLayerHelper`(IN id INT, INOUT counter INT)
BEGIN
IF EXISTS (SELECT 1 FROM sys_datadict o WHERE o.id = id) THEN
BEGIN
DECLARE pid INT DEFAULT 0;
DECLARE done BOOLEAN DEFAULT FALSE;
DECLARE _cursor CURSOR FOR SELECT o.parentId FROM sys_datadict o WHERE o.id = id;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
SET counter = counter + 1;
OPEN _cursor;
read_loop: LOOP
FETCH _cursor INTO pid;
IF done THEN LEAVE read_loop;
END IF;
CALL countLayerHelper(pid, counter);
END LOOP;
CLOSE _cursor;
END;
END IF;
END
另外需要一个函数来调用存储过程。
CREATE DEFINER=`root`@`%` FUNCTION `countLayer`(id INT) RETURNS int(11)
BEGIN
DECLARE counter INT DEFAULT 0;
SET max_sp_recursion_depth = 255;
CALL countLayerHelper(id, counter);
RETURN counter - 1;
END
用户调用:
SELECT countLayer(3) AS depth
参考出处。
树节点数组如何转换为 JSON 树?
数据库返回的是一维数组,如何转换为带层次的 JSON 树呢?首先声明一个树节点的类型。
/**
* 树节点
*/
type TreeMap = {
id: number;
/**
* 父 id,输入时候必填
*/
parentId: number;
/**
* 子节点,生成 Tree 之后出现
*/
children?: TreeMap[];
}
输入(JSON 起码要有 id
、parentId
字段):
{"result":[{"stat":null,"updateDate":"2022-03-28 18:12:55","type":null,"content":null,"parentId":9,"sortNo":null,"uid":null,"name":null,"tenantId":0,"id":14,"value":"广州新闻","createByUser":null,"createDate":"2022-03-28 18:12:26"}, {"stat":null,"updateDate":"2022-02-26 21:38:28","type":null,"content":null,"parentId":5,"sortNo":null,"uid":null,"name":null,"tenantId":0,"id":6,"value":"香港新闻","createByUser":null,"createDate":"2022-02-26 21:38:28"}, {"stat":null,"updateDate":"2022-03-28 15:20:43","type":null,"content":null,"parentId":5,"sortNo":null,"uid":null,"name":null,"tenantId":0,"id":7,"value":"澳门新闻","createByUser":null,"createDate":"2022-03-28 15:20:43"}, {"stat":null,"updateDate":"2022-03-28 15:20:55","type":null,"content":null,"parentId":5,"sortNo":null,"uid":null,"name":null,"tenantId":0,"id":8,"value":"台湾新闻","createByUser":null,"createDate":"2022-03-28 15:20:55"}, {"stat":null,"updateDate":"2022-03-28 15:22:06","type":null,"content":null,"parentId":4,"sortNo":null,"uid":null,"name":null,"tenantId":0,"id":12,"value":"美国新闻","createByUser":null,"createDate":"2022-03-28 15:22:06"}, {"stat":null,"updateDate":"2022-03-28 15:22:14","type":null,"content":null,"parentId":4,"sortNo":null,"uid":null,"name":null,"tenantId":0,"id":13,"value":"俄罗斯新闻","createByUser":null,"createDate":"2022-03-28 15:22:14"}, {"stat":null,"updateDate":"2022-03-28 15:21:08","type":null,"content":null,"parentId":3,"sortNo":null,"uid":null,"name":null,"tenantId":0,"id":9,"value":"广东新闻","createByUser":null,"createDate":"2022-03-28 15:21:05"}, {"stat":null,"updateDate":"2022-03-28 15:21:23","type":null,"content":null,"parentId":3,"sortNo":null,"uid":null,"name":null,"tenantId":0,"id":10,"value":"湖南新闻","createByUser":null,"createDate":"2022-03-28 15:21:23"}, {"stat":null,"updateDate":"2021-11-06 23:58:11","type":null,"content":null,"parentId":2,"sortNo":null,"uid":null,"name":null,"tenantId":0,"id":3,"value":"国内新闻","createByUser":null,"createDate":"2021-11-06 23:58:11"}, {"stat":null,"updateDate":"2021-11-06 23:58:25","type":null,"content":null,"parentId":2,"sortNo":null,"uid":null,"name":null,"tenantId":0,"id":4,"value":"国际新闻","createByUser":null,"createDate":"2021-11-06 23:58:25"}, {"stat":null,"updateDate":"2021-11-06 23:58:52","type":null,"content":null,"parentId":2,"sortNo":null,"uid":null,"name":null,"tenantId":0,"id":5,"value":"港澳台新闻","createByUser":null,"createDate":"2021-11-06 23:58:52"}, {"stat":null,"updateDate":"2021-11-06 23:57:25","type":null,"content":null,"parentId":1,"sortNo":null,"uid":null,"name":null,"tenantId":0,"id":2,"value":"新闻","createByUser":null,"createDate":"2021-11-06 23:57:25"}]}
转换为(多了 children
,表示下一级的节点集合):
[{
"stat": null,
"updateDate": "2021-11-06 23:58:11",
"type": null,
"content": null,
"parentId": 2,
"sortNo": null,
"uid": null,
"name": null,
"tenantId": 0,
"id": 3,
"value": "国内新闻",
"createByUser": null,
"createDate": "2021-11-06 23:58:11",
"children": [{
"stat": null,
"updateDate": "2022-03-28 15:21:08",
"type": null,
"content": null,
"parentId": 3,
"sortNo": null,
"uid": null,
"name": null,
"tenantId": 0,
"id": 9,
"value": "广东新闻",
"createByUser": null,
"createDate": "2022-03-28 15:21:05",
"children": [{
"stat": null,
"updateDate": "2022-03-28 18:12:55",
"type": null,
"content": null,
"parentId": 9,
"sortNo": null,
"uid": null,
"name": null,
"tenantId": 0,
"id": 14,
"value": "广州新闻",
"createByUser": null,
"createDate": "2022-03-28 18:12:26",
"children": []
}]
}, {
"stat": null,
"updateDate": "2022-03-28 15:21:23",
"type": null,
"content": null,
"parentId": 3,
"sortNo": null,
"uid": null,
"name": null,
"tenantId": 0,
"id": 10,
"value": "湖南新闻",
"createByUser": null,
"createDate": "2022-03-28 15:21:23",
"children": []
}]
}, {
"stat": null,
"updateDate": "2021-11-06 23:58:25",
"type": null,
"content": null,
"parentId": 2,
"sortNo": null,
"uid": null,
"name": null,
"tenantId": 0,
"id": 4,
"value": "国际新闻",
"createByUser": null,
"createDate": "2021-11-06 23:58:25",
"children": [{
"stat": null,
"updateDate": "2022-03-28 15:22:06",
"type": null,
"content": null,
"parentId": 4,
"sortNo": null,
"uid": null,
"name": null,
"tenantId": 0,
"id": 12,
"value": "美国新闻",
"createByUser": null,
"createDate": "2022-03-28 15:22:06",
"children": []
}, {
"stat": null,
"updateDate": "2022-03-28 15:22:14",
"type": null,
"content": null,
"parentId": 4,
"sortNo": null,
"uid": null,
"name": null,
"tenantId": 0,
"id": 13,
"value": "俄罗斯新闻",
"createByUser": null,
"createDate": "2022-03-28 15:22:14",
"children": []
}]
}, {
"stat": null,
"updateDate": "2021-11-06 23:58:52",
"type": null,
"content": null,
"parentId": 2,
"sortNo": null,
"uid": null,
"name": null,
"tenantId": 0,
"id": 5,
"value": "港澳台新闻",
"createByUser": null,
"createDate": "2021-11-06 23:58:52",
"children": [{
"stat": null,
"updateDate": "2022-02-26 21:38:28",
"type": null,
"content": null,
"parentId": 5,
"sortNo": null,
"uid": null,
"name": null,
"tenantId": 0,
"id": 6,
"value": "香港新闻",
"createByUser": null,
"createDate": "2022-02-26 21:38:28",
"children": []
}, {
"stat": null,
"updateDate": "2022-03-28 15:20:43",
"type": null,
"content": null,
"parentId": 5,
"sortNo": null,
"uid": null,
"name": null,
"tenantId": 0,
"id": 7,
"value": "澳门新闻",
"createByUser": null,
"createDate": "2022-03-28 15:20:43",
"children": []
}, {
"stat": null,
"updateDate": "2022-03-28 15:20:55",
"type": null,
"content": null,
"parentId": 5,
"sortNo": null,
"uid": null,
"name": null,
"tenantId": 0,
"id": 8,
"value": "台湾新闻",
"createByUser": null,
"createDate": "2022-03-28 15:20:55",
"children": []
}]
}]
使用递归是一个方法,而且异常简洁、直观:
/**
* 数组转成树
*
* @param list 数组
* @param parentId 顶级父 id
* @returns
*/
function tranListToTreeData(list: TreeMap[], parentId: number): TreeMap[] {
// 先找到所有的根节点
let tranList: TreeMap[] = list.filter((it: TreeMap) => it.parentId === parentId);
// 传入 id list进行递归 在筛选出 他的父级 是一个数组
tranList.forEach((itm: TreeMap) => itm.children = tranListToTreeData(list, itm.id));
return tranList;
}
另外一个方法是这样的,建立一个 Map<id, TreeMap>
,然后相当于建立“链表”连接起来,——这个思路也异常简单,而且比递归的方法少了出栈入栈的消耗,效率更高。
/**
* 数组转成树
*
* @param list 数组
* @returns JSON Tree
*/
function tranListToTreeData(list: TreeMap[]): TreeMap[] {
let treeList: TreeMap[] = [];// 最终要产出的树状数据的数组
let map = {}; // 所有项都使用对象存储起来
list.forEach((item: TreeMap) => { // 建立一个映射关系:通过 id 快速找到对应的元素
if (!item.children)
item.children = [];
map[item.id] = item;
});
list.forEach((item: TreeMap) => {
// 对于每一个元素来说,先找它的上级
// 如果能找到,说明它有上级,则要把它添加到上级的 children 中去
// 如果找不到,说明它没有上级,直接添加到 treeList
let parent = map[item.parentId];
if (parent)// 如果存在则表示 item 不是最顶层的数据
parent.children.push(item);
else
treeList.push(item);// 如果不存在 则是顶层数据
});
return treeList;
}