Mybatis-Plus租户解析的应用

当前不少同学都在做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 

大功告成!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 196,859评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,772评论 2 374
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,956评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,729评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,611评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,441评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,850评论 3 388
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,485评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,770评论 1 293
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,803评论 2 314
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,598评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,434评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,856评论 3 300
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,065评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,365评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,852评论 2 343
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,065评论 2 338

推荐阅读更多精彩内容