当前不少同学都在做SAAS平台,SAAS平台中各租户的数据一般在同一张表中。
如何优美的处理各租户数据的数据权限是一个很大的问题。比如,A租户在平台只能增删改查id为A的数据,而不能处理租户B的数据,否则就乱套了。
而在平台中,带有租户ID字段的表可谓很多,我上一个项目中几百张表八成冗余了租户ID字段
个人在原先的公司都是以手动设置租户ID的形式对后台数据进行租户的限定,一般为前端传入ID参数,或者从当前会话中取出租户ID,再手动设置到sql 的where 条件中。
在使用Mybatis-Plus插件中,发现了其一个租户解析器的功能,研究一波拿来使用一下
官方配置示例
下面为其官方使用例子,该示例展示如何配置租户sql解析
package com.baomidou.mybatisplus.samples.tenant.config;
import java.util.ArrayList;
import java.util.List;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.baomidou.mybatisplus.core.parser.ISqlParser;
import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor;
import com.baomidou.mybatisplus.extension.plugins.PerformanceInterceptor;
import com.baomidou.mybatisplus.extension.plugins.tenant.TenantHandler;
import com.baomidou.mybatisplus.extension.plugins.tenant.TenantSqlParser;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
/**
* @author miemie
* @since 2018-08-10
*/
@Configuration
@MapperScan("com.baomidou.mybatisplus.samples.tenant.mapper")
public class MybatisPlusConfig {
/**
* 多租户属于 SQL 解析部分,依赖 MP 分页插件
*/
@Bean
public PaginationInterceptor paginationInterceptor() {
PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
/*
* 【测试多租户】 SQL 解析处理拦截器<br>
* 这里固定写成住户 1 实际情况你可以从cookie读取,因此数据看不到 【 麻花藤 】 这条记录( 注意观察 SQL )<br>
*/
List<ISqlParser> sqlParserList = new ArrayList<>();
TenantSqlParser tenantSqlParser = new TenantSqlParser();
tenantSqlParser.setTenantHandler(new TenantHandler() {
@Override
public Expression getTenantId() {
return new LongValue(1L);
}
@Override
public String getTenantIdColumn() {
return "tenant_id";
}
@Override
public boolean doTableFilter(String tableName) {
// 这里可以判断是否过滤表
/*if ("user".equals(tableName)) {
return true;
}*/
return false;
}
});
sqlParserList.add(tenantSqlParser);
paginationInterceptor.setSqlParserList(sqlParserList);
// paginationInterceptor.setSqlParserFilter(new ISqlParserFilter() {
// @Override
// public boolean doFilter(MetaObject metaObject) {
// MappedStatement ms = PluginUtils.getMappedStatement(metaObject);
// // 过滤自定义查询此时无租户信息约束【 麻花藤 】出现
// if ("com.baomidou.springboot.mapper.UserMapper.selectListBySQL".equals(ms.getId())) {
// return true;
// }
// return false;
// }
// });
return paginationInterceptor;
}
/**
* 性能分析拦截器,不建议生产使用
* 用来观察 SQL 执行情况及执行时长
*/
@Bean
public PerformanceInterceptor performanceInterceptor(){
return new PerformanceInterceptor();
}
}
注意其中关键的两个类,建议去看一下源码
TenantSqlParser
TenantHandler
其中TenantSqlParser
需要一个 TenantHandler
接口成员,
根据TenantHandler
接口的 getTenantId()
getTenantIdColumn()
doTableFilter(String tableName)
三个方法分别配置租户ID的获取,租户ID的字段名,以及是否过滤该表
问题
看起来近乎完美,配置好上面三项配置,我们CRUD完全不需要手动传租户ID参数,省了一大堆事情。然鹅,问题来了:
配置:我的租户ID从当前登录会话中取,根据租户用户不同,获取不同的租户ID实现了数据权限的控制,
栗子:我是系统管理员,我不仅需要查看租户A的数据,也需要查看租户B的数据,而且我不是租户没有租户ID。
继续引用以上配置,sql将解析成类似于where tenant_id = null
的语句,无法查询到租户AB的数据
处理思路
我需要在某个地方做判断,如果是系统管理员时,我不希望做这个租户sql解析,不需要限定 tenant_id 的条件;当我是租户用户是,我需要做这个限定
如果自己实现一个拦截去做上面的判断,颇为啰嗦,我注意到了
TenantHandler
接口的doTableFilter(String tableName)
方法。这个方法可以根据表名判断是否做租户sql解析,我们参照此实现做拓展即可
实现根据用户判断是否进行租户sql解析
- 自定义接口
MyTenantHandler
继承TenantHandler
新增doUserFilter(ContextInfo userInfo)
方法,注意ContextInfo 当前用户会话信息,具体实现请查阅此博客中对ContextInfo及拦截使用的方式
import com.baomidou.mybatisplus.extension.plugins.tenant.TenantHandler;
import com.zh.backend.common.dto.ContextInfo;
/**
* @author wangqichang
* @since 2019/6/6
*/
public interface MyTenantHandler extends TenantHandler {
boolean doUserFilter(ContextInfo userInfo);
}
- 自定义
MyTenantSqlParser
继承AbstractJsqlParser
参考 原来的实现类TenantSqlParser
,加入对当前用户的判断
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.parser.AbstractJsqlParser;
import com.baomidou.mybatisplus.core.toolkit.Assert;
import com.baomidou.mybatisplus.core.toolkit.ExceptionUtils;
import com.baomidou.mybatisplus.core.toolkit.StringPool;
import com.baomidou.mybatisplus.extension.plugins.tenant.TenantHandler;
import com.zh.backend.common.context.CustomContext;
import com.zh.backend.common.dto.ContextInfo;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import net.sf.jsqlparser.expression.BinaryExpression;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.Parenthesis;
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
import net.sf.jsqlparser.expression.operators.conditional.OrExpression;
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.schema.Column;
import net.sf.jsqlparser.schema.Table;
import net.sf.jsqlparser.statement.delete.Delete;
import net.sf.jsqlparser.statement.insert.Insert;
import net.sf.jsqlparser.statement.select.*;
import net.sf.jsqlparser.statement.update.Update;
import java.util.List;
/**
* @author wangqichang
* @since 2019/6/6
*/
@Data
@Accessors(chain = true)
@EqualsAndHashCode(callSuper = true)
public class MyTenantSqlParser extends AbstractJsqlParser {
private MyTenantHandler tenantHandler;
/**
* select 语句处理
*/
@Override
public void processSelectBody(SelectBody selectBody) {
if (tenantHandler.doUserFilter(CustomContext.current())){
//对系统用户不过滤
return;
}
if (selectBody instanceof PlainSelect) {
processPlainSelect((PlainSelect) selectBody);
} else if (selectBody instanceof WithItem) {
WithItem withItem = (WithItem) selectBody;
if (withItem.getSelectBody() != null) {
processSelectBody(withItem.getSelectBody());
}
} else {
SetOperationList operationList = (SetOperationList) selectBody;
if (operationList.getSelects() != null && operationList.getSelects().size() > 0) {
operationList.getSelects().forEach(this::processSelectBody);
}
}
}
/**
* <p>
* insert 语句处理
* </p>
*/
@Override
public void processInsert(Insert insert) {
if (tenantHandler.doTableFilter(insert.getTable().getName())) {
// 过滤退出执行
return;
}
if (tenantHandler.doUserFilter(CustomContext.current())){
//对系统用户不过滤
return;
}
if (tenantHandler.doUserFilter(CustomContext.current())) {
ContextInfo userInfo = CustomContext.current();
// 过滤退出执行
if (ObjectUtil.isNotNull(userInfo)&&ObjectUtil.isNotNull(userInfo.getTentId())){
return;
}
return;
}
insert.getColumns().add(new Column(tenantHandler.getTenantIdColumn()));
if (insert.getSelect() != null) {
processPlainSelect((PlainSelect) insert.getSelect().getSelectBody(), true);
} else if (insert.getItemsList() != null) {
// fixed github pull/295
ItemsList itemsList = insert.getItemsList();
if (itemsList instanceof MultiExpressionList) {
((MultiExpressionList) itemsList).getExprList().forEach(el -> el.getExpressions().add(tenantHandler.getTenantId()));
} else {
((ExpressionList) insert.getItemsList()).getExpressions().add(tenantHandler.getTenantId());
}
} else {
throw ExceptionUtils.mpe("Failed to process multiple-table update, please exclude the tableName or statementId");
}
}
/**
* <p>
* update 语句处理
* </p>
*/
@Override
public void processUpdate(Update update) {
if (tenantHandler.doUserFilter(CustomContext.current())){
//对系统用户不过滤
return;
}
List<Table> tableList = update.getTables();
Assert.isTrue(null != tableList && tableList.size() < 2,
"Failed to process multiple-table update, please exclude the statementId");
Table table = tableList.get(0);
if (tenantHandler.doTableFilter(table.getName())) {
// 过滤退出执行
return;
}
update.setWhere(this.andExpression(table, update.getWhere()));
}
/**
* <p>
* delete 语句处理
* </p>
*/
@Override
public void processDelete(Delete delete) {
if (tenantHandler.doUserFilter(CustomContext.current())){
//对系统用户不过滤
return;
}
if (tenantHandler.doTableFilter(delete.getTable().getName())) {
// 过滤退出执行
return;
}
delete.setWhere(this.andExpression(delete.getTable(), delete.getWhere()));
}
/**
* <p>
* delete update 语句 where 处理
* </p>
*/
protected BinaryExpression andExpression(Table table, Expression where) {
//获得where条件表达式
EqualsTo equalsTo = new EqualsTo();
equalsTo.setLeftExpression(this.getAliasColumn(table));
equalsTo.setRightExpression(tenantHandler.getTenantId());
if (null != where) {
if (where instanceof OrExpression) {
return new AndExpression(equalsTo, new Parenthesis(where));
} else {
return new AndExpression(equalsTo, where);
}
}
return equalsTo;
}
/**
* <p>
* 处理 PlainSelect
* </p>
*/
protected void processPlainSelect(PlainSelect plainSelect) {
processPlainSelect(plainSelect, false);
}
/**
* <p>
* 处理 PlainSelect
* </p>
*
* @param plainSelect
* @param addColumn 是否添加租户列,insert into select语句中需要
*/
protected void processPlainSelect(PlainSelect plainSelect, boolean addColumn) {
FromItem fromItem = plainSelect.getFromItem();
if (fromItem instanceof Table) {
Table fromTable = (Table) fromItem;
if (tenantHandler.doTableFilter(fromTable.getName())) {
// 过滤退出执行
return;
}
plainSelect.setWhere(builderExpression(plainSelect.getWhere(), fromTable));
if (addColumn) {
plainSelect.getSelectItems().add(new SelectExpressionItem(new Column(tenantHandler.getTenantIdColumn())));
}
} else {
processFromItem(fromItem);
}
List<Join> joins = plainSelect.getJoins();
if (joins != null && joins.size() > 0) {
joins.forEach(j -> {
processJoin(j);
processFromItem(j.getRightItem());
});
}
}
/**
* 处理子查询等
*/
protected void processFromItem(FromItem fromItem) {
if (fromItem instanceof SubJoin) {
SubJoin subJoin = (SubJoin) fromItem;
if (subJoin.getJoinList() != null) {
subJoin.getJoinList().forEach(this::processJoin);
}
if (subJoin.getLeft() != null) {
processFromItem(subJoin.getLeft());
}
} else if (fromItem instanceof SubSelect) {
SubSelect subSelect = (SubSelect) fromItem;
if (subSelect.getSelectBody() != null) {
processSelectBody(subSelect.getSelectBody());
}
} else if (fromItem instanceof ValuesList) {
logger.debug("Perform a subquery, if you do not give us feedback");
} else if (fromItem instanceof LateralSubSelect) {
LateralSubSelect lateralSubSelect = (LateralSubSelect) fromItem;
if (lateralSubSelect.getSubSelect() != null) {
SubSelect subSelect = lateralSubSelect.getSubSelect();
if (subSelect.getSelectBody() != null) {
processSelectBody(subSelect.getSelectBody());
}
}
}
}
/**
* 处理联接语句
*/
protected void processJoin(Join join) {
if (join.getRightItem() instanceof Table) {
Table fromTable = (Table) join.getRightItem();
if (this.tenantHandler.doTableFilter(fromTable.getName())) {
// 过滤退出执行
return;
}
join.setOnExpression(builderExpression(join.getOnExpression(), fromTable));
}
}
/**
* 处理条件
*/
protected Expression builderExpression(Expression expression, Table table) {
//生成字段名
EqualsTo equalsTo = new EqualsTo();
equalsTo.setLeftExpression(this.getAliasColumn(table));
equalsTo.setRightExpression(tenantHandler.getTenantId());
//加入判断防止条件为空时生成 "and null" 导致查询结果为空
if (expression == null) {
return equalsTo;
} else {
if (expression instanceof BinaryExpression) {
BinaryExpression binaryExpression = (BinaryExpression) expression;
if (binaryExpression.getLeftExpression() instanceof FromItem) {
processFromItem((FromItem) binaryExpression.getLeftExpression());
}
if (binaryExpression.getRightExpression() instanceof FromItem) {
processFromItem((FromItem) binaryExpression.getRightExpression());
}
}
if (expression instanceof OrExpression) {
return new AndExpression(equalsTo, new Parenthesis(expression));
} else {
return new AndExpression(equalsTo, expression);
}
}
}
/**
* <p>
* 租户字段别名设置<br>
* tableName.tenantId 或 tableAlias.tenantId
* </p>
*
* @param table 表对象
* @return 字段
*/
protected Column getAliasColumn(Table table) {
StringBuilder column = new StringBuilder();
if (null == table.getAlias()) {
column.append(table.getName());
} else {
column.append(table.getAlias().getName());
}
column.append(StringPool.DOT);
column.append(tenantHandler.getTenantIdColumn());
return new Column(column.toString());
}
}
- 修改Mybatis-Plus的配置,替换原先实现
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.parser.ISqlParser;
import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor;
import com.zh.backend.common.context.CustomContext;
import com.zh.backend.common.dto.ContextInfo;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* 向MP注入多租户解析器,其中自定义MyTenantSqlParser和MyTenantHandler对MP的租户处理逻辑进行继承拓展
* 处理器将会判断当前用户是否为租户用户再决定是否过滤不做租户解析
* 过滤处理的表格将通过配置注入进来
*
* @author wangqichang
* @since 2019/6/10
*/
@Configuration
public class BaseTenantSqlParserConfig {
@Value("${tenant.tentTables}")
private String tentTables;
/**
* 多租户属于 SQL 解析部分,依赖 MP 分页插件
*/
@Bean
public PaginationInterceptor paginationInterceptor() {
PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
/*
* 【测试多租户】 SQL 解析处理拦截器<br>
* 这里固定写成住户 1 实际情况你可以从cookie读取,因此数据看不到 【 麻花藤 】 这条记录( 注意观察 SQL )<br>
*/
List<ISqlParser> sqlParserList = new ArrayList<>();
MyTenantSqlParser tenantSqlParser = new MyTenantSqlParser();
tenantSqlParser.setTenantHandler(new MyTenantHandler() {
@Override
public boolean doUserFilter(ContextInfo userInfo) {
/**
* 这个参数是当前线程变量中的用户信息
* 当用户信息没有租户ID(超管或者未登录),即不过滤该sql
*/
if (ObjectUtil.isNotNull(userInfo) && ObjectUtil.isNotNull(userInfo.getTentId())) {
return false;
}
return true;
}
@Override
public Expression getTenantId() {
/**
* sql解析时,租户ID参数从会话线程中取出
*/
if (ObjectUtil.isNotNull(CustomContext.current()) && ObjectUtil.isNotNull(CustomContext.current().getTentId())) {
return new LongValue(CustomContext.current().getTentId());
}
return null;
}
/**
* 数据库各表中,租户ID字段名
* @return
*/
@Override
public String getTenantIdColumn() {
return "tent_id";
}
@Override
public boolean doTableFilter(String tableName) {
/**
* 这里可以判断是否过滤表
* 表名根据实际去配置,凡是不带tent_id的表均应该配置,否则sql会报找不到tent_id这个字段
*/
if (ObjectUtil.isNotNull(tentTables)) {
List<String> tables = Arrays.asList(tentTables.split(","));
if (CollUtil.isNotEmpty(tables) && tables.contains(tableName)) {
return false;
}
}
return true;
}
});
sqlParserList.add(tenantSqlParser);
paginationInterceptor.setSqlParserList(sqlParserList);
return paginationInterceptor;
}
}
效果展示
- 当登录超管用户进行查询,sql中没有限定租户ID
[2019-06-14 09:25:08,963] [INFO ] [http-nio-8906-exec-7] jdbc.sqltiming 370 SELECT id, name, full_name, create_time, create_id, dept_id, password, tent_id, type, lock,
work_id FROM sys_user
- 当使用租户用户进行查询,sql中限定了租户ID
[2019-06-14 09:26:35,446] [INFO ] [http-nio-8906-exec-10] jdbc.sqltiming 370 SELECT id, name, full_name, create_time, create_id, dept_id, password, tent_id, type, lock,
work_id FROM sys_user WHERE sys_user.tent_id = 8
大功告成!