前言

权限管理是每个系统不可缺少的一部分,大部分开发者应该都设计过权限管理系统,很多开发者学习的第一个项目可能就是权限管理系统。但是常见的权限设计在租户量非常大、角色数量非常多时会存在角色权限表数据量指数增长的情况,本文介绍一种可以避免这种情况的权限设计思路。

传统权限设计方案

传统的权限系统设计一般有四张表分别为 菜单表、角色表、角色菜单表、用户角色表,我们先按传统权限系统设计一套数据表结构:

菜单表 SYS_MENU

ID

NAME

1

账务管理

2

用户管理

3

订单管理

角色 SYS_ROLE

ID

NAME

1

管理员

2

普通用户

角色菜单 SYS_ROLE_MENU

ID

ROLE_ID

MENU_ID

1

1

1

2

1

2

1

2

2

用户角色 SYS_USER_ ROLE

ID

ROLE_ID

USER_ID

1

1

1

2

2

1

1

2

2

数据指数增长问题

如果我们系统有1万个租户,每个租户有100个角色,每个角色有100个菜单点,则SYS_ROLE_MENU数据量为1亿条数据,这个数据是非常恐怖的。

新的权限设计

角色表 SYS_ROLE

ID

ROLE_NAME

MENU_CODE

1

管理员

1fd4

对角色的菜单点进行编码,我们先构建一个二进制,默认为全0,将对角色拥有的菜单MENU_ID位置为1,如
管理员角色三个菜单权限们,它的的MENU_ID为 [16,10,3],则我们将第16位、第10位、第3位置成1,则二进制编码为(从第0位开始)10000010000001000,我们将此二进制转成36进进制为1fd4,二进制如下图所示

软件单租户架构如何向多租户架构转型 多租户系统权限设计_java

按上面的表设计后,我再看:
如果我们系统有1万个租户,每个租户有100个角色,每个角色有100个菜单点,则SYS_ROLE数据量为100万,比传统的少了100倍

与前端数据交换

用户登录后,前端会调用后端接口获取用户所能访问的菜单权限,比如用户有[16,10,3]菜单权限位,我数据库里存的是36位的编码10g4,传给前端肯定要转成[16,10,3],这里我们利用BigInteger 很易容就可以转成36进制,因为BigInteger 最高进制只能支持36进制,可以自己写个简单的进制转换,转成64进制,这样随着MENU_ID增大,MENU_CODE长度会小很多。

public class MenuCodeConvert{

    /**
     * code 为36进制 String
     * 1fd4 返回 [16,10,3]
     *
     * @param code
     * @return
     */
    public static List<Long> codeToIds(String code) {
        List<Long> ids = new ArrayList<>();
        BigInteger bigInteger = new BigInteger(code, 36);
        for (int i = 0; i < bigInteger.bitLength(); i++) {
            if (bigInteger.testBit(i)) {
                ids.add((long) i);
            }
        }
        return ids;
    }

    /**
     * [16,10,3]  编码 1fd4
     *
     * @param ids
     * @return
     */
    public static String idsToCode(List<Long> ids) {
        if (ids == null && ids.size() == 0) {
            return null;
        }
        BigInteger bigInteger = BigInteger.ZERO;
        for (Long id : ids) {
            bigInteger = bigInteger.setBit(id.intValue());
        }
        return bigInteger.toString(36);
    }

    public static void main(String[] args) {
        List<Long> ids = Arrays.asList(16L,10L,3L );
        System.out.println(idsToCode(ids));
        System.out.println(codeToIds("1fd4"));
    }
    //输出 
    1fd4
    [3, 10, 16]

}

为了 前端<–>后端<–>数据库双向传输过程menuCode编码转换变的更自动简单,我们可以简单封装一下,自定义TypeHandler可以解决此问题,可以参数我之前的文章1
首先创建一个MenuCode 对象

public class MenuCode {
    public MenuCode(List<Long> appMenuIds) {
        this.appMenuIds = appMenuIds;
        this.menuCode = MenuCodeConvert.idsToCode(appMenuIds);
    }
    public MenuCode(String menuCode) {
        this.appMenuIds = MenuCodeConvert.codeToIds(menuCode);
        this.menuCode = menuCode;
    }
    private String menuCode;
    private List<Long> appMenuIds;
    
    public String getMenuCode(){
        return menuCode;
    }

    public void setMenuCode(String menuCode) {
        this.menuCode = menuCode;
        this.appMenuIds = MenuCodeConvert.codeToIds(menuCode);
    }
    public void setAppMenuIds(List<Long> appMenuIds) {
        this.appMenuIds = appMenuIds;
        this.menuCode = MenuCodeConvert.idsToCode(appMenuIds);
    }
}

创建角色表

@Data
@TableName("sys_role")
public class SysRole  {
    @TableId
    private Long id;
    private String roleName;
    private MenuCode menuCode;
}

给MenuCode创建TypeHandler

@MappedTypes({MenuCode.class})
public class MenuCodeHandler extends BaseTypeHandler<MenuCode> {
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, MenuCode parameter, JdbcType jdbcType) throws SQLException {
        ps.setString(i, parameter.getMenuCode());
    }

    @Override
    public MenuCode getNullableResult(ResultSet rs, String columnName) throws SQLException {
        String content = rs.getString(columnName);
        return rs.wasNull() ? null : new MenuCode(content);
    }

    @Override
    public MenuCode getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        String content = rs.getString(columnIndex);
        return rs.wasNull() ? null : new MenuCode(content);
    }

    @Override
    public MenuCode getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        String content = cs.getString(columnIndex);
        return cs.wasNull() ? null : new MenuCode(content);
    }
}

这样我们在调用 SysRole sysRole=sysRoleService.getById(1L);从数据库查出的36进制编码1fd4会自动转成[16,10,3], 返回给前端的数据就是JSON格式为:

{
    "id": 1,
    "name": "管理员",
    "menuCode": [
        16,
        10,
        3
    ]
}

在保存角色权限时前端传的JSON,我们调用sysRoleService.save(sysRole)也会自动将[16,10,3]转成1fd4保存到数据库,这样完成自动转换,根本不用关心中间的菜单权限编码了。

总结

本文介绍一种适用于大量租户大量角色的权限系统设计,解决了系统由于租户数量及角色数据不断增长导致角色权限表成指数增长的问题,并巧妙利用BigInteger 完成二进制和36进制中间的转换,最后利用Mybatis中的自定义TypeHandler解决前端到后端再到数据库菜单编码自动转换的问题。

缺点及未来展望

如果系统中菜单有1000个ID从1-1000,某一个角色只有菜单ID为1000的权限点,那么他的menuCode为4lxcmkxpcdbbom7n3gica9gqteokl39474etuib075x4lhig8dvocg32jwycjwfjzmzfh2ukqnemkxt6xlyq5ze8x7okzf66sgxrzep0m50yirndmhnu9t1ywaycup2k0j6be15l7amfyk29u14alvodnqk6644vt0oldwmm6p082rjyxatszf91qbmhbi1i4g,menuCode会随着最大菜单ID增大而变得非常长,不过可以通过分组来解决,每个分组的菜单ID都是从1开始自增,并将分组ID写到编码前几位。