水煮MyBatis(二五)- 插件案例【全局数据权限】

86 阅读4分钟

前言

从这篇文章开始,之后会以两个插件的例子来深入了解一下mybatis的插件功能。
数据权限的应用场景:在后台管理系统里面,需要针对单个用户来限制其访问的数据范围,让不同的用户看到不同的数据。

分析

从上面的描述,可以看出,这个插件的功能是在程序运行期间,动态给特定的sql添加过滤条件。我们需要调整的是最终的查询语句,也就是BoundSql这个对象。回顾上一章介绍的四个接口,不难分析出需要拦截的是StatementHandler的prepare方法。

  Statement prepare(Connection connection, Integer transactionTimeout)
      throws SQLException;

在这个方法里,允许开发者对查询参数、超时时间、返回数据条数等进行动态调整。

思路

需要考虑下面几个情况:

  • 只需要处理select语句;
  • 不是所有的select语句都需要被拦截,比如只需要过滤帖子表数据,那么用户表的查询应该被忽略;
  • 过滤条件的写入,有多个场景需要区分处理;

过滤条件

  1. 表名之后带where条件的:select * from tb_test1 where id = 3
  2. 表名之后没有带where条件,select * from tb_test1
  3. 表名之后没有where条件,带分号的,select * from tb_test1 ;
  4. 表名之后没有where条件,带分号,且分号与表名之间没有空格的,select * from tb_test1;
  5. 表名之后是别名的:select * from tb_test1 tp
  6. 表名之后是别名,之后再是where的:select * from tb_test1 tp where id = 3
  7. 表名之后是别名,之后再是order,group,limit之类的:select * from tb_test1 tp order by id desc
  8. 被查询的表名之前,有出现与表名同名字符串的:select *,a_id as tb_test1 from tb_test1

至于join查询,union查询,这里就不一一列举了。

核心代码

核心代码处理拦截的方法里[intercept],在需要在执行具体sql之前,动态的修改过滤条件:addAccessCondition(invocation);
将最终修改好的sql语句,更新到【MetaObject】实例的"delegate.boundSql.sql"属性里面,我们这个插件的工作就完成了。

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 添加数据过滤的查询条件
        addAccessCondition(invocation);
        // 执行sql
        return invocation.proceed();
    }

    /**
     * 添加数据过滤的查询条件
     *
     * @param invocation 拦截参数
     */
    private void addAccessCondition(Invocation invocation) {
        StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
        MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
        MappedStatement ms = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
        // 如果是非查询操作,或者是需要忽略的查询操作,直接返回
        if (SqlCommandType.SELECT != ms.getSqlCommandType()) {
            return;
        }
        // 原始sql
        String originalSql = (String) metaObject.getValue("delegate.boundSql.sql");
        // 判定是否需要进行数据过滤
        if (!isNeedFilter(originalSql)) {
            return;
        }
        // 拼接最终sql
        String finalSql = insertDataScope(originalSql);
        // 将修改过的sql写入当前上下文
        metaObject.setValue("delegate.boundSql.sql", finalSql);
    }

判断是否需要进行拦截

看一下这段代码,根据配置在DATA_ACCESS_LIST集合里的数据,判断是否对查询语句进行拦截


    /**
     * 需要进行数据过滤的表,以及数据过滤关键字段
     */
    private static final Map<String, String> DATA_ACCESS_LIST = new HashMap<String, String>() {{
        put("tb_test1", "t_id");
        put("tb_test2", "t_id");
    }};
    
    public static boolean isNeedFilter(String originalSql) {
        for (String table : DATA_ACCESS_LIST.keySet()) {
            if (originalSql.contains(table)) {
                return true;
            }
        }
        return false;
    }

动态添加查询条件

在SQL中插入数据权限的查询条件的核心逻辑:

  • 根据空格来切分原始sql,逐个分析拆分出来的字符串;
  • 如果有where,则直接替换: where name = "zhangsan" 替换成 where t_id in (1,2,3) and name = "zhangsan"
  • 如果没有where,则添加查询条件:where t_id in (1,2,3)

添加的条件里,t_id是根据DATA_ACCESS_LIST集合的配置,根据特定的表名来获取。比如("tb_test3", "a_id"),tb_test3里需要进行过滤的字段是a_id,那么在tb_test3里,动态添加的语句就是:where a_id in (1,2,3)

    /**
     * 在SQL中插入数据权限的查询条件
     */
    public static String insertDataScope(String sql) {
        // 替换掉分号,因为在sql语法中,分号与sql主体之间允许无空格
        sql = sql.replaceAll(";", "");
        String[] sqlSplits = sql.split(" ");
        List<String> sqlItems = new ArrayList<>(Arrays.asList(sqlSplits));
        // 强制性以分号结尾
        sqlItems.add(";");
//        String dataScope = dataScope();
        // 拦截范围,id in (1,2,3)
        String dataScope = "(1,2,3)";
        Set<String> tables = DATA_ACCESS_LIST.keySet();
        for (String table : tables) {
            // 遍历所有需要数据过滤的表
            if (!sqlItems.contains(table)) {
                continue;
            }
            // 判定是否需要进行数据过滤,返回目标id的查询字段
            String targetId = DATA_ACCESS_LIST.get(table);
            // 数据权限查询条件
            String condition = String.format(" WHERE %s in %s ", targetId, dataScope);
            String conditionAnd = condition + " and ";
            int size = sqlItems.size();
            // 不需要处理第一个元素,循环从1开始;默认应该从0开始
            for (int i = 1; i < size; i++) {
                String item = sqlItems.get(i);
                String before = sqlItems.get(i - 1);
                if (!(item.equals(table)
                        && before.equalsIgnoreCase("from"))) {
                    continue;
                }
                // 判断表是否有别名
                int nextIndex_1 = i + 1;
                // 如果当前item是最后一个,则直接添加查询条件
                if (size == nextIndex_1) {
                    continue;
                }
                String next = sqlItems.get(nextIndex_1);
                String next_1 = next.toLowerCase();
                // 如果后面接的where条件
                if (next_1.equals("where")) {
                    sqlItems.set(nextIndex_1, conditionAnd);
                } else if (next_1.equals(";")) {
                    sqlItems.set(nextIndex_1, condition + ";");
                } else {
                    // 表名后面接的非where和;,有可能是别名或者order,limit之类,需要区分处理
                    int nextIndex_2 = i + 2;
                    boolean isAlias = !NOT_ALIAS.contains(next_1);
                    // 如果后面第二个元素是别名
                    if (isAlias) {
                        String next_2 = sqlItems.get(nextIndex_2).toLowerCase();
                        // 如果别名后面接的where条件
                        if (next_2.equals("where")) {
                            sqlItems.set(nextIndex_2, conditionAnd);
                        } else if (next_2.equals(";")) {
                            sqlItems.set(nextIndex_2, condition + ";");
                        } else {
                            // order,limit之类
                            sqlItems.set(nextIndex_1, next + " " + condition);
                        }
                    } else {
                        log.error("数据权限,sql中的未知元素,item:[" + next_1 + "],SQL:" + sql);
                    }
                }
            }
        }
        return String.join(" ", sqlItems);
    }

如果获取用户的数据范围

在上个段落里,频繁出现的查询条件:where t_id in (1,2,3),后面in查询的就是用户的数据范围。
一般在用户登录的时候,查询到配置表中,用户可以看到的id列表,放到用户登录上下文里,在查询时使用。