Springboot + SpringSecurity + mybatis-plus 多租户SaaS方案(共享数据库表)

欢迎大家去我的个人网站踩踩 点这里哦

 

一、前言

 

前面曾经写过一篇Springboot项目实现多租户的方案,当时用的是每个租户独立数据库,通过切换数据源的方式来实现,看这篇Springboot项目使用动态切换数据源实现多租户SaaS方案,这篇我们说一下,方案三通过共享数据库,共享数据库表,使用字段来区分不同租户,此方案数据隔离性差,但是成本最低。

 

二、实现思路

 

其实同一数据库表,要实现多租户,思路如下:

1、数据库表通过增加一个租户id字段(tenant_id)来区分不同的数据;

2、用户在登录后要将用户所属租户id保存在当前登录用户信息中;

3、用户访问接口时,获取当前用户的租户id;

4、在所有需要区分租户的数据库操作sql 语句 where 条件都加上 and tenant_id = xxxx ;

 

这样就只会操作自己所属租户的数据,只是如果我们手动在每个sql后都自己加上租户条件太繁琐了,所以我们可以通过拦截器实现。

用过Mybatis我们知道,对于拦截器Mybatis为我们提供了一个Interceptor接口,可以实现拦截sql语句,可能我们之前已经用过分页拦截器来实现分页的功能,具体拦截器的用法这里就不多说了,可以自己查一下,了解一下。

 

三、mybatis-plus多租户拦截器

 

我们这里直接选用 mybatis-plus ,它已经为我们实现了多租户的拦截器,我们看一下具体用法:

 

(一)数据库增加区分字段

首先为每张表(所有需要区分租户的表)增加一个 tenant_id 字段,用来区分租户,我们通过查询所有表拼接出添加语句,这样可以一次性为所有表添加字段

 

SELECT
	concat( 'ALTER TABLE ', table_schema, '.', table_name, ' ADD COLUMN tenant_id varchar(100) NULL;' ) 
FROM
	information_schema.TABLES t
WHERE
	table_schema = '当前数据库';

 

(二)配置 mybatis-plus

 

这里使用 mybatis-plus 3.2.0 ,多租户的实现是在分页拦截器里的,配置如下:

 

package cn.mukanyun.config;

import com.baomidou.mybatisplus.core.parser.ISqlParser;
import com.baomidou.mybatisplus.core.parser.ISqlParserFilter;
import com.baomidou.mybatisplus.core.parser.SqlParserHelper;
import com.baomidou.mybatisplus.extension.exceptions.ApiException;
import com.baomidou.mybatisplus.extension.parsers.BlockAttackSqlParser;
import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor;
import com.baomidou.mybatisplus.extension.plugins.tenant.TenantHandler;
import com.baomidou.mybatisplus.extension.plugins.tenant.TenantSqlParser;
import cn.mukanyun.core.tenant.TenantInfoHolder;
import lombok.extern.slf4j.Slf4j;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.StringValue;
import org.apache.commons.lang3.StringUtils;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.reflection.MetaObject;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import java.util.ArrayList;
import java.util.List;

/**
 * @Author: guomh
 * @Date: 2019/11/06
 * @Description: mybatis配置*
 */
@Slf4j
@EnableTransactionManagement
@Configuration
@MapperScan({"cn.mukanyun.base.dao","cn.mukanyun.*.*.mapper"})
public class MybatisPlusConfig {

	
    /**
     * 加载分页插件
     * @return
     */
    @Bean
    public PaginationInterceptor paginationInterceptor() {
        PaginationInterceptor paginationInterceptor = new PaginationInterceptor();

        List<ISqlParser> sqlParserList = new ArrayList<>();
        
        TenantSqlParser tenantSqlParser = new TenantSqlParser();
        tenantSqlParser.setTenantHandler(new TenantHandler() {
            @Override
            public Expression getTenantId(boolean where) {
                // 该 where 条件 3.2.0 版本开始添加的,用于分区是否为在 where 条件中使用
            	String currentUserTenantId = TenantInfoHolder.getTenantId();
				log.info("获取租户id:"+currentUserTenantId);
				if(StringUtils.isBlank(currentUserTenantId)) {
					throw new ApiException("获取当前租户id为空!");
				}
                return new StringValue(currentUserTenantId);
            }

            @Override
            public String getTenantIdColumn() {
                return "tenant_id";
            }

            @Override
            public boolean doTableFilter(String tableName) {
                // 这里可以判断是否过滤表
                if ("tenant_info".equalsIgnoreCase(tableName)
                		|| "t_role".equalsIgnoreCase(tableName)
                		
                ) {
                    return true;
                }
                return false;
            }
        });
        sqlParserList.add(tenantSqlParser);
        
        // 攻击 SQL 阻断解析器、加入解析链
        sqlParserList.add(new BlockAttackSqlParser());
        paginationInterceptor.setSqlParserList(sqlParserList);
        
        paginationInterceptor.setSqlParserFilter(new ISqlParserFilter() {
            @Override
            public boolean doFilter(MetaObject metaObject) {
                MappedStatement ms = SqlParserHelper.getMappedStatement(metaObject);
                // 过滤自定义查询此时无租户信息约束出现
                if (
                	"cn.mukanyun.core.element.mapper.ContractElementMapper.getByResId".equals(ms.getId())
                	||"cn.mukanyun.core.order.mapper.SyncOrderMapper.countDeptOrder".equals(ms.getId())
                ) {
                    return true;
                }
                return false;
            }
        });
        
        return paginationInterceptor;
    }

}

 

我们来分析一下上面的配置文件:

 

1、 PaginationInterceptor (AbstractSqlParserHandler)

 

PaginationInterceptor 分页拦截器,继承了 AbstractSqlParserHandler

 

 

我们看 AbstractSqlParserHandler 里面有两个成员变量 sqlParserList、sqlParserFilter

sqlParserFilter 是用来过滤需要处理的sql语句,sqlParserList 是sql处理器列表

所以我们看上面 mybatis 配置文件的分页插件里:

 

1)先构造了 一个sqlParserList ,往里添加了一个处理器 TenantSqlParser

 

List<ISqlParser> sqlParserList = new ArrayList<>();
TenantSqlParser tenantSqlParser = new TenantSqlParser();
sqlParserList.add(tenantSqlParser);

//然后设置到分页拦截器里
paginationInterceptor.setSqlParserList(sqlParserList);

 

2)然后构造了一个匿名内部类 SqlParserFilter 设置到了分页拦截器里

 

paginationInterceptor.setSqlParserFilter(new ISqlParserFilter() {...});

 

这样两个成员变量都有了,下面我们看一下这两个成员变量具体用法。

 

2、TenantSqlParser

 

我们分析一下TenantSqlparser,从名字我们就可以看出,这个是处理租户的sql语句的,我们先看看源码

 

 

这里截取了一部分代码,可以看出来这个类就是处理sql语句加上租户条件的,有处理insert、update、select 等等的

里面有个成员变量 tenantHandler,看图里标红的部分,主要有几个方法

我们在看 mybatis 配置, PaginationInterceptor 里也是用匿名内部类构造了 tenantHandler,对这几个方法进行实现

 

 

1、方法 getTenantIdColumn 就是返回 tenant_id 这个字段名就行;

2、方法 getTenantId 这个就是最重要的,要返回当前登录用户的租户id,这个要我们自己处理一下;

  我是定义了 TenantInfoHolder 类 getTenentId方法获取当前用户租户信息,这个我们后面说。

3、方法 doTableFilter 是按照表名来过滤掉一些不需要进行处理的表(这个是按表过滤,SqlParserFilter 是按具体sql语句过滤);

 

3、SqlParserFilter

 

这个其实没啥好说的,里面就一个 doFilter 方法,功能就是过滤掉不需要处理器处理的 sql 语句,这个要写具体 sql 语句的全限定名

 

paginationInterceptor.setSqlParserFilter(new ISqlParserFilter() {
            @Override
            public boolean doFilter(MetaObject metaObject) {
                MappedStatement ms = SqlParserHelper.getMappedStatement(metaObject);
                // 过滤自定义查询此时无租户信息约束出现
                if (
                	"cn.mukanyun.core.element.mapper.ContractElementMapper.getByResId".equals(ms.getId())
                	||"cn.mukanyun.core.order.mapper.SyncOrderMapper.countDeptOrder".equals(ms.getId())
                ) {
                    return true;
                }
                return false;
            }
 });
        

 

(三)TenantInfoHolder

 

从上面的配置来看,写法基本都是固定的,我们主要就是有几点需要配置的:

 

1、哪些表不用区分租户的,这个表相关的sql都不用加 tenantId 条件,我们一定要去掉,doTableFilter 方法里加上就行了;

2、有些表某些特殊的 sql 语句可能不用处理,也要过滤掉,SqlParserFilter 里面加上具体的 sql 路径;

3、某些复杂 sql 语句,这个是处理不了的,会报错,那我们就也用 SqlParserFilter 过滤掉,然后自己手动在 sql 语句加上 tenant_id 条件,手动传参

4、getTenantId 方法,这个是要返回当前登录的租户 id 。

 

最重要的就是第4点,获取当前租户的 tenant_id 值,我这定义了一个 TenantInfoHolder 用来操作 tenantId,代码如下:

 

package cn.mukanyun.core.tenant;

import java.util.Collection;

import org.apache.commons.lang3.StringUtils;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;

import cn.mukanyun.common.util.UserUtil;
import cn.mukanyun.core.login.entity.UserInfo;

public class TenantInfoHolder {

	 private static final ThreadLocal<String> contextHolder = new ThreadLocal<String>() {
	        /**
	         * 默认tenantId
	         */
	        @Override
	        protected String initialValue() {
	            return "";
	        }
	};

	 /**
     * 切换租户
     * @param key
     */
    public static void setTenantId(String tenantId) {
        contextHolder.set(tenantId);
    }

    /**
     * 获取当前租户
     * @return
     */
    public static String getTenantId() {

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if(authentication != null && authentication.getPrincipal() != null
              && !AnonymousAuthenticationToken.class.isAssignableFrom(authentication.getClass())) {
        	
        	UserInfo userInfo = (UserInfo)authentication.getPrincipal();
	        if(userInfo != null) {
	        	/**
	        	 * 超级管理员优先从contextHolder中取
	        	 */
	        	Collection<? extends GrantedAuthority> authorities = userInfo.getAuthorities();
	        	if(authorities != null) {
	        		
        			if(authorities.contains(new SimpleGrantedAuthority("superAdmin"))){
        				
		        		if(StringUtils.isNotBlank(contextHolder.get())) {
		        			return contextHolder.get();
		        		}
        			}
	        	}
	        	return userInfo.getTenantId();
        	}
        }
        
        return contextHolder.get();
    }

    /**
     * 清空当前租户信息
     */
    public static void clearTenantId() {
        contextHolder.remove();
    }

	
}

 

1、getTenantId 方法

 

这个类功能就是获取当前用户的租户 id,你需要根据自己的项目来实现,因为我是 Spring Security 做的权限,所以用如下方法获取当前用户

 

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

if(authentication != null && authentication.getPrincipal() != null
              && !AnonymousAuthenticationToken.class.isAssignableFrom(authentication.getClass())) {

     UserInfo userInfo = (UserInfo)authentication.getPrincipal();

      return userInfo.getTenantId();

 

这个 UserInfo 是在你登录用户时,应该会在登录表单里加一个租户标识,让用户选择要登录哪个租户

通过这个标识获取tenant_id(标识也可以直接用tenant_id,不过为了用户体验,一般是公司的代码等等)

然后根据 用户名、tenant_id 查数据库,查这个租户是否有这个用户,然后校验密码,登录成功后,就把 tenantId 设置到 userInfo里面,后面就能获取到了。

 

2、contextHolder 线程局部变量

 

但是你发现我这个类里又定义了一个线程局部变量 ThreadLocal<String> contextHolder,这个是干什么用的 ?

其实我们考虑还有一种情况,就是拥有管理权限的用户,如超级管理员等,这些用户不属于任何一个租户,而且可能有权限操作多个租户的数据,

还有其他一些系统行为的数据库操作,比如一个定时任务,定时处理某些租户的数据,这个时候上面的方法就不适用了

这时,我们就用到这个线程局部变量 contextHolder,我们就拿定时任务举个例子:

当定时任务在操作某个租户数据以前,我们先调用setTenantId将 租户id 设置到当前线程局部变量里

 

TenantInfoHolder.setTenantId(XXX);

 

然后是就是调用 service 层处理业务数据,这时当租户处理器 获取当前租户 最后调用到 TenantInfoHolder.getTenantId() 方法时,因为没有用户信息,所以直接走

  return contextHolder.get() 从线程局部变量取出来我们设置进去的租户,这样就能手动指定某个租户了,因为是线程局部变量,所以不会有多线程的问题。

一般我们操作完后,最好清除一下当前线程局部变量的值 

 

TenantInfoHolder.clearTenantId();

 

我一般把切换租户放到 try finally 里面:

 

try {
			
	TenantInfoHolder.setTenantId(xxx);
			
	xxxService.xxxx(xxx); //业务方法

} finally {

	TenantInfoHolder.clearTenantId();

}

这样就可以在需要的时候方便的切换不同的租户。

 

四、总结

 

其实,需要加的代码并不太多,我们主要就是登录后设置当前用户的租户id、如何获取当前用户的租户id、过滤不需处理的表、过滤掉不需要处理的sql语句。

还有一点我们一定要注意的地方,就是因为不同租户的数据都在一个数据库里,我们一定要确保数据安全,出现异常不会查到其他租户的数据。

 




  • 微信
  • 赶快加我聊天吧
  • QQ
  • 相逢即是有缘
  • weinxin
三尾鱼

发表评论 取消回复