前言
从这篇文章开始,之后会以两个插件的例子来深入了解一下mybatis的插件功能。
数据权限的应用场景:在后台管理系统里面,需要针对单个用户来限制其访问的数据范围,让不同的用户看到不同的数据。
分析
从上面的描述,可以看出,这个插件的功能是在程序运行期间,动态给特定的sql添加过滤条件。我们需要调整的是最终的查询语句,也就是BoundSql这个对象。回顾上一章介绍的四个接口,不难分析出需要拦截的是StatementHandler的prepare方法。
Statement prepare(Connection connection, Integer transactionTimeout)
throws SQLException;
在这个方法里,允许开发者对查询参数、超时时间、返回数据条数等进行动态调整。
思路
需要考虑下面几个情况:
- 只需要处理select语句;
- 不是所有的select语句都需要被拦截,比如只需要过滤帖子表数据,那么用户表的查询应该被忽略;
- 过滤条件的写入,有多个场景需要区分处理;
过滤条件
- 表名之后带where条件的:
select * from tb_test1 where id = 3
- 表名之后没有带where条件,
select * from tb_test1
; - 表名之后没有where条件,带分号的,
select * from tb_test1 ;
- 表名之后没有where条件,带分号,且分号与表名之间没有空格的,
select * from tb_test1;
- 表名之后是别名的:
select * from tb_test1 tp
- 表名之后是别名,之后再是where的:
select * from tb_test1 tp where id = 3
- 表名之后是别名,之后再是order,group,limit之类的:
select * from tb_test1 tp order by id desc
- 被查询的表名之前,有出现与表名同名字符串的:
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列表,放到用户登录上下文里,在查询时使用。