位运算的运用之菜单权限的判断

326 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第3天,点击查看活动详情

背景

公司菜单项权限在前端判断,由后端返回权限给前端;菜单项也是由前端写死,不方便更改。同时后端权限在jsp判断获取,多个if/else的结构,难以维护。同时将权限返回给前端也有权限暴露的风险,且前端判断权限影响用户体验。

菜单项的特点:

  • 读多写少;
  • 权限粒度较细;

方案设计

方案迭代:

  1. 前后端逻辑分离,菜单项配置化,配置父子级,由接口判断每个菜单项权限,返回列表给前端;

  2. 梳理权限。利用位掩码判断权限,由接口返回;

  3. 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:子级菜单

设计步骤:
  1. 先判断用户权限,计算用户的权限位掩码。

    mask = hasAuthForX() ? mask | x : mask;

  2. 遍历菜单项,匹配判断mask是否包含当前菜单的权限。

    menuList = []; // 返回的菜单列表
    for(Menu menu : allMenuList) {
        if(mask & menu.authMask = menu.authMask) {	// 当用户权限包含菜单权限时,满足条件
    		menuList.add(menu);
        }
    }
    
  3. 由于菜单项是父子结构,还需要判断子菜单的权限是否满足,然后添加子菜单。在前面的逻辑补充,需要递归调用菜单项。

    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分支判断,到现在统一计算,代码相对来说整洁些。同时,后续如果有新加权限或者菜单项,只需要修改配置文件即可,不需要再新增一分支。

​ 该方案也是有缺点的,比如菜单项的掩码不直观,修改/新增较麻烦等。不过这种问题也是很好处理的,在提供给内部人员看的页面里,后台将掩码还原计算出来即可。添加/修改时同理。

​ 此外,在代码上还能进一步做优化。比如菜单项或者用户权限缓存化,减少频繁计算。不过在菜单权限或者用户权限有变动时,需要注意如何将缓存更新,而这就需要从业务上做考虑了。也可以简单处理,将缓存的时效降低,设定一个可接受的时间范围,毕竟菜单项一般改动较少。