数据权限的设计与实现系列3——MybatisPlus数据权限插件实现机制及使用示例

背景

上篇我们深度剖析了若依开发平台关于数据权限的设计与实现,并指出了其模式的局限性。
今天我们来看一下MyBatisPlus提供的数据权限插件的实现机制与使用示例。
官方说明:https://baomidou.com/plugins/data-permission/

实现机制

在 SQL 执行前拦截 SQL 语句,并根据用户权限动态添加权限相关的 SQL 片段。这样,只有用户有权限访问的数据才会被查询出来。
拦截SQL语句是基于拦截器技术实现的,修改SQL语句是基于开源的 SQL 解析库JSQLParser完成的

JSQLParser的解析功能示例如下:

// 示例 SQL
String sql = "SELECT * FROM user WHERE status = 'active'";
Expression expression;

try {
    expression = CCJSqlParserUtil.parseCondExpression("status = 'inactive'");
    PlainSelect select = (PlainSelect) ((Select) CCJSqlParserUtil.parse(sql)).getSelectBody();
    select.setWhere(expression);

    System.out.println(select); // 输出:SELECT * FROM user WHERE status = 'inactive'
} catch (JSQLParserException e) {
    e.printStackTrace();
}

使用方式

1.实现数据权限逻辑

官方提供了一个MultiDataPermissionHandler类,需要继承该类,覆写getSqlSegment方法,自行处理数据权限逻辑,也就是需要追加的数据权限过滤的SQL片段。

public class CustomDataPermissionHandler extends MultiDataPermissionHandler {
    @Override
    public Expression getSqlSegment(Table table, Expression where, String mappedStatementId) {
        // 在此处编写自定义数据权限逻辑
        try {
            String sqlSegment = "..."; // 数据权限相关的 SQL 片段
            return CCJSqlParserUtil.parseCondExpression(sqlSegment);
        } catch (JSQLParserException e) {
            e.printStackTrace();
            return null;
        }
    }
}

2.注册数据权限拦截器

修改MybatisPlus拦截器的配置类,将数据权限插件加入进去,并且注意需要放在分页插件之前。

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 1.添加数据权限插件
        interceptor.addInnerInterceptor(new DataPermissionInterceptor(new CustomDataPermissionHandler ()));
        // 2.添加分页插件
        PaginationInnerInterceptor pageInterceptor = new PaginationInnerInterceptor();   
        interceptor.addInnerInterceptor(pageInterceptor);
        return interceptor;
    }

实际上,MybatisPlus的数据权限插件,只提供了一个框架,并不是完整实现,核心的数据权限逻辑部分,需要自行实现。

数据权限逻辑的实现

打算找个具体的例子来补全拼图,去网上搜了大量资料,最终找到了这么一篇相对完整的示例。
https://blog.csdn.net/qq_42402854/article/details/139099661
整体实现思路与上篇提到的若依开发平台类似,但也存在一些差异,做了一些改进,下面具体来说说。

定义数据权限范围

同样设定了5种数据范围,使用枚举类型定义,相比若依平台的5个零散的字符串常量,更优雅一些。

缓存数据权限

若依开发平台是在业务实体的基类的params属性中,通过一个约定键名为dataScope来存放需要追加的SQL片段。
本示例中则是扩展了权限控制组件SpringSecurity的用户对象UserDetails,增加了数据权限的属性,即在用户登录,系统认证完成后,查询当前用户所拥有的角色,进而查询角色对应的数据权限范围,然后存到用户对象中备用。

定义数据权限注解

与若依平台实现,有所差异和改进。
一方面,使用一个公用属性tableAlias来处理部门表和用户表别名问题,而不是若依开发平台中的两个,意味着某些场景下复杂SQL语句如果同时涉及到部门表和用户表,则无法控制。不过是否真的存在这种场景存疑,主要是逻辑处理中是或关系,同时设置了部门维度和用户维度,实质上也会造成数据范围的扩大吧。
另一方面,若依平台只考虑了表别名问题,字段名则是硬编码到处理逻辑中去了,隐性要求业务表中部门id必须是dept_id,用户id必须是user_id。本示例中则考虑了该问题,进行了改进,使用deptScopeName来定义部门字段名,oneselfScopeName来定义用户字段名。

实现数据权限逻辑

接下来就是最关键的部分,也就是MybatisPlus数据权限插件中要求的自定义权限逻辑处理器的实现,源码如下:

/**
 * 数据权限拼装逻辑处理
 *
 */
public class DataScopeHandler implements MultiDataPermissionHandler {

    /**
     * 获取数据权限 SQL 片段。
     * <p>旧的 {@link MultiDataPermissionHandler#getSqlSegment(Expression, String)} 方法第一个参数包含所有的 where 条件信息,如果 return 了 null 会覆盖原有的 where 数据,</p>

     * <p>新版的 {@link MultiDataPermissionHandler#getSqlSegment(Table, Expression, String)} 方法不能覆盖原有的 where 数据,如果 return 了 null 则表示不追加任何 where 条件</p>

     *
     * @param table             所执行的数据库表信息,可以通过此参数获取表名和表别名
     * @param where             原有的 where 条件信息
     * @param mappedStatementId Mybatis MappedStatement Id 根据该参数可以判断具体执行方法
     * @return JSqlParser 条件表达式,返回的条件表达式会拼接在原有的表达式后面(不会覆盖原有的表达式)
     */
    @Override
    public Expression getSqlSegment(Table table, Expression where, String mappedStatementId) {
        try {
            Class<?> mapperClazz = Class.forName(mappedStatementId.substring(0, mappedStatementId.lastIndexOf(".")));
            String methodName = mappedStatementId.substring(mappedStatementId.lastIndexOf(".") + 1);

            /**
             * DataScope注解优先级:【类上 > 方法上】
             */
            // 获取 DataScope注解
            DataScope dataScopeAnnotationClazz = mapperClazz.getAnnotation(DataScope.class);
            if (ObjectUtils.isNotEmpty(dataScopeAnnotationClazz) && dataScopeAnnotationClazz.enabled()) {
                return buildDataScopeByAnnotation(dataScopeAnnotationClazz);
            }
            // 获取自身类中的所有方法,不包括继承。与访问权限无关
            Method[] methods = mapperClazz.getDeclaredMethods();
            for (Method method : methods) {
                DataScope dataScopeAnnotationMethod = method.getAnnotation(DataScope.class);
                if (ObjectUtils.isEmpty(dataScopeAnnotationMethod) || !dataScopeAnnotationMethod.enabled()) {
                    continue;
                }
                if (method.getName().equals(methodName) || (method.getName() + "_COUNT").equals(methodName) || (method.getName() + "_count").equals(methodName)) {
                    return buildDataScopeByAnnotation(dataScopeAnnotationMethod);
                }
            }
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        return null;
    }

    /**
     * DataScope注解方式,拼装数据权限
     *
     * @param dataScope
     * @return
     */
    private Expression buildDataScopeByAnnotation(DataScope dataScope) {
        // 获取 UserDetails用户信息
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null) {
            return null;
        }
        Map<String, Object> userDetailsMap = BeanUtil.beanToMap(authentication.getPrincipal());
        Set<String> dataScopeTypes = (Set<String>) userDetailsMap.get("dataScopeTypes");
        Set<Long> dataScopeDeptIds = (Set<Long>) userDetailsMap.get("dataScopeDeptIds");
        Long dataScopeCreateId = (Long) userDetailsMap.get("dataScopeCreateId");

        // 获取注解信息
        String tableAlias = dataScope.tableAlias();
        String deptScopeName = dataScope.deptScopeName();
        String oneselfScopeName = dataScope.oneselfScopeName();
        Expression expression = buildDataScopeExpression(tableAlias, deptScopeName, oneselfScopeName, dataScopeDeptIds, dataScopeCreateId);
        return expression == null ? null : new Parenthesis(expression);
    }

    /**
     * 拼装数据权限
     *
     * @param tableAlias        表别名
     * @param deptScopeName     部门限制范围的字段名称
     * @param oneselfScopeName  本人限制范围的字段名称
     * @param dataScopeDeptIds  数据权限部门ID集合,去重
     * @param dataScopeCreateId 数据权限本人ID
     * @return
     */
    private Expression buildDataScopeExpression(String tableAlias, String deptScopeName, String oneselfScopeName, Set<Long> dataScopeDeptIds, Long dataScopeCreateId) {
        /**
         * 构造部门in表达式。
         */
        InExpression deptIdInExpression = null;
        if (CollectionUtils.isNotEmpty(dataScopeDeptIds)) {
            deptIdInExpression = new InExpression();
            ExpressionList deptIds = new ExpressionList(dataScopeDeptIds.stream().map(LongValue::new).collect(Collectors.toList()));
            // 设置左边的字段表达式,右边设置值。
            deptIdInExpression.setLeftExpression(buildColumn(tableAlias, deptScopeName));
            deptIdInExpression.setRightExpression(new Parenthesis(deptIds));
        }

        /**
         * 构造本人eq表达式
         */
        EqualsTo oneselfEqualsTo = null;
        if (dataScopeCreateId != null) {
            oneselfEqualsTo = new EqualsTo();
            oneselfEqualsTo.withLeftExpression(buildColumn(tableAlias, oneselfScopeName));
            oneselfEqualsTo.setRightExpression(new LongValue(dataScopeCreateId));
        }

        if (deptIdInExpression != null && oneselfEqualsTo != null) {
            return new OrExpression(deptIdInExpression, oneselfEqualsTo);
        } else if (deptIdInExpression != null && oneselfEqualsTo == null) {
            return deptIdInExpression;
        } else if (deptIdInExpression == null && oneselfEqualsTo != null) {
            return oneselfEqualsTo;
        }
        return null;
    }

    /**
     * 构建Column
     *
     * @param tableAlias 表别名
     * @param columnName 字段名称
     * @return 带表别名字段
     */
    public static Column buildColumn(String tableAlias, String columnName) {
        if (StringUtils.isNotEmpty(tableAlias)) {
            columnName = tableAlias + "." + columnName;
        }
        return new Column(columnName);
    }
}

代码中的注释写的比较详细,这里不再赘述,有几个关键点提一下:
1.对于getSqlSegment方法的入参,具体什么含义,怎么用,MybatisPlus官网其实没提,而示例给了详细的注释和说明,这一点挺好。特别是第三个参数mappedStatementId,可以根据该参数使用反射,拿到执行的类和方法,以及其上的注解。
2.因为提前做了预处理(在登录环节将当前用户的数据权限查询出来放到用户对象中),使用的时候大大简化了处理逻辑。
3.该逻辑实际存在瑕疵,问题出在部门表和用户表别名公用一个属性,实际存在冲突,要完善其实也简单,拆开就好了,不麻烦。

总结

本文介绍了MybatisPlus的数据权限插件的机制和使用,并通过一个具体示例补全了关键环节数据权限控制逻辑的实现。
与上篇若依开发平台对比,主要区别在于技术实现方式不同。
若依开发平台是自行拼接SQL片段,MybatisPlus的数据权限插件是基于拦截器机制,在执行SQL前解析和修改SQL。
但对于数据权限的控制维度和实现思路,都是一致的,基于部门维度,控制点放在角色上,因此角色爆炸问题和权限扩大问题,也都存在。

开源平台资料

平台名称:一二三开发平台
简介: 企业级通用开发平台
设计资料:csdn专栏
开源地址:Gitee
开源协议:MIT
如果您在阅读本文时获得了帮助或受到了启发,希望您能够喜欢并收藏这篇文章,为它点赞~
请在评论区与我分享您的想法和心得,一起交流学习,不断进步,遇见更加优秀的自己!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

学海无涯,行者无疆

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值