持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第3天,点击查看活动详情
背景
公司菜单项权限在前端判断,由后端返回权限给前端;菜单项也是由前端写死,不方便更改。同时后端权限在jsp判断获取,多个if/else的结构,难以维护。同时将权限返回给前端也有权限暴露的风险,且前端判断权限影响用户体验。
菜单项的特点:
- 读多写少;
- 权限粒度较细;
方案设计
方案迭代:
-
前后端逻辑分离,菜单项配置化,配置父子级,由接口判断每个菜单项权限,返回列表给前端;
-
梳理权限。利用位掩码判断权限,由接口返回;
-
cache菜单项或权限,减少重复判断;
-
缓存权限,会多次计算菜单项的权限,返回列表。但好处是存储的数据少。<uid,mask> 保存即可;
- 用户权限或者菜单项权限更新时,删除缓存;
- 菜单项新增/更新时,删除所有缓存;
- 缓存权限占用内存小,一个用户最多32位即4字节。
-
缓存菜单项,不用多次计算。缺点是占用内存会较多。不过由于菜单项并不多,即使用户量多,其缓存数据也不会很多。(需计算比较下)
- 用户权限或者菜单项权限更新时,删除缓存;
- 菜单项新增/更新时,删除所有缓存;
-
优化 1.0
基于位掩码实现权限判断
结构设计
菜单项实体定义
@Data
@ToString
@AllArgsConstructor
@NoArgsConstructor
public class Menu {
/**
* 菜单项的唯一标识
*/
private String key;
/**
* 菜单名
*/
private String name;
/**
* 权限的位掩码
*/
private int authMask;
/**
* 子菜单列表
*/
private List<Menu> subList;
}
用户定义
@Data
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class User {
/**
* 用户id:唯一标识
*/
private int id;
/**
* 用户名
*/
private String name;
/**
* 是否超管
*/
private boolean adm;
/**
* 是否为试用
*/
private boolean trial;
/**
* 等级
*/
private int level;
/**
* 等级常量
*/
public static final class Level {
/**
* 青铜
*/
public static final int Q_LEVEL = 1;
/**
* 黄金
*/
public static final int G_LEVEL = 2;
}
}
菜单项数据格式设计(展示部分):
[
{
"key": "account",
"name": "帐号管理",
"authMask": 18,
"subList": [
{
"key": "info",
"name": "帐号信息",
"authMask": 2,
"subList": []
},
{
"key": "staff",
"name": "员工管理",
"authMask": 18,
"subList": []
},
{
"key": "safe",
"name": "帐号安全",
"authMask": 18,
"subList": []
}
]
},
...
]
各字段的意义表示为:
- key:菜单项的唯一标识;
- name:菜单项的名称显示;
- authMask:菜单项权限的位掩码;
- subList:子级菜单
设计步骤:
-
先判断用户权限,计算用户的权限位掩码。
mask = hasAuthForX() ? mask | x : mask; -
遍历菜单项,匹配判断mask是否包含当前菜单的权限。
menuList = []; // 返回的菜单列表 for(Menu menu : allMenuList) { if(mask & menu.authMask = menu.authMask) { // 当用户权限包含菜单权限时,满足条件 menuList.add(menu); } } -
由于菜单项是父子结构,还需要判断子菜单的权限是否满足,然后添加子菜单。在前面的逻辑补充,需要递归调用菜单项。
public List<Menu> getMenuList(List<Menu> menuList,int userMask) { if(menuList == null || menuList.isEmpty()) { return new ArrayList<>(); } List<Menu> resultList = new ArrayList<>(); for(Menu menu : menuList) { if((userMask & menu.getAuthMask()) == menu.getAuthMask()) { resultList.add(new Menu(menu.getKey(),menu.getName(),0,getMenuList(menu.getSubList(),userMask))); } } return resultList; }
注:父权限一定是子级菜单的最小权限。因为要先能访问到父级菜单,才有机会访问到子级菜单。
优化 2.0
由于一个菜单的权限可能是由多个与权限 和 或权限组成的,那么按照上面的方式去处理是不行的,因为上面的处理方式只包括了与权限的判断。与权限即是指要满足所有权限才允许访问。
举个例子说明下:
例如帐号管理--帐号安全这个子菜单的权限,如果当权限为非试用&青铜等级 或者非试用&超管时都可以满足条件,那么按照上面的方式去判断就会导致超管反而访问不了。
伪代码如下:
menuList = []
if((!trial && level == Q_Level) || (!trial && isAdm)) { // 假设没有isAdm的条件判断,那么超管则访问不了
menuList.add(menu);
}
为此,可以将 Menu类中的authMask字段改为列表,表示当前菜单的权限是列表中的任一权限,即只要用户的位掩码满足其中一个即可。伪代码如下:
menuList = []; // 返回的菜单列表
for(Menu menu : allMenuList) {
for(int menuMask : menu.authMasks) {
if((mask & menuMask =menuMask) ) {
menuList.add(menu);
// 递归调用...
}
}
}
代码实现
- 菜单项
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Menu {
/**
* 菜单项的唯一id
*/
private String key;
/**
* 菜单名
*/
private String name;
/**
* 权限的位掩码列表
*/
private List<Integer> authMasks;
/**
* 子菜单列表
*/
private List<Menu> subList;
@Override
public String toString() {
return "{" +
"\"key\":\"" + key + '\"' +
", \"name\":\"" + name + '\"' +
", \"subList\":" + subList +
'}';
}
}
- 菜单权限的帮助类
/**
* 菜单权限的帮助类
* 用来计算用户权限的位掩码 以及 获取用户的菜单项
*/
public class MenuAuthHelper {
/**
* 计算用户权限的位掩码
* @param user
* @return
*/
public static int calUserAuthMask(User user) {
int mask = 0;
/**
* 超管权限判断
*/
mask = user.isAdm() ? (mask | AuthConstant.ADM) : mask;
/**
* 试用权限判断
*/
mask = !user.isTrial() ? (mask | AuthConstant.NOT_TRIAL) : mask;
/**
* 青铜
*/
mask = user.getLevel() == User.Level.Q_LEVEL ? (mask | AuthConstant.Q_VIP) : mask;
/**
* 黄金
*/
mask = user.getLevel() == User.Level.G_LEVEL ? (mask | AuthConstant.G_VIP) : mask;
/**
* 内部用户
*/
mask = UserHelper.isInsideUser(user) ? (mask | AuthConstant.INSIDE_USER) : mask;
return mask;
}
/**
* 返回用户菜单列表
* @param user
* @return
*/
public static List<Menu> getUserMenuList(User user) {
List<Menu> resultList = new ArrayList<>();
List<Menu> menuList = loadMenuList();
if(menuList == null) {
return resultList;
}
// 计算用户的权限位掩码
int userMask = calUserAuthMask(user);
resultList = getMenuList(menuList,userMask);
return resultList;
}
/**
* 递归调用获取菜单
* @param menuList
* @param userMask
* @return
*/
private static List<Menu> getMenuList(List<Menu> menuList,int userMask) {
if(menuList == null || menuList.isEmpty()) {
return new ArrayList<>();
}
List<Menu> resultList = new ArrayList<>();
for(Menu menu : menuList) {
for(int menuMask : menu.getAuthMasks()) {
if((userMask & menuMask) == menuMask) {
resultList.add(new Menu(menu.getKey(),menu.getName(),null,getMenuList(menu.getSubList(),userMask)));
break; // 有一个条件满足即可
}
}
}
return resultList;
}
/**
* 加载所有菜单项
* @return
*/
public static List<Menu> loadMenuList() {
String fileName = "E:\\code\\local\\MenuDemo\\menuList.json";
try {
Stream<String> lines = Files.lines(Paths.get(fileName));
StringBuilder sb = new StringBuilder();
lines.forEach(sb::append);
return JSON.parseObject(sb.toString(), new TypeReference<List<Menu>>(){});
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}
最后
从位掩码的方案中,接口把用户权限和菜单项权限的判断拆开。从一开始的多个权限 if/else分支判断,到现在统一计算,代码相对来说整洁些。同时,后续如果有新加权限或者菜单项,只需要修改配置文件即可,不需要再新增一分支。
该方案也是有缺点的,比如菜单项的掩码不直观,修改/新增较麻烦等。不过这种问题也是很好处理的,在提供给内部人员看的页面里,后台将掩码还原计算出来即可。添加/修改时同理。
此外,在代码上还能进一步做优化。比如菜单项或者用户权限缓存化,减少频繁计算。不过在菜单权限或者用户权限有变动时,需要注意如何将缓存更新,而这就需要从业务上做考虑了。也可以简单处理,将缓存的时效降低,设定一个可接受的时间范围,毕竟菜单项一般改动较少。