背景
最近项目做了一个多层级配置开关的需求,觉得这样的需求挺普遍,设计也挺有借鉴意义,所以记录下。 因为这样的配置开关挺普遍的,所以以微信的配置作为业务背景举例,声明下我并不知道微信这块具体是怎么做的,只是单纯觉得可以这么做,也未参与过微信开发。
具体场景

如图,可以看到微信的配置开关,这代表一种配置功能的开启和关闭,点击进聊天界面的新消息通知这块配置,又能看到下面有二级开关,分别是声音和振动。

点击进通知管理这块,又有允许通知和静默通知这两个二级开关

系统设计
用户配置表的定义
我们知道每个账号下关于这块的配置是不同的,但是配置项是固定的,就是这么几个配置项,因此所以每个account与配置的关系是一对一关系,为此可以建立一张表,每行数据表示一个account对应的所有配置开关,那么有这么多个配置开关,如何定义表示呢?
答案是可以用一个long型的字段setting表示,我们知道long类型有64位,则最多可以表示64个开关,可以假设接受新消息通知(accept new notice)占据第1位,接收语音和视频通话邀请提醒(accpet voice and video invitation notice)占据第二位,通知显示消息详情(notice show message detail)占据第三位,聊天页面的新消息通知(new message notice in chat)占据第四位,巴拉巴拉,还是画个图简单明了。

配置开关占据的对应的比特位的关系可以保存在代码中。
首先这张表存储了accountId和setting的关系,每条数据表示了一个accountId下的setting是怎样子的,哪些开哪些关。我们来看下表结构和代码如何定义。表结构定义如下:
CREATE TABLE setting_config (
id BIGINT AUTO_INCREMENT PRIMARY KEY, -- 唯一标识符
account_id BIGINT NOT NULL, -- 账户ID
setting BIGINT NOT NULL, -- 用于存储配置的bitmap,0表示关,1表示开
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL -- 更新时间
);
此时第一张表已经建好,表示的是每个账号account_id对应的配置开关setting具体是打开还是关闭,这张表名为setting_config表,作为账户配置表。
Bitmap中位的对应关系
虽然有了这么一张表,但我们并不知道setting字段的值具体是对应哪个权限,举个例子,假设有一条数据,account_id = 1,setting = 011101,setting字段的值有6位,从右到左每一位分别表示哪个配置我们并不知道。 前面提过,配置开关的对应的比特位的关系可以保存在代码中。这也是使用Bitmap这种设计方式的用法了,
public class Setting {
private static final int MOMENTS = 1 << 0; // 朋友圈
private static final int MOMENTS_DISCOVER = 1 << 1; // 朋友圈的发现
private static final int MOMENTS_REMIDN = 1 << 2; // 朋友圈的提醒
private static final int CHANNELS = 1 << 3; // 视频号
private static final int CHANNELS_DISCOVER = 1 << 4; // 视频号的发现
private static final int CHANNELS_REMIDN = 1 << 5; // 视频号的提醒
private int configFlags = 0; // 初始配置状态为0
public void enableMoments() {
configFlags |= MOMENTS; // 设置朋友圈为开启状态
}
public void enableMomentsDiscover() {
configFlags |= MOMENTS_DISCOVER; // 设置 朋友圈的发现 为开启状态
}
public void enableMomentsRemind() {
configFlags |= MOMENTS_REMIDN; // 设置 朋友圈的提醒 为开启状态
}
public boolean isConfigAEnabled() {
return (configFlags & CONFIG_FLAG_A) != 0; // 查询配置A的开启状态
}
public boolean isConfigBEnabled() {
return (configFlags & CONFIG_FLAG_B) != 0; // 查询配置B的开启状态
}
public boolean isConfigCEnabled() {
return (configFlags & CONFIG_FLAG_C) != 0; // 查询配置C的开启状态
}
}
开关的层级关系
此时还有第三个问题,那就是开关的层级关系,在第一张表中,所有开关都是在相同的字段配置,它们之间是平级的比如声音和振动这两个开关属于二级开关,处于聊天页面的新消息通知这个一级开关下面。这种层级关系必须有所体现。此时的做法是可以抽象成一棵多叉树,每个父节点下可以有多个子节点。

因为一个节点可以有多个子节点但却只有一个父节点,所以可以设计一张表,将每个节点包括其父节点存进去,,此时这表中每条数据都代表一个节点,一个节点代表一个配置项,有多少个配置项就有多少个节点,有多少个节点就有多少条数据,但每条数据都需要有个字段,表示它的父节点是哪个,其中像voice和vibration这两个二级节点的父节点是new message notice in chat,但像new message notice in chat这样的一级节点是没有父节点的,它的父节点是为null。这个父节点可以存放每个节点数据的id,反正是需要唯一标志节点数据的就行,所以第二张表也已经建好,这张表名为node表。 给出node表的DDL:
CREATE TABLE `node`
(
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '节点ID',
`parent_id` int(11) NOT NULL DEFAULT '0' COMMENT '父节点ID',
`title` varchar(32) NOT NULL DEFAULT '' COMMENT '节点标题',
PRIMARY KEY (`id`),
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='节点信息表';
先说下我们希望的最终返回结果,我们希望返回一种清晰的开关配置结果给前端,让前端更简单得处理,格式是这样

返回一个数组,其中key代表的是哪个节点,enable代表这个节点是开还是关,sub代表这个节点有多少个子节点,我把json给出来,有兴趣可以复制出来看看是怎样
[
{
"key": "e1",
"enable": true,
"subs": [
{
"key": "es1",
"enable": true,
"subs": [
{
"key": "es11",
"enable": true
}
]
},
{
"key": "e12",
"enable": true
}
]
},
{
"key": "e2",
"enable": true,
"subs": [
{
"key": "es2",
"enable": true,
"subs": [
{
"key": "es21",
"enable": true
}
]
},
{
"key": "e22",
"enable": true
}
]
}
]
给出这样的json后,可以先思考下怎么根据设计好的两张表来写代码构造出这样的json。
在这里先提出两个问题:
(1)我们如何构造出类似上面json的返回结果给前端用来展示一个account下的配置开关是怎样的。即list接口要怎么写?
(2)如果我们修改开关,把开关打开或关闭,要怎么做?即update接口要怎么写?
这时学到的数据结构和算法就派上用场了。
解决方式:
(1)对于第一个问题,我们可以用构建多叉树这个算法来写出我们想要的返回结果的代码。我们将node表的数据全部读取出来,然后用读取的数据去构建一棵二叉树。具体代码可以先想想。
具体代码如下:
// TreeNode.java
@Data
public class TreeNode {
private String id;
private String parentId;
private List<TreeNode> children;
public TreeNode(String id, String parentId) {
this.id = id;
this.parentId = parentId;
this.children = new ArrayList<>();
}
}
//TreeUtils.java
public class TreeUtils {
// 将数据库查询出来的节点数据进行组装构建成多叉树结构
public static List<TreeNode> transformToTreeFormat(List<TreeNode> nodes) {
// List<TreeNode> nodes是从数据库查询出来的所有节点数据
if (nodes == null || nodes.isEmpty()) {
return Collections.emptyList();
}
Map<String, TreeNode> idNodeMap = new HashMap<>();
for (TreeNode node : nodes) {
idNodeMap.put(node.getId(), node);
}
// rootNodes用于保存所有父节点
List<TreeNode> rootNodes = new ArrayList<>();
for (TreeNode node : nodes) {
// 得到节点对应的父节点
TreeNode parentNode = idNodeMap.get(node.getParentId());
if(parentNode != null && !node.getId().equals(node.getParentId())){
if (parentNode.getChildren() == null) {
parentNode.setChildren(new ArrayList<>());
}
parentNode.getChildren().add(node);
}else{
rootNodes.add(node);
}
}
return rootNodes;
}
}
以上代码是根据数据库查询数据来构建出树状结构返回结果
(2)入参是传过来一个节点,我们需要校验这个节点是否是真的节点,因为要记住,参数是可以伪造的, 如果构造的参数里一个二级开关属于其他一级开关那这样很明显是错的,所以我们需要用到多叉树遍历算法来校验参数的层级关系是否正确。因为节点之间的关系已经存储在数据库,所以当我们要验证前端传来的数据是否是正确的树状结构时,可以从数据库读取出正确的树结构数据与前端传来的数据比较,简单点说就是验证两棵树是否是相同的树
(3)update接口传参会是一个树节点,由此需要对其进行树的遍历打平成多个树节点。