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

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

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

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

我们看 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语句的,我们先看看源码

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

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

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

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

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

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语句。

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

 

 

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

发布者:全栈程序员-用户IM,转载请注明出处:https://javaforall.cn/111311.html原文链接:https://javaforall.cn

【正版授权,激活自己账号】: Jetbrains全家桶Ide使用,1年售后保障,每天仅需1毛

【官方授权 正版激活】: 官方授权 正版激活 支持Jetbrains家族下所有IDE 使用个人JB账号...

(0)


相关推荐

  • KRACK 攻击解密安卓传输数据,OpenBSD 提前释出补丁

    KRACK 攻击解密安卓传输数据,OpenBSD 提前释出补丁比利时鲁汶大学的两位研究人员正式披露了被命名为KRACK(KeyReinstallationAttacks)的密钥重安装攻击,他们开发的概念验证攻击演示了对Android设备传输数据的解密能力。如果你的设备支持Wi-Fi,那么很有可能你的设备受到影响。运行Android、Linux、Apple、Windows、OpenBSD、联发科和…

  • RS232 DB9串口设备

    大家好,又见面了,我是你们的朋友全栈君。

  • java VM option

    -Xms256m-Xmx256m-XX:MaxNewSize=256m-XX:MaxPermSize=256m

  • 单片机led点阵显示程序_LED点阵

    单片机led点阵显示程序_LED点阵单片机LED点阵一、简述     使用8×8LED点阵显示汉字。向上滚动"中华"两个汉字。   文件打包:链接:https://pan.baidu.com/s/1oHSAIY6qVA7qFFWUvMvJEA密码:snyg二、效果三、工程文件结构1、Keil工程2、仿真电路图四、代码88led.c文件#include&lt;reg51.h&gt;#defineuintunsigne…

    2022年10月22日
  • Robotium体验—-白盒

    Robotium体验—-白盒什么是Robotium?先说一下发音。音标类似于[rəʊbɒʃɪəm],可参照有道。Robotium是一款开源测试框架,官方定义为AndroidApp的黑盒测试框架(官方示例为白盒),适用于native/hybridapp。由于开源,该框架源码可以从github上获取,地址为https://github.com/RobotiumTech/robotium。若需要文档,j…

  • 博客园博客背景图片设置

    博客园博客背景图片设置首先在博客园后台管理页面的相册上传自己想要设置背景的图片:上传完成之后点击该图片进去:看到大图再通过控制台获取路径然后转到设置:加上如下代码:最后效果:

发表回复

您的电子邮箱地址不会被公开。

关注全栈程序员社区公众号