前言 近期工作中有遇到多租户模式的应用场景,对此自己查阅了大量的资料。对可行性进行分析后选择了共享库表,按租户id字段区分租户的方式去实现。以此记录一下方便日后所需查阅
1.熟悉多租户之前先来了解一下什么是SaaS系统 以下内容来着百度百科
SaaS平台是运营saas软件的平台。SaaS提供商为企业搭建信息化所需要的所有网络基础设施及软件、硬件运作平台,并负责所有前期的实施、后期的维护等一系列服务,企业无需购买软硬件、建设机房、招聘IT人员,即可通过互联网使用信息系统。SaaS 是一种软件布局模型,其应用专为网络交付而设计,便于用户通过互联网托管、部署及接入。
也就是说,我只需要能连接上互联网,并且给saas平台交租金,我就能用saas平台给我提供的系统服务。这方面最典型的例子就是各种云平台,例如阿里云。既然我能通过互联网使用saas平台提供的服务,那么其他人当然也是可以的。于是这就产生了一个多租户的问题。
2.什么是多租户模式 多租户,简单来说,是一种架构设计方式,就是在一台或者一组服务器上运行的saas系统,可以为多个租户(客户)提供服务,目的是为了让多个租户在互联网环境下使用同一套程序,且保证租户间的数据隔离。从这种架构设计的模式上,不难看出来,多租户架构的重点就是同一套程序下多个租户数据的隔离。由于租户数据是集中存储的,所以要实现数据的安全性,就是看能否实现对租户数据的隔离,防止租户数据不经意或被他人恶意地获取和篡改。
3.多租户数据隔离方式 目前saas多租户系统的数据隔离有三种解决方案,即:
为每个租户提供独立的数据库 独立的表空间 按字段区分租户
3.2.每个租户提供独立的表空间 这种方案的实现方式,就是所有租户共享同一个应用,应用后端只连接一个数据库系统,所有租户共享这个数据库系统,每个租户在数据库系统中拥有一个独立的表空间。表空间中的数据表结构都是一样的。DB2、ORACLE、PostgreSQL,一个数据库下可以有多个Schema(在mysql中其实就是分多个数据库) 3.4.三种数据隔离方案的优劣势分析 隔离方案 成本 支持租户数量 优点 不足 独立数据库系统 高 少 隔离级别最高,安全性最好,能够满足不同租户的独特需求,出现故障时恢复数据比较容易 数据库需要独立安装,维护成本和购置成本高 共享数据库,独立表空间 中 较多 提供了一定程度的逻辑数据隔离,一个数据库系统可支持多个租户 出现故障的情况下,数据恢复相对而言比较复杂 按租户id字段区分 低 非常多 维护和购置成本最低,每个数据库能够支持的租户数量最多 隔离级别最低,安全性也最低,数据备份和恢复非常复杂,需要逐表逐条备份和还原 4.使用Mybatisplus搭建多租户模式(方式三的实现:共享库表,按租户id字段区分租户) 4.1.MyBatisPlusConfig.java
package com.bitvalue.gp.sys.config;
import com.bitvalue.gp.sys.core.mybatis.dbid.GunsDatabaseIdProvider; import com.bitvalue.gp.sys.core.mybatis.fieldfill.CustomMetaObjectHandler; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.autoconfigure.ConfigurationCustomizer; import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; import org.mybatis.spring.annotation.MapperScan; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration;
/**
@date 2021/4/18 10:49 */ @Configuration //扫描mapper @MapperScan(basePackages = {"com.bitvalue.gp.**.mapper"}) public class MyBatisPlusConfig {
/**
@Bean public ConfigurationCustomizer configurationCustomizer() {
return configuration -> configuration.setUseDeprecatedExecutor(false);
}
/**
getTenantId()方法,该方法主要用于设置租户Id的值,在框架去处理SQL语句前去改写SQL语句,为SQL语句添加上租户判断条件。租户Id可以从缓存、cookie、token等中获取(根据实际的业务场景来) getTenantIdColumn()方法,该方法用于设置租户Id的字段名称 ignoreTable(String tableName)方法,该方法用于标记忽略添加租户ID的表 主要的核心还是在getTenantId()方法,我们需要考虑这个租户Id的值应该如何去设置已经设置的同时会不会出现线程安全问题(看了大部分文章都是通过一个Bean中的字段类进行赋值的,这样可能会出现线程安全问题)。
我这里的思想是,在用户登录成功后。存储用户的基本信息到安全框架的上下文对象中并将用户的基本信息和租户Id生成一个token返回给请求方。当请求方再次来访问时会携带上这个token(首先会在过滤器中拦截请求,验证token能够解析后)进行一系列的业务操作后,最终要执行SQL语句时来到这个租户监听器中,在这里获取并设置租户Id。我这里是从请求头中获取token,通过解析token获取租户Id。 也可能会有一种情况就是,如果是内部mapper之间的调用那就没有HttpServerRequest,就无法获取到token并且还会报错。这里我对此进行了try/catch。 在catch结构体中处理内部调用问题,处理的方式就是从上下文对象中获取当前登录用户的账号,根据用户的账号去缓存中获取到该用户的租户信息。 ps:用户账号:租户Id信息我是在Spring容器初始化完成后就往redis里面存储了 其实同样的也可以将用户的租户Id存储到上下文对象中,直接获取。
package com.bitvalue.gp.sys.config;
import cn.hutool.core.util.ObjectUtil; import cn.hutool.extra.spring.SpringUtil; import com.bitvalue.gp.core.consts.CommonConstant; import com.bitvalue.gp.core.context.login.LoginContextHolder; import com.bitvalue.gp.core.exception.AuthException; import com.bitvalue.gp.core.exception.ServiceException; import com.bitvalue.gp.core.pojo.login.SysLoginUser; import com.bitvalue.gp.sys.core.jwt.JwtPayLoad; import com.bitvalue.gp.sys.core.jwt.JwtTokenUtil; import com.bitvalue.gp.core.util.HttpServletUtil; import com.bitvalue.gp.sys.modular.auth.service.AuthService; import com.alibaba.fastjson.JSON; import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler; import com.google.common.collect.Lists; import lombok.extern.slf4j.Slf4j; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.LongValue; import org.apache.commons.lang3.StringUtils; import org.springframework.data.redis.core.RedisTemplate;
import javax.servlet.http.HttpServletRequest; import java.util.List; import java.util.Map;
/**
@date 2021/04/26 13:37 */ @Slf4j public class CustomTenantLineHandler implements TenantLineHandler {
/**
/**
/**
/**
/**
/**
import com.bitvalue.gp.core.exception.ServiceException; import com.bitvalue.gp.core.exception.enums.ServerExceptionEnum; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse;
/**
@date 2021/3/30 15:09 */ public class HttpServletUtil {
/**
/**
package com.bitvalue.gp.sys.core.mybatis.fieldfill;
import cn.hutool.log.Log; import com.bitvalue.gp.core.context.login.LoginContextHolder; import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; import org.apache.ibatis.reflection.MetaObject; import org.apache.ibatis.reflection.ReflectionException;
import java.util.Date;
/**
@date 2021/3/30 15:21 */ public class CustomMetaObjectHandler implements MetaObjectHandler {
private static final Log log = Log.get();
private static final String CREATE_USER = "createUser";
private static final String CREATE_TIME = "createTime";
private static final String UPDATE_USER = "updateUser";
private static final String UPDATE_TIME = "updateTime";
@Override public void insertFill(MetaObject metaObject) {
try {
//设置createUser(BaseEntity)
setFieldValByName(CREATE_USER, this.getUserUniqueId(), metaObject);
//设置createTime(BaseEntity)
setFieldValByName(CREATE_TIME, new Date(), metaObject);
} catch (ReflectionException e) {
log.warn(">>> CustomMetaObjectHandler处理过程中无相关字段,不做处理");
}
}
@Override public void updateFill(MetaObject metaObject) {
try {
//设置updateUser(BaseEntity)
setFieldValByName(UPDATE_USER, this.getUserUniqueId(), metaObject);
//设置updateTime(BaseEntity)
setFieldValByName(UPDATE_TIME, new Date(), metaObject);
} catch (ReflectionException e) {
log.warn(">>> CustomMetaObjectHandler处理过程中无相关字段,不做处理");
}
}
/**