欢迎大家去我的个人网站踩踩 点这里哦
一、前言
前面曾经写过一篇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语句。
还有一点我们一定要注意的地方,就是因为不同租户的数据都在一个数据库里,我们一定要确保数据安全,出现异常不会查到其他租户的数据。
发布者:全栈程序员-用户IM,转载请注明出处:https://javaforall.cn/111311.html原文链接:https://javaforall.cn
【正版授权,激活自己账号】: Jetbrains全家桶Ide使用,1年售后保障,每天仅需1毛
【官方授权 正版激活】: 官方授权 正版激活 支持Jetbrains家族下所有IDE 使用个人JB账号...