多租户实现分类
既然多租户设计的难点在于隔离用户数据,又同时共享资源。那么可以根据用户数据的物理分离程度来进行分类。
分为三类:数据库(DataBase)、模式(Schema)、数据表(Table)。
分离数据库
每一个租户分配一个数据库连接池,根据租户id获取对应的连接池
模式(Schema)
应用使用一个数据库连接池,切换不同的 Schema 。就可以切换不同租户。(可以简单理解为。java里面的包名称。不同的包名下,可以有相同类名的 class )
MySQL 不支持 Schema 使用数据库代替! SQL Server 和 PostgreSQL 支持 Schema、
数据表(Table)
给每一个表结构,添加一个 tenant_id 字段,在 select、insert、update、delete 中都加上 tenant_id 的条件,此方式也是最简单,改动代码最少的。但是数据量大的时候,单表压力较大!
以上内容截取自 Spring Boot JPA MySQL 多租户系统 Part1 - 基础实现
本次使用第二种方式 “模式(Schema)” 基于 spring-boot 2.6.14 和 mybatis-plus 3.5.2 以及 liquibase 4.17.2
liquibase 是用来管理 表结构变化的版本控制!重中之重!!!
因为 每创建一个租户,都需要创建表结构,所以必须要有版本来控制 表结构,
而我们的项目不可能表结构,一次创建永远不修改。
那么已存在的租户,表结构,也需要跟着修改!
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
<version>4.17.2</version>
</dependency>
创建一个 DatabaseManager 用于管理,数据库的表结构!
package com.xaaef.molly.core.tenant;
import com.xaaef.molly.core.tenant.props.MultiTenantProperties;
import javax.sql.DataSource;
public interface DatabaseManager {
default String getOldDbName(String url) {
var startInx = url.lastIndexOf("?");
var sub = url.substring(0, startInx);
int startInx2 = sub.lastIndexOf("/") + 1;
return sub.substring(startInx2);
}
/**
* TODO 租户创建表结构
*
* @author WangChenChen
* @date 2022/12/11 11:04
*/
void createTable(String tenantId);
/**
* TODO 租户删除表结构
*
* @author WangChenChen
* @date 2022/12/11 11:04
*/
void deleteTable(String tenantId);
/**
* TODO 获取多租户信息
*
* @author WangChenChen
* @date 2022/12/11 11:04
*/
MultiTenantProperties getMultiTenantProperties();
}
createTable() : 创建租户的时候,创建表结构
deleteTable() : 删除租户的时候,删除表结构
实现类
package com.xaaef.molly.core.tenant.schema;
import com.xaaef.molly.core.tenant.DatabaseManager;
import com.xaaef.molly.core.tenant.props.MultiTenantProperties;
import liquibase.Liquibase;
import liquibase.database.jvm.JdbcConnection;
import liquibase.resource.ClassLoaderResourceAccessor;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.DriverManager;
import static org.springframework.util.ResourceUtils.CLASSPATH_URL_PREFIX;
/**
* 多租户 基于 模式(Schema)
* 此方式,是 默认连接池,切换 租户的 schema,
* 因为 mysql 数据库不支持 schema 所以只能使用 数据库 代替
*
* @author WangChenChen
* @date 2022/12/11 11:29
*/
@Slf4j
@Component
@AllArgsConstructor
@ConditionalOnProperty(prefix = "multi.tenant", name = "db-style", havingValue = "Schema")
public class SchemaDataSourceManager implements DatabaseManager {
// 默认租户的数据源
private final DataSource dataSource;
private final MultiTenantProperties multiTenantProperties;
private final DataSourceProperties dataSourceProperties;
@Override
public MultiTenantProperties getMultiTenantProperties() {
return multiTenantProperties;
}
/**
* 创建表
*
* @author WangChenChen
* @date 2022/12/7 21:05
*/
@Override
public void createTable(String tenantId) {
log.info("tenantId: {} create table ...", tenantId);
try {
// 判断 schema 是否存在。不存在就创建
var conn = dataSource.getConnection();
// 判断数据库是否存在!不存在就创建
String tenantDbName = multiTenantProperties.getPrefix() + tenantId;
String sql = String.format("CREATE DATABASE IF NOT EXISTS %s CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;", tenantDbName);
conn.createStatement().execute(sql);
// 创建一次性的 jdbc 链接。只是用来生成表结构的。用完就关闭。
var conn1 = new JdbcConnection(getTempConnection(tenantDbName));
var changeLogPath = multiTenantProperties.getOtherChangeLog();
// 使用 Liquibase 创建表结构
if (multiTenantProperties.getOtherChangeLog().startsWith(CLASSPATH_URL_PREFIX)) {
changeLogPath = multiTenantProperties.getOtherChangeLog().replaceFirst(CLASSPATH_URL_PREFIX, "");
}
var liquibase = new Liquibase(changeLogPath, new ClassLoaderResourceAccessor(), conn1);
liquibase.update(tenantId);
// 关闭链接
conn1.close();
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void deleteTable(String tenantId) {
log.info("tenantId: {} delete table ...", tenantId);
String tenantDbName = multiTenantProperties.getPrefix() + tenantId;
String sql = String.format("DROP DATABASE %s ;", tenantDbName);
try {
var conn = getTempConnection(tenantDbName);
conn.createStatement().execute(sql);
conn.close();
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 创建 临时的 jdbc 连接。 用于生成表结构。用完就关闭
*
* @return
* @author WangChenChen
* @date 2022/12/8 12:44
*/
private Connection getTempConnection(String tenantDbName) throws Exception {
// 获取默认的数据名称
var oldDbName = getOldDbName(dataSourceProperties.getUrl());
// 替换连接池中的数据库名称
var dataSourceUrl = dataSourceProperties.getUrl().replaceFirst(oldDbName, tenantDbName);
//3.获取数据库连接对象
return DriverManager.getConnection(dataSourceUrl,
dataSourceProperties.getUsername(),
dataSourceProperties.getPassword());
}
}
多租户的配置类。包括,默认的租户ID, 租户数据库的前缀。以及 生成表结构的 语句
package com.xaaef.molly.core.tenant.props;
import com.xaaef.molly.core.tenant.enums.DbStyle;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* <p>
* 多租户全局配置
* </p>
*
* @author WangChenChen
* @version 1.1
* @date 2022/12/9 11:53
*/
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "multi.tenant")
public class MultiTenantProperties {
/**
* 是否开启租户模式
*/
private Boolean enable = true;
/**
* 是否开启租户模式
*/
private Boolean enableProject = false;
/**
* 数据库名称前缀
*/
private String prefix = "molly_";
/**
* 默认租户ID
*/
private String defaultTenantId = "master";
/**
* 默认 项目ID
*/
private String defaultProjectId = "master";
/**
* 多租户的类型。
*
* 一定要在配置文件里指定....
*/
private DbStyle dbStyle;
/**
* 创建表结构
*/
private Boolean createTable = Boolean.TRUE;
/**
* 其他 数据库 创建表结构的 Liquibase 文件地址
*/
private String otherChangeLog = "classpath:db/changelog-other.xml";
/**
* 主 数据库 创建表结构的 Liquibase 文件地址
*/
private String masterChangeLog = "classpath:db/changelog-master.xml";
}
package com.xaaef.molly.core.tenant.util;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.NamedInheritableThreadLocal;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.Objects;
import static com.xaaef.molly.core.tenant.consts.MbpConst.X_PROJECT_ID;
import static com.xaaef.molly.core.tenant.consts.MbpConst.X_TENANT_ID;
/**
* <p>
* </p>
*
* @author WangChenChen
* @version 1.1
* @date 2022/11/25 11:14
*/
public class TenantUtils {
private final static ThreadLocal<String> TENANT_ID_THREAD_LOCAL = new NamedInheritableThreadLocal<>("TENANT_ID_THREAD_LOCAL");
private final static ThreadLocal<String> PROJECT_ID_THREAD_LOCAL = new NamedInheritableThreadLocal<>("PROJECT_ID_THREAD_LOCAL");
/**
* 获取 租户ID
*/
public static String getTenantId() {
if (StringUtils.isNotBlank(TENANT_ID_THREAD_LOCAL.get())) {
return TENANT_ID_THREAD_LOCAL.get();
}
var attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (Objects.isNull(attributes)) {
return null;
}
var request = attributes.getRequest();
return request.getHeader(X_TENANT_ID);
}
/**
* 设置 租户ID
*/
public static void setTenantId(String tenantId) {
if (StringUtils.isNotBlank(tenantId)) {
TENANT_ID_THREAD_LOCAL.set(tenantId);
} else {
TENANT_ID_THREAD_LOCAL.remove();
}
}
/**
* 获取 项目ID
*/
public static String getProjectId() {
if (StringUtils.isNotBlank(PROJECT_ID_THREAD_LOCAL.get())) {
return PROJECT_ID_THREAD_LOCAL.get();
}
var attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (Objects.isNull(attributes)) {
return null;
}
var request = attributes.getRequest();
return request.getHeader(X_PROJECT_ID);
}
/**
* 设置 项目ID
*/
public static void setProjectId(String tenantId) {
if (StringUtils.isNotBlank(tenantId)) {
PROJECT_ID_THREAD_LOCAL.set(tenantId);
} else {
PROJECT_ID_THREAD_LOCAL.remove();
}
}
}
spring mvc 拦截器,从请求中获取,租户ID
package com.xaaef.molly.core.tenant;
import cn.hutool.core.util.StrUtil;
import com.xaaef.molly.common.util.JsonResult;
import com.xaaef.molly.common.util.ServletUtils;
import com.xaaef.molly.core.auth.jwt.JwtSecurityUtils;
import com.xaaef.molly.core.tenant.service.MultiTenantManager;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.xaaef.molly.core.tenant.util.TenantUtils;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import static com.xaaef.molly.core.tenant.consts.MbpConst.X_TENANT_ID;
/**
* <p>
* </p>
*
* @author WangChenChen
* @version 1.1
* @date 2022/11/15 11:41
*/
@Slf4j
@Component
@AllArgsConstructor
public class TenantIdInterceptor implements HandlerInterceptor {
private final MultiTenantManager tenantManager;
@Override
public boolean preHandle(HttpServletRequest request,
@NotNull HttpServletResponse response,
@NotNull Object handler) throws Exception {
/*
* 从请求头中获取 如:
* GET https://www.baidu.com/hello
* x-tenant-id=master
* */
var tenantId = request.getHeader(X_TENANT_ID);
if (StringUtils.isEmpty(tenantId)) {
/*
* 从URL地址中获取 如:
* GET https://www.baidu.com/hello?x-tenant-id=master
* */
tenantId = request.getParameter(X_TENANT_ID);
}
if (StringUtils.isEmpty(tenantId)) {
// 判断当前此请求,是否已经登录。
if (JwtSecurityUtils.isAuthenticated()) {
// 判断登录的用户类型。
// 系统用户: 必须添加 租户ID.
// 租户用户: 租户ID 在登录的时候,已经确定了
if (JwtSecurityUtils.isMasterUser()) {
return writeError(response);
} else {
TenantUtils.setTenantId(JwtSecurityUtils.getTenantId());
tenantId = JwtSecurityUtils.getTenantId();
}
} else {
return writeError(response);
}
}
// 校验租户,是否存在系统中
if (!tenantManager.existById(tenantId)) {
var err = StrUtil.format("租户ID {} 不存在!", tenantId);
ServletUtils.renderError(response, JsonResult.fail(err));
return false;
}
return HandlerInterceptor.super.preHandle(request, response, handler);
}
private static boolean writeError(HttpServletResponse response) {
var err = StrUtil.format("请求头或者URL参数中必须添加 {}", X_TENANT_ID);
ServletUtils.renderError(response, JsonResult.fail(err));
return false;
}
}
下面就是 核心中的核心了 mybatis-puls 拦截器
package com.xaaef.molly.core.tenant.schema;
import cn.hutool.core.collection.CollectionUtil;
import com.baomidou.mybatisplus.core.toolkit.PluginUtils;
import com.baomidou.mybatisplus.extension.parser.JsqlParserSupport;
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import com.xaaef.molly.core.auth.jwt.JwtSecurityUtils;
import com.xaaef.molly.core.tenant.props.MultiTenantProperties;
import com.xaaef.molly.core.tenant.util.TenantUtils;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.sf.jsqlparser.JSQLParserException;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.statement.Statements;
import net.sf.jsqlparser.util.TablesNamesFinder;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.springframework.stereotype.Component;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Collection;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import static com.xaaef.molly.core.tenant.consts.MbpConst.TENANT_IGNORE_TABLES;
/**
* <p>
* </p>
*
* @author WangChenChen
* @version 1.1
* @date 2022/12/14 12:40
*/
@Slf4j
@Component
@AllArgsConstructor
public class SchemaInterceptor extends JsqlParserSupport implements InnerInterceptor {
private final MultiTenantProperties props;
@Override
public void beforePrepare(StatementHandler sh, Connection conn, Integer transactionTimeout) {
var mpBoundSql = PluginUtils.mpBoundSql(sh.getBoundSql());
// 获取当前 sql 语句中的。表名称
Set<String> tableName = getTableListName(mpBoundSql.sql());
// 判断 表名称 是否需要过滤,即: 使用 公共库,而不是 租户 库。
if (ignoreTable(tableName)) {
// 切换数据库
switchSchema(conn, props.getDefaultTenantId());
} else {
// 切换数据库
switchSchema(conn, getCurrentTenantId());
}
InnerInterceptor.super.beforePrepare(sh, conn, transactionTimeout);
}
private String getCurrentTenantId() {
// 判断当前此请求,是否已经登录。
if (JwtSecurityUtils.isAuthenticated()) {
// 判断登录的用户类型。
// 系统用户: 可以操作任何一个 租户 的数据库。
// 租户用户: 只能操作 所在租户 的数据库
if (JwtSecurityUtils.isMasterUser()) {
return TenantUtils.getTenantId();
} else {
return JwtSecurityUtils.getTenantId();
}
}
return Optional.ofNullable(TenantUtils.getTenantId())
.orElse(props.getDefaultTenantId());
}
private void switchSchema(Connection conn, String schema) {
// PostgreSQL 和 SQL Server 可以使用 schema
// conn.setSchema(schema);
// 切换数据库
String sql = String.format("use %s%s", props.getPrefix(), schema);
try {
conn.createStatement().execute(sql);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
private final static TablesNamesFinder TABLES_NAMES_FINDER = new TablesNamesFinder();
/**
* 解析 sql 获取全部的 表名称
*/
private static Set<String> getTableListName(String sql) {
Statements statements = null;
try {
statements = CCJSqlParserUtil.parseStatements(sql);
} catch (JSQLParserException e) {
e.printStackTrace();
return Set.of();
}
return statements.getStatements()
.stream()
.map(TABLES_NAMES_FINDER::getTableList)
.flatMap(Collection::stream)
.collect(Collectors.toSet());
}
/**
* 过滤 公共的表。
*/
private static boolean ignoreTable(Set<String> tableName) {
return CollectionUtil.containsAny(TENANT_IGNORE_TABLES, tableName);
}
}
主要方法
beforePrepare() : 在 sql 语句执行前的 前置处理。根据PluginUtils.mpBoundSql(sh.getBoundSql()); 获取当前执行的 sql 语句。
getTableListName() : 根据 sql 语句,获取要执行的表名称。然后根据表名,判断此表,是公共表,还是租户表。如:sys_config 这种表,肯定是所有租户公用的。而 user,role,之类,肯定是每个租户独有的!
switchSchema() : 根据数据库名称,切换数据库,因为 mysql 不支持 Schema 。所有只能用数据库替代。
getCurrentTenantId() : 获取当前登录的用户所在的租户ID。 当然也要判断用户类型。
如果是:系统用户,那么此用户就可以操作 所有的租户数据库。也就是说,可以随便切换到其他租户的数据库。
如果是:租户用户,那么此用户只能操作 “默认数据库” 和 “租户所属的数据库”,默认数据库中存放了一些公共数据,如:sys_config 。
这个判断很简单, 就是租户id ,是不是默认的租户id、
package com.xaaef.molly.core.tenant;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.xaaef.molly.common.util.JsonUtils;
import com.xaaef.molly.core.auth.jwt.JwtSecurityUtils;
import com.xaaef.molly.core.tenant.props.MultiTenantProperties;
import com.xaaef.molly.core.tenant.schema.SchemaInterceptor;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import java.time.LocalDateTime;
import static com.xaaef.molly.core.tenant.consts.MbpConst.*;
/**
* <p>
* </p>
*
* @author Wang Chen Chen
* @version 1.0
* @date 2021/7/8 9:21
*/
@Slf4j
@Configuration
@AllArgsConstructor
@EnableTransactionManagement
public class MybatisPlusConfig {
private final MultiTenantProperties tenantProperties;
/**
* 单页分页条数限制(默认无限制,参见 插件#handlerLimit 方法)
*/
private static final Long MAX_LIMIT = 100L;
/**
* 新的分页插件,一缓和二缓遵循mybatis的规则,
* 需要设置 MybatisConfiguration#useDeprecatedExecutor = false
* 避免缓存出现问题(该属性会在旧插件移除后一同移除)
*/
@Bean
public MybatisPlusInterceptor paginationInterceptor() {
// 设置 ObjectMapper
JacksonTypeHandler.setObjectMapper(JsonUtils.getMapper());
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 是否启用租户
if (tenantProperties.getEnable()) {
var schemaInterceptor = new SchemaInterceptor(tenantProperties);
interceptor.addInnerInterceptor(schemaInterceptor);
}
//分页插件: PaginationInnerInterceptor
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor();
paginationInnerInterceptor.setMaxLimit(MAX_LIMIT);
//防止全表更新与删除插件: BlockAttackInnerInterceptor
BlockAttackInnerInterceptor blockAttackInnerInterceptor = new BlockAttackInnerInterceptor();
interceptor.addInnerInterceptor(paginationInnerInterceptor);
interceptor.addInnerInterceptor(blockAttackInnerInterceptor);
return interceptor;
}
}
到这里已经整合完成了。大概流程就是这样
1. web浏览器发送请求,请求头中携带 x-tenant-id 参数,用于区分是哪个租户
2.spring mvc 拦截器,拦截到租户id。保存到 ThreadLocal 中。
3.执行自己的业务
4.mybatis-plus 拦截器,拦截到业务中的 sql 语句,获取到 表名称 。判断 是否为公共表,
如果是:公共表 切换到 默认数据库,
如果是:租户的表,就再次判断当前登录的用户类型,
如果是:系统用户,获取 ThreadLocal 中的租户id。切换到对应的数据库
如果是:租户用户,直接切换到 所属的租户。租户用户,只能操作自己的库
系统用户登录
### 获取验证码
### http://localhost:18891/auth/captcha/codes?codeKey=5jXzuwcoUzbtnHNh
GET {{baseUrl}}/auth/captcha/codes?codeKey=5jXzuwcoUzbtnHNh
x-tenant-id: master### [master]密码模式登录
POST {{baseUrl}}/auth/login
Content-Type: application/json
x-tenant-id: master
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0{
"username": "admin",
"password": "123456",
"codeKey": "5jXzuwcoUzbtnHNh",
"codeText": "uj57i"
}> {%
client.global.set("tokenValue", response.body.data.access_token);
client.global.set("refreshToken", response.body.data.refresh_token);
%}
租户用户登录
### 获取验证码 ### http://localhost:18891/auth/captcha/codes?codeKey=5jXzuwcoUzbtnHNh GET {{baseUrl}}/auth/captcha/codes?codeKey=applezrgegbtnHNrefh x-tenant-id: apple ### [master]密码模式登录 POST {{baseUrl}}/auth/login Content-Type: application/json x-tenant-id: apple User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0 { "username": "apple", "password": "apple", "codeKey": "applezrgegbtnHNrefh", "codeText": "9xwbm" } > {% client.global.set("tokenValue", response.body.data.access_token); client.global.set("refreshToken", response.body.data.refresh_token); %}
执行获取 默认租户的 角色列表
执行获取 google租户的 角色列表