引言
多租户(Multi-Tenancy
)是一种架构模式,允许多个租户共享同一个应用程序实例,但每个租户的数据是隔离的。
常见的多租户实现方式包括:
1. 独立数据库:每个租户有独立的数据库。
2. 共享数据库,独立 Schema:所有租户共享同一个数据库,但每个租户有独立的 Schema。
3. 共享数据库,共享 Schema:所有租户共享同一个数据库和 Schema,通过字段区分租户数据。
温馨提示
官方全系软件产品目前采用的是上述第三种解决方案:共享数据库,共享 Schema:所有租户共享同一个数据库和 Schema,通过字段区分租户数据。
当然有特殊场景需求的企业、开发者也可以自行实现 独立数据库(即:分库实现方案)
或者 共享数据库,独立 Schema
,如使用 PostgreSQL
数据库时可以共享数据库,单独给不同的租户划分不同的 Schema
模块
特别说明:其他实现方案需要对软件产品进行二次开发和改造,如有需要,企业和开发者可以自行实现。
所有租户共享同一个数据库和 Schema
,通过字段区分租户数据。
添加租户标识字段:在实体类中添加 tenant_id 字段。
数据过滤:在查询时自动添加 tenant_id 条件。
租户上下文管理:通过请求头或路径参数获取租户标识。
添加多租户依赖
在 pom.xml
配置文件中引入以下依赖:
<!-- 多租户模块 -->
<dependency>
<groupId>com.xiaomayi</groupId>
<artifactId>xiaomayi-tenant</artifactId>
</dependency>
多租户配置文件
在 xiaomayi-modules/xiaomayi-admin
模块的资源目录下,添加多租户 application-tencent.yml
配置文件。
# 多租户配置文件
tenant:
# 是否开启多租户
enable: true
# 租户字段名
column: tenant_id
# 需要进行租户ID过滤的表名集合
filterTables:
# 需要忽略的多租户的表,此配置优先filterTables,若此配置为空则启用filterTables
ignoreTables:
- tables
- columns
- gen_table
- gen_table_column
- gen_template
- qrtz_blob_triggers
- qrtz_calendars
- qrtz_cron_triggers
- qrtz_fired_triggers
- qrtz_job_details
- qrtz_locks
- qrtz_paused_trigger_grps
- qrtz_scheduler_state
- qrtz_simple_triggers
- qrtz_simprop_triggers
- qrtz_triggers
- qrtz_job
- qrtz_job_log
- sys_city
- sys_menu
- sys_role_menu
- sys_user_role
- sys_data_source
- sys_tenant
# 需要排除租户过滤的登录用户名
ignoreLoginNames:
- demo
参数说明
enable
:是否开启多租户,true
为开启多租户功能。tenant_id
:租户字段名,禁止修改。filterTables
:需要进行租户ID过滤的表名集合。ignoreTables
:需要忽略的多租户的表,优先filterTables
,若为空则启用filterTables
。ignoreLoginNames
:需要排除租户过滤的登录用户名。
多租户拦截插件
package com.xiaomayi.tenant.handler;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import com.xiaomayi.core.utils.StringUtils;
import com.xiaomayi.tenant.config.TenantProperties;
import com.xiaomayi.tenant.content.MybatisTenantContext;
import com.xiaomayi.core.utils.LoginUtils;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import java.util.List;
import java.util.Objects;
/**
* <p>
* 多租户执行器
* </p>
*
* @author 小蚂蚁云团队
* @since 2023-06-16
*/
@Slf4j
@AllArgsConstructor
public class MultiTenantLineHandler implements TenantLineHandler {
/**
* 租户属性
*/
private final TenantProperties tenantProperties;
/**
* 获取租户ID
*
* @return 返回结果
*/
@Override
public Expression getTenantId() {
// 租户ID
Integer tenantId = LoginUtils.getTenantId();
log.debug("当前租户ID为: >> {}", tenantId);
// 返回租户ID
return new LongValue(tenantId);
}
/**
* 获取租户字段名
*
* @return 返回结果
*/
@Override
public String getTenantIdColumn() {
return tenantProperties.getColumn();
}
/**
* 根据表名判断是否忽略拼接多租户条件
* 默认都要进行解析并拼接多租户条件
*
* @param tableName 数据表名
* @return 是否忽略, true:表示忽略,false:需要解析并拼接多租户条件
*/
@Override
public boolean ignoreTable(String tableName) {
// 租户登录账号
String loginName = LoginUtils.getUsername();
log.debug("当前租户登录账号为: >> {}", loginName);
// 忽略指定用户对租户的数据过滤
List<String> ignoreLoginNames = tenantProperties.getIgnoreLoginNames();
// 判断当前登录租户名是否忽略用户
if (StringUtils.isNotEmpty(ignoreLoginNames) && ignoreLoginNames.contains(loginName)) {
return true;
}
// 忽略指定表对租户数据的过滤
List<String> ignoreTables = tenantProperties.getIgnoreTables();
if (StringUtils.isNotEmpty(ignoreTables) && ignoreTables.contains(tableName)) {
return true;
}
// 根据忽略租户隔离注解判断是否需要租户隔离
if (Objects.nonNull(MybatisTenantContext.get())) {
log.info("是否做租户隔离:{}", MybatisTenantContext.get());
return MybatisTenantContext.get();
}
// 返回结果,默认租户隔离
return false;
}
}
添加多租户拦截
在 xiaomayi-common/xiaomayi-mybatis
模块的 MybatisPlusConfig
配置文件中,设置启动多租户并执行多住户执行器。
package com.xiaomayi.mybatis.config;
import com.baomidou.mybatisplus.autoconfigure.SpringBootVFS;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisXMLLanguageDriver;
import com.baomidou.mybatisplus.core.config.GlobalConfig;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.xiaomayi.core.utils.SpringUtils;
import com.xiaomayi.mybatis.exception.MybatisPlusException;
import com.xiaomayi.mybatis.handler.MybatisMetaObjectHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.io.VFS;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.type.JdbcType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import javax.sql.DataSource;
/**
* <p>
* MybatisPlus配置类
* </p>
*
* @author 小蚂蚁云团队
* @since 2024-05-21
*/
@Slf4j
// 开启事务
@EnableTransactionManagement(proxyTargetClass = true)
@Configuration
public class MybatisPlusConfig {
@Autowired
private Environment env;
@Autowired
private DataSource dataSource;
/**
* 单页分页条数限制(默认无限制,参见 插件#handlerLimit 方法)
*/
private static final Long MAX_LIMIT = 1000L;
@Value("${tenant.enable:false}")
private boolean elable;
/**
* 分页拦截器
*
* @return 返回结果
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor();
// 判断是否开启多租户
if (elable) {
// 添加多租户拦截插件
TenantLineInnerInterceptor tenantLineInnerInterceptor = SpringUtils.getBean(TenantLineInnerInterceptor.class);
mybatisPlusInterceptor.addInnerInterceptor(tenantLineInnerInterceptor);
}
// 添加乐观锁插件拦截器
mybatisPlusInterceptor.addInnerInterceptor(optimisticLockerInnerInterceptor());
// 添加分页插件拦截器,MySQL数据类型,分页插件放最后处理
mybatisPlusInterceptor.addInnerInterceptor(paginationInnerInterceptor());
// 防止全表更新与删除插件: BlockAttackInnerInterceptor
BlockAttackInnerInterceptor blockAttackInnerInterceptor = new BlockAttackInnerInterceptor();
mybatisPlusInterceptor.addInnerInterceptor(blockAttackInnerInterceptor);
return mybatisPlusInterceptor;
}
/**
* 乐观锁插件
*
* @return 返回结果
*/
public OptimisticLockerInnerInterceptor optimisticLockerInnerInterceptor() {
return new OptimisticLockerInnerInterceptor();
}
/**
* 分页插件
*
* @return 返回结果
*/
public PaginationInnerInterceptor paginationInnerInterceptor() {
// 实例化分页插件
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor();
// 分页合理化
paginationInnerInterceptor.setOverflow(true);
// 设置单页分页条数限制
paginationInnerInterceptor.setMaxLimit(MAX_LIMIT);
// 返回插件
return paginationInnerInterceptor;
}
/**
* 异常处理器
*/
@Bean
public MybatisPlusException mybatisException() {
return new MybatisPlusException();
}
/**
* 元对象字段填充
*/
@Bean
public MetaObjectHandler metaObjectHandler() {
return new MybatisMetaObjectHandler();
}
/**
* 注册工厂Bean
*
* @return 返回结果
* @throws Exception 异常处理
*/
@Bean
public SqlSessionFactory sqlSessionFactory() throws Exception {
// 全局环境配置参数
String typeAliasesPackage = env.getProperty("mybatis-plus.typeAliasesPackage");
String mapperLocations = env.getProperty("mybatis-plus.mapperLocations");
String configLocation = env.getProperty("mybatis-plus.configLocation");
VFS.addImplClass(SpringBootVFS.class);
MybatisSqlSessionFactoryBean sqlSessionFactoryBean = new MybatisSqlSessionFactoryBean();
// 设置数据源(也可以设置指定数据源)
sqlSessionFactoryBean.setDataSource(dataSource);
sqlSessionFactoryBean.setTypeAliasesPackage(typeAliasesPackage);
// 此处设置为了解决找不到mapper文件的问题
sqlSessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources(mapperLocations));
// sessionFactory.setConfigLocation(new DefaultResourceLoader().getResource(configLocation));
// 设置Mybatis配置
MybatisConfiguration configuration = new MybatisConfiguration();
configuration.setDefaultScriptingLanguage(MybatisXMLLanguageDriver.class);
configuration.setJdbcTypeForNull(JdbcType.NULL);
// 自动使用驼峰命名属性映射字段,如:userId user_id
configuration.setMapUnderscoreToCamelCase(true);
configuration.setCacheEnabled(false);
//使用jdbc的getGeneratedKeys获取数据库自增主键值
configuration.setUseGeneratedKeys(true);
sqlSessionFactoryBean.setConfiguration(configuration);
sqlSessionFactoryBean.setPlugins(new Interceptor[]{
// 添加分页插件
mybatisPlusInterceptor()
});
// 添加全局配置
sqlSessionFactoryBean.setGlobalConfig((new GlobalConfig()).setMetaObjectHandler(metaObjectHandler()));
// 返回结果
return sqlSessionFactoryBean.getObject();
}
}
元数据填充策略
在 xiaomayi-common/xiaomayi-mybatis
模块的 MybatisPlusConfig
配置文件中,已注册了数据填充策略 MetaObjectHandler
文件。
package com.xiaomayi.mybatis.handler;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.http.HttpStatus;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.xiaomayi.core.config.AppConfig;
import com.xiaomayi.core.exception.BizException;
import com.xiaomayi.mybatis.model.BaseEntity;
import com.xiaomayi.mybatis.model.TenantEntity;
import com.xiaomayi.tenant.constant.TenantConstant;
import com.xiaomayi.core.utils.LoginUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.Arrays;
/**
* <p>
* 自定义元对象数据填充处理
* </p>
*
* @author 小蚂蚁云团队
* @since 2024-03-23
*/
@Slf4j
@Component
public class MybatisMetaObjectHandler implements MetaObjectHandler {
/**
* 插入自动填充
*
* @param metaObject 元数据对象
*/
@Override
public void insertFill(MetaObject metaObject) {
try {
if (ObjectUtil.isNotNull(metaObject)) {
// 基类基础字段数据填充处理
if (metaObject.getOriginalObject() instanceof BaseEntity baseEntity) {
// 获取当前时间
LocalDateTime localDateTime = ObjectUtil.isNotNull(baseEntity.getCreateTime())
? baseEntity.getCreateTime() : LocalDateTime.now();
// 设置创建时间
baseEntity.setCreateTime(localDateTime);
// 设置更新时间
baseEntity.setUpdateTime(localDateTime);
// 设置填充创建人、更新人
if (ObjectUtil.isNull(baseEntity.getCreateUser())) {
// 获取用户登录账号
String username = LoginUtils.getUsername();
if (ObjectUtil.isNotEmpty(username)) {
// 设置填充创建人
baseEntity.setCreateUser(username);
// 设置填充更新人
baseEntity.setUpdateUser(username);
}
}
}
// 租户ID字段数据填充处理
if (metaObject.getOriginalObject() instanceof TenantEntity tenantEntity) {
// 元对象自动填充租户ID
if (metaObject.hasGetter(TenantConstant.TENANT_ID_PARAM) && ObjectUtil.isNull(tenantEntity.getTenantId())) {
tenantEntity.setTenantId(LoginUtils.getTenantId());
}
}
}
} catch (Exception e) {
throw new BizException("填充处理异常 => " + e.getMessage(), HttpStatus.HTTP_UNAUTHORIZED);
}
}
/**
* 更新自动填充
*
* @param metaObject 元数据对象
*/
@Override
public void updateFill(MetaObject metaObject) {
try {
if (ObjectUtil.isNotNull(metaObject) && metaObject.getOriginalObject() instanceof BaseEntity baseEntity) {
// 设置填充更新时间
baseEntity.setUpdateTime(LocalDateTime.now());
// 获取用户登录账号
String username = LoginUtils.getUsername();
// 设置填充更新人
if (ObjectUtil.isNotEmpty(username)) {
baseEntity.setUpdateUser(username);
}
}
} catch (Exception e) {
throw new BizException("填充处理异常 => " + e.getMessage(), HttpStatus.HTTP_UNAUTHORIZED);
}
}
}
总结
根据业务需求选择合适的方案,可以有效实现多租户架构,确保数据隔离和系统性能。