在构建多租户系统或需要数据权限控制的应用时,数据隔离是一个关键问题,而解决这一问题的有效方案之一是在项目的数据库访问层实现数据过滤。
本文将介绍如何在Spring Boot项目中利用Mybatis的强大拦截器机制结合JSqlParser(一个功能丰富的 SQL 解析器),来轻松实现数据隔离的目标。
一、工具介绍
1.1 MyBatis拦截器
Mybatis支持在SQL执行的不同阶段拦截并插入自定义逻辑。本文将通过拦截 StatementHandler 接口的 prepare 方法修改SQL语句,实现数据隔离的目的。
1.2 JSqlParser
JSqlParser是一个开源的SQL语句解析工具,它可以对SQL语句进行解析、重构等各种操作:
- 能够将SQL字符串转换成一个可操作的抽象语法树(AST),这使得程序能够理解和操作SQL语句的各个组成部分。
- 根据需求对解析出的AST进行修改,比如添加额外的过滤条件,然后再将AST转换回SQL字符串,实现需求定制化的SQL语句构建。
SELECT语法树简图:
二、详细步骤
2.1 导入依赖
MyBatis依赖:
<dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>3.0.3</version>
</dependency>
JSqlParser依赖:
<dependency><groupId>com.github.jsqlparser</groupId><artifactId>jsqlparser</artifactId><version>4.6</version>
</dependency>
注意:如果项目选择了Mybatis Plus作为数据持久层框架,那么就无需另外添加 Mybatis 和 JSqlParser 的依赖。
Mybatis Plus 自身已经包含了这两项依赖,并且保证了它们之间的兼容性。重复添加这些依赖可能会引起版本冲突,从而干扰项目的稳定性。
2.2 构建测试数据
数据库中执行下述SQL,提前构造好测试数据:
DROP TABLE IF EXISTS `t_user`;
CREATE TABLE `t_user` (`id` INT(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',`name` VARCHAR(10) NOT NULL COMMENT '姓名',`org_id` INT(11) NOT NULL COMMENT '组织ID',`env` VARCHAR(5) NOT NULL COMMENT '环境',PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
INSERT INTO `t_user` (`id`, `name`, `org_id`, `env`) VALUES('1','秦思容','1','local');
INSERT INTO `t_user` (`id`, `name`, `org_id`, `env`) VALUES('2','闪电','1','local');
INSERT INTO `t_user` (`id`, `name`, `org_id`, `env`) VALUES('3','易天行','2','test');
INSERT INTO `t_user` (`id`, `name`, `org_id`, `env`) VALUES('4','易继风','2','test');
INSERT INTO `t_user` (`id`, `name`, `org_id`, `env`) VALUES('5','张君宝','3','prod');
INSERT INTO `t_user` (`id`, `name`, `org_id`, `env`) VALUES('6','张翠山','3','prod');DROP TABLE IF EXISTS `t_organize`;
CREATE TABLE `t_organize` (`org_id` TINYINT(4) NOT NULL AUTO_INCREMENT COMMENT '组织代码',`org_name` VARCHAR(4) NOT NULL COMMENT '组织名称',`env` VARCHAR(5) NOT NULL COMMENT '环境',PRIMARY KEY (`org_id`)
) ENGINE=INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
INSERT INTO `t_organize` (`org_id`, `org_name`, `env`) VALUES('1','逍遥派','local');
INSERT INTO `t_organize` (`org_id`, `org_name`, `env`) VALUES('2','名剑山庄','test');
INSERT INTO `t_organize` (`org_id`, `org_name`, `env`) VALUES('3','武当派','prod');
2.3 构建实体类
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;@Data
@TableName("t_user")
public class User {@TableIdprivate Long id;@TableField(value = "name")private String name;@TableField(value = "org_id")private Integer orgId;
}
import lombok.Data;@Data
public class UserVO {private String name;private String faction;
}
2.4 构建mapper接口
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.entity.User;
import com.example.demo.vo.UserVO;
import org.springframework.stereotype.Repository;import java.util.List;@Repository
public interface UserMapper extends BaseMapper<User> {List<UserVO> queryAll();int insert(User user);int update(User user);int delete(User user);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.mapper.UserMapper"><select id="queryAll" resultType="com.example.demo.vo.UserVO">SELECT name,org_name as faction from t_user t1 left join t_organize t2 on t1.org_id = t2.org_id;</select><insert id="insert" parameterType="com.example.demo.entity.User">insert into t_user(name,org_id) values (#{name},#{orgId})</insert><update id="update" parameterType="com.example.demo.entity.User">update t_user set name = #{name} where id = #{id}</update><delete id="delete" parameterType="com.example.demo.entity.User">delete from t_user where id = #{id}</delete>
</mapper>
2.5 定义一个拦截器
构建一个拦截器,拦截所有的SQL语句并加入 env 条件,如下所示:
import cn.hutool.core.util.StrUtil;
import net.sf.jsqlparser.JSQLParserException;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.RowConstructor;
import net.sf.jsqlparser.expression.StringValue;
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
import net.sf.jsqlparser.expression.operators.relational.ExpressionList;
import net.sf.jsqlparser.expression.operators.relational.ItemsList;
import net.sf.jsqlparser.expression.operators.relational.MultiExpressionList;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.schema.Column;
import net.sf.jsqlparser.schema.Table;
import net.sf.jsqlparser.statement.Statement;
import net.sf.jsqlparser.statement.delete.Delete;
import net.sf.jsqlparser.statement.insert.Insert;
import net.sf.jsqlparser.statement.select.FromItem;
import net.sf.jsqlparser.statement.select.Join;
import net.sf.jsqlparser.statement.select.PlainSelect;
import net.sf.jsqlparser.statement.select.Select;
import net.sf.jsqlparser.statement.select.SelectBody;
import net.sf.jsqlparser.statement.select.SetOperationList;
import net.sf.jsqlparser.statement.select.SubSelect;
import net.sf.jsqlparser.statement.update.Update;
import net.sf.jsqlparser.statement.values.ValuesStatement;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;import java.sql.Connection;
import java.util.ArrayList;
import java.util.List;@Component
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class DataIsolationInterceptor implements Interceptor{/*** 从配置文件中环境变量*/@Value("${spring.profiles.active}")private String env;private String ENV_COLUMN="env";@Overridepublic Object intercept(Invocation invocation) throws Throwable {Object target = invocation.getTarget();//确保只有拦截的目标对象是 StatementHandler 类型时才执行特定逻辑if (target instanceof StatementHandler) {StatementHandler statementHandler = (StatementHandler) target;// 获取 BoundSql 对象,包含原始 SQL 语句BoundSql boundSql = statementHandler.getBoundSql();String originalSql = boundSql.getSql();String newSql = setEnvToStatement(originalSql);// 使用MetaObject对象将新的SQL语句设置到BoundSql对象中MetaObject metaObject = SystemMetaObject.forObject(boundSql);metaObject.setValue("sql", newSql);}// 执行SQLreturn invocation.proceed();}/*** 功能描述:给原始SQL语句动态设置环境变量** @param originalSql 原始SQL语句* @return*/private String setEnvToStatement(String originalSql) {Statement statement;try {statement = CCJSqlParserUtil.parse(originalSql);} catch (JSQLParserException e) {throw new RuntimeException("EnvironmentVariableInterceptor::SQL语句解析异常:"+originalSql);}if (statement instanceof Select) {Select select = (Select) statement;PlainSelect selectBody = select.getSelectBody(PlainSelect.class);if (selectBody.getFromItem() instanceof Table) {Expression newWhereExpression;if (selectBody.getJoins() == null || selectBody.getJoins().isEmpty()) {newWhereExpression = setEnvToWhereExpression(selectBody.getWhere(), null);} else {// 如果是多表关联查询,在关联查询中新增每个表的环境变量条件newWhereExpression = multipleTableJoinWhereExpression(selectBody);}// 将新的where设置到Select中selectBody.setWhere(newWhereExpression);} else if (selectBody.getFromItem() instanceof SubSelect) {// 如果是子查询,在子查询中新增环境变量条件// 当前方法只能处理单层子查询,如果有多层级的子查询的场景需要通过递归设置环境变量SubSelect subSelect = (SubSelect) selectBody.getFromItem();PlainSelect subSelectBody = subSelect.getSelectBody(PlainSelect.class);Expression newWhereExpression = setEnvToWhereExpression(subSelectBody.getWhere(), null);subSelectBody.setWhere(newWhereExpression);}// 获得修改后的语句return select.toString();} else if (statement instanceof Insert) {Insert insert = (Insert) statement;setEnvToInsert(insert);return insert.toString();} else if (statement instanceof Update) {Update update = (Update) statement;Expression newWhereExpression = setEnvToWhereExpression(update.getWhere(),null);// 将新的where设置到Update中update.setWhere(newWhereExpression);return update.toString();} else if (statement instanceof Delete) {Delete delete = (Delete) statement;Expression newWhereExpression = setEnvToWhereExpression(delete.getWhere(),null);// 将新的where设置到delete中delete.setWhere(newWhereExpression);return delete.toString();}return originalSql;}/*** 功能描述:将需要隔离的字段加入到SQL的Where语法树中** @param whereExpression SQL的Where语法树* @param alias 表别名* @return 新的SQL Where语法树*/private Expression setEnvToWhereExpression(Expression whereExpression, String alias) {// 添加SQL语法树的一个where分支,并添加环境变量条件AndExpression andExpression = new AndExpression();EqualsTo envEquals = new EqualsTo();envEquals.setLeftExpression(new Column(StrUtil.isNotBlank(alias) ? String.format("%s.env", alias) : "env"));envEquals.setRightExpression(new StringValue(env));if (whereExpression == null){return envEquals;} else {// 将新的where条件加入到原where条件的右分支树andExpression.setRightExpression(envEquals);andExpression.setLeftExpression(whereExpression);return andExpression;}}/*** 功能描述:多表关联查询时,给关联的所有表加入环境隔离条件** @param selectBody select语法树* @return 新的SQL Where语法树*/private Expression multipleTableJoinWhereExpression(PlainSelect selectBody){Table mainTable = selectBody.getFromItem(Table.class);String mainTableAlias = mainTable.getAlias().getName();// 将 t1.env = ENV 的条件添加到where中Expression newWhereExpression = setEnvToWhereExpression(selectBody.getWhere(), mainTableAlias);List<Join> joins = selectBody.getJoins();for (Join join : joins) {FromItem joinRightItem = join.getRightItem();if (joinRightItem instanceof Table) {Table joinTable = (Table) joinRightItem;String joinTableAlias = joinTable.getAlias().getName();// 将每一个join的 tx.env = ENV 的条件添加到where中newWhereExpression = setEnvToWhereExpression(newWhereExpression, joinTableAlias);}}return newWhereExpression;}/*** 功能描述:新增数据时,插入env字段** @param insert Insert 语法树*/private void setEnvToInsert(Insert insert) {// values中添加环境变量值if (insert.getSelect() != null) {// 如果已经是INSERT...SELECT形式modifyInsertSelect(insert);} else if (insert.getItemsList() != null) {// 如果是INSERT...VALUES形式modifyInsertValues(insert);}}private void modifyInsertSelect(Insert insert) {List<SelectBody> selects = insert.getSelect().getSelectBody(SetOperationList.class).getSelects();for (SelectBody select : selects) {if (select instanceof ValuesStatement){ValuesStatement valuesStatement = (ValuesStatement) select;ExpressionList expressions = (ExpressionList) valuesStatement.getExpressions();List<Expression> values = expressions.getExpressions();for (Expression expression : values){if (expression instanceof RowConstructor) {RowConstructor rowConstructor = (RowConstructor) expression;ExpressionList exprList = rowConstructor.getExprList();exprList.addExpressions(new StringValue(env));}}}}}private void modifyInsertValues(Insert insert) {List<Column> columns = insert.getColumns();ItemsList itemsList = insert.getItemsList();// 如果env列已存在,不做修改if (columns != null && columns.stream().anyMatch(c -> ENV_COLUMN.equalsIgnoreCase(c.getColumnName()))) {return;}// 添加env列if (columns == null) {columns = new ArrayList<>();insert.setColumns(columns);}columns.add(new Column(ENV_COLUMN));// 修改值列表if (itemsList instanceof ExpressionList) {ExpressionList exprList = (ExpressionList) itemsList;exprList.getExpressions().add(new StringValue(env));}else if (itemsList instanceof MultiExpressionList) {MultiExpressionList multiExprList = (MultiExpressionList) itemsList;for (ExpressionList exprList : multiExprList.getExprList()) {exprList.getExpressions().add(new StringValue(env));}}}
}
三、测试
3.1 Select语句
yml配置文件中的spring.profiles.active设置为local,构建测试用例:
import com.example.demo.mapper.UserMapper;
import com.example.demo.vo.UserVO;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;import java.util.List;@SpringBootTest
public class DemoApplicationTests {@Autowiredprivate UserMapper userMapper;@Testpublic void queryAll(){List<UserVO> userList =userMapper.queryAll();userList.forEach(System.out::println);}
}
执行上述代码,其输出结果如下所示:
UserVO(name=秦思容, faction=逍遥派)
UserVO(name=闪电, faction=逍遥派)
刚进入拦截器时,Mybatis 解析的 SQL 语句:
select name,org_name as faction from t_user t1 left join t_organize t2 on t1.org_id = t2.org_id
执行完 setEnvToStatement(originalSql) 方法后,会得到新的 SQL 语句:
select name, org_name AS faction FROM t_user t1 LEFT JOIN t_organize t2 ON t1.org_id = t2.org_id WHERE t1.env = 'local' AND t2.env = 'local'
3.2 Insert语句
yml配置文件中的spring.profiles.active设置为test,构建测试用例:
import com.example.demo.entity.User;
import com.example.demo.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;@SpringBootTest
public class DemoApplicationTests {@Autowiredprivate UserMapper userMapper;@Testpublic void insert(){User user = new User();user.setName("易云");user.setOrgId(2);userMapper.insert(user);}
}
执行上述代码,然后查看数据库中的表数据,可以看到数据插入成功!!!
刚进入拦截器时,Mybatis 解析的 SQL 语句:
insert into t_user(name,org_id) values ('易云',2)
执行完 setEnvToInsert(insert) 方法后,会得到新的 SQL 语句:
insert into t_user(name,org_id,env) values ('易云',2,'test')
3.3 Update语句
yml配置文件中的spring.profiles.active设置为local,构建测试用例:
import com.example.demo.entity.User;
import com.example.demo.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;@SpringBootTest
public class DemoApplicationTests {@Autowiredprivate UserMapper userMapper;@Testpublic void update(){User user =new User();user.setId(1l);user.setName("红娘");userMapper.update(user);}
}
执行上述代码,然后查看数据库中的相关数据,已经被修改成功!!!
刚进入拦截器时,Mybatis 解析的 SQL 语句:
update t_user set name = '红娘' where id = 1
执行完 setWhere(newWhereExpression) 方法后,会得到新的 SQL 语句:
update t_user set name = '红娘' where id = 1 AND env = 'local'
3.4 Delete语句
yml配置文件中的spring.profiles.active设置为test,构建测试用例:
import com.example.demo.entity.User;
import com.example.demo.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;@SpringBootTest
public class DemoApplicationTests {@Autowiredprivate UserMapper userMapper;@Testpublic void delete(){User user =new User();user.setId(7l);userMapper.delete(user);}
}
执行上述代码,然后查看数据库中的相关数据,已经被删除成功!!!
刚进入拦截器时,Mybatis 解析的 SQL 语句:
delete from t_user where id = 7
执行完 setWhere(newWhereExpression) 方法后,会得到新的 SQL 语句:
delete from t_user where id = 7 AND env = 'test'
四、为什么要拦截StatementHandler接口的prepare方法?
可以注意到,在这个例子中定义拦截器时 @Signature 注解中拦截的是 StatementHandler 接口的 prepare 方法,为什么拦截的是 prepare 方法而不是 query 和 update 方法?为什么拦截 query 和 update 方法修改 SQL 语句后仍然执行的是原SQL?
答案:这是因为 SQL 语句是在 prepare 方法中被构建和参数化的。prepare 方法是负责准备 PreparedStatement 对象的,这个对象表示即将要执行的SQL语句。在prepare方法中可以对SQL语句进行修改,而这些修改将会影响最终执行的SQL。而query和update方法是在prepare方法之后被调用的。它们主要的作用是执行已经准备好的 PreparedStatement 对象。在这个阶段,SQL 语句已经被创建并绑定了参数值,所以拦截这两个方法并不能改变已经准备好的 SQL 语句。
简单来说,如果想要修改SQL语句的内容(比如增加where子句、改变排序规则等),那么需要在SQL语句被准备之前进行拦截,即在prepare方法的执行过程中进行。
以下是MyBatis执行过程中的几个关键步骤:
- 解析配置和映射文件: MyBatis启动时,首先加载配置文件和映射文件,解析里面的SQL语句。
- 生成StatementHandler和BoundSql:当执行一个操作,比如查询或更新时,MyBatis会创建一个StatementHandler 对象,并包装了BoundSql对象,后者包含了即将要执行的SQL语句及其参数。
- 执行prepare方法:StatementHandler的prepare方法被调用,完成PreparedStatement的创建和参数设置。
- 执行query或update: 根据执行的是查询操作还是更新操作,MyBatis再调用query或 update方法来实际执行 SQL。
通过在prepare方法进行拦截,我们可以在SQL语句被最终确定之前更改它,从而使修改生效。如果在query或 update方法中进行拦截,则无法更改SQL语句,只能在执行前后进行其它操作,比如日志记录或者结果处理。