背景
为了在共库共表的情况下实现行级数据权限控制,唯一的方法就是修改 SQL 语句,增加权限字段条件。
而在修改 SQL 时,由于 SELECT
选择的表源可能是多层嵌套的,且选择项中可能不存在权限字段,所以单纯的 SELECT * FROM ( … ) WHERE permission_condition
并不能解决所有情况。
而使用 SQL 抽象语法树遍历,在限制访问表的 SQL 对应层级添加 WHERE
权限条件,及在可能存在的 GROUP BY
语句中添加权限字段项的,即可在不修改原有数据访问 SQL 及业务代码的情况下实现行级数据权限控制。
可以通过开发 MyBatis 插件对准备执行的 SQL 进行拦截,在拦截中修改 SQL,再执行修改后的 SQL。
由于 MyBatis 插件开发的资料比较多,这里就不再赘述;这里只叙述如何通过使用 Druid SQL Parser 对 SQL 进行遍历,从而可以对现有的涉及要限制访问表的 SQL 添加权限字段,达到数据行级权限控制。
预备知识
Druid 抽象语法树知识
Druid 抽象语法树知识:Druid_SQL_AST
这篇文档中介绍了我们实现遍历抽象语法树修改 SQL 所必需的知识:
- Druid SQL AST 有哪些主要节点,增删改查语句分别对应什么节点
- 什么 SQL token 会被解析成什么节点类
Druid 抽象语法树遍历器
Druid 抽象语法树遍历器示例:SQL_Parser_Demo_visitor
Oracle 版本示例遍历器:
public static class ExportTableAliasVisitor extends OracleASTVisitorAdapter {
private Map<String, SQLTableSource> aliasMap = new HashMap<String, SQLTableSource>();
public boolean visit(OracleSelectTableReference x) {
String alias = x.getAlias();
aliasMap.put(alias, x);
return true;
}
public Map<String, SQLTableSource> getAliasMap() {
return aliasMap;
}
}
使用遍历器:
finnal String dbType = JdbcConstants.ORACLE; // JdbcConstants.MYSQL或者JdbcConstants.POSTGRESQL
String sql = "select * from mytable a where a.id = 3";
List<SQLStatement> stmtList = SQLUtils.parseStatements(sql, dbType);
ExportTableAliasVisitor visitor = new ExportTableAliasVisitor();
for (SQLStatement stmt : stmtList) {
stmt.accept(visitor);
}
SQLTableSource tableSource = visitor.getAliasMap().get("a");
System.out.println(tableSource);
留意示例中一个很重要的地方:stmt.accept()
调用了 ExportTableAliasVisitor.visit()
方法进行遍历,且 ExportTableAliasVisitor.visit()
方法接受的参数是 OracleSelectTableReference
,而 stmt.accept()
调用时没有明显涉及 OracleSelectTableReference
的实例。
以下进行 Druid 抽象语法树遍历机制的源码解析,源码查看可以通过在 IDE 中 Ctrl + Click 点击源文件中的类名打开对应源码。
从 stmt.accept()
入手,使用 System.out.println(stmt.getClass())
可得其实际实现类为:SQLSelectStatement
public class SQLSelectStatement extends SQLStatementImpl {
protected SQLSelect select;
protected void accept0(SQLASTVisitor visitor) {
if (visitor.visit(this)) {
acceptChild(visitor, this.select);
}
visitor.endVisit(this);
}
}
虽然没找到 accept()
但是找到了个 accept0()
,记住这个方法,然后继续追溯 SQLSelectStatement
继承的基类来找 accept()
方法。
public abstract class SQLStatementImpl extends SQLObjectImpl implements SQLStatement {
public final void accept(SQLASTVisitor visitor) {
if (visitor == null) {
throw new IllegalArgumentException();
}
visitor.preVisit(this);
accept0(visitor);
visitor.postVisit(this);
}
protected abstract void accept0(SQLASTVisitor visitor);
protected final void acceptChild(SQLASTVisitor visitor, SQLObject child) {
if (child == null) {
return;
}
child.accept(visitor);
}
}
分析其调用关系,SQLSelectStatement.accept()
调用的实际上是 SQLObjectImpl.accept()
。SQLObjectImpl.accept()
中进行了预处理 visitor.preVisit(this)
和后处理 visitor.postVisit(this)
,实际执行业务逻辑的是 accept0(visitor)
。
分析 SQLObjectImpl.accept0()
可知,其为一个抽象方法,而在这条分析链中,刚好有这个方法的实现方法 SQLSelectStatement.accept0()
,这就是我们开头要记住的方法。
在 SQLSelectStatement.accept0()
中,才真正第一次调用到 visitor.visit(this)
,也就是样例实现的 visit()
同名方法,但入参还是和 OracleSelectTableReference
类型不符。但是我们可以看到,如果 visit()
方法返回的布尔值为真,则会将对该类型节点的子节点进行递归遍历 visit()
调用(参考其他 SQLObjectImpl
的具体实现类可实证)。也就是说 visit()
返回的布尔值,实际上是控制是否对该节点的子节点进行递归。
要解答为何参数不同仍进行遍历这个问题,就要分析 ExportTableAliasVisitor
继承的基类 OracleASTVisitorAdapter
。在 OracleASTVisitorAdapter
中,一切问题的答案都将连起来:
public class OracleASTVisitorAdapter extends SQLASTVisitorAdapter implements OracleASTVisitor {
// ...
@Override
public boolean visit(OracleAnalyticWindowing x) {
return true;
}
@Override
public boolean visit(OracleDbLinkExpr x) {
return true;
}
@Override
public boolean visit(OracleDeleteStatement x) {
return true;
}
@Override
public boolean visit(OracleIntervalExpr x) {
return true;
}
@Override
public boolean visit(OracleOuterExpr x) {
return true;
}
// ...
}
可以看见,OracleASTVisitorAdapter
中对每个 SQLObjectImpl
的派生类及其派生类的派生类等等,进行了完全了列举和实现。至此我们可以隐约感觉到,这与方法的重载与多态有关。现在我们已经具备问题答案的所有线索了,让我们重新分析一遍:
-
SQLSelectStatement.accept()
调用了SQLObjectImpl.accept()
-
SQLObjectImpl.accept()
调用了SQLSelectStatement.accept0()
-
SQLSelectStatement.accept0()
调用了SQLStatementImpl.appendChild()
对其子节点SQLSelect
进行了(递归)遍历 - 而遍历到
OracleSelectTableReference
子节点,则是因为虽然ExportTableAliasVisitor
中没有实现对应参数的visit()
,但其继承的基类OracleASTVisitorAdapter
中有对应参数方法的实现,这是函数的多态。而又因为OracleASTVisitorAdapter
的每个visit()
实现方法默认返回true
即默认递归遍历,这样一层又一层的递归遍历及多态调用,最终抵达了ExportTableAliasVisitor.visit(OracleSelectTableReference x)
。
至此分析完毕,故我们分析出我们要在哪个节点终止递归,并进行分析修改即可。Druid 用层次状态机解决这复杂的抽象语法树遍历问题,以免去手动列举处理所有情况组合,真的高。
实现
public class RowDataAccessControlVisitor extends OracleASTVisitorAdapter {
/* 权限字段 */
private String targetFieldName;
/* 限制条件 */
private String condition;
/* 限制访问的表名 */
private String[] targetTables;
/* 数据库类型 */
private final String dbType = JdbcConstants.ORACLE;
// Insert 语句建议在业务逻辑代码中限制
// Select 语句无需实现,使用基类的实现方法即可
public boolean visit(OracleUpdateStatement updateStatement) {
SQLTableSource tableSource = updateStatement.getFrom();
if (tableSource != null) {
String alias = tableSource.getAlias();
if (tableSource instanceof SQLExprTableSource && isTargetTableSource((SQLExprTableSource)tableSource, targetTables))
updateStatement.addCondition(SQLUtils.toSQLExpr(formCondition(alias), dbType));
}
return true;
}
public boolean visit(OracleDeleteStatement deleteStatement) {
SQLTableSource tableSource = deleteStatement.getFrom();
if (tableSource != null) {
String alias = tableSource.getAlias();
if (tableSource instanceof SQLExprTableSource && isTargetTableSource((SQLExprTableSource)tableSource, targetTables))
deleteStatement.addCondition(SQLUtils.toSQLExpr(formCondition(alias), dbType));
}
return true;
}
// 出口状态
public boolean visit(OracleSelectTableReference selectTableReferenece) {
/* 符合目标表源名 */
if (isTargetTableSource(selectTableReferenece, targetTables)) {
SQLObject parent = selectTableReferenece.getParent();
String alias = selectTableReferenece.getAlias();
/* 回溯到选择语句 */
while (!(parent instanceof OracleSelectQueryBlock) && parent != null)
parent = parent.getParent();
/* 插入行控制条件 */
if (parent != null)
((OracleSelectQueryBlock)parent).addCondition(formCondition(alias));
}
return false;
}
/* 判断是否与目标表名一致 */
private boolean isTargetTableSource(SQLExprTableSource tableSource, String[] targetTables) {
for (String target : targetTables)
if (tableSource.getName().getSimpleName().toString().equalsIgnoreCase(target))
return true;
return false;
}
/* 别名条件 */
private String getAliasFieldName (String alias) {
return alias == null ? targetFieldName : alias + "." + targetFieldName;
}
/* 组成条件 */
private String formCondition (String alias) {
return alias == null ? targetFieldName + " " + condition :
alias + "." + targetFieldName + " " + condition;
}
/* 省略 Getter/Setter */
// ...
}