多租户基于Springboot+MybatisPlus实现使用一个数据库一个表 使用字段进行数据隔离
多租户实现方式
多租户在数据存储上主要存在三种方案,分别是:
1. 独立数据库
即一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本较高。
优点:为不同的租户提供独立的数据库,有助于简化数据模型的扩展设计,满足不同租户的独特需求;如果出现故障,恢复数据比较简单。
缺点:增多了数据库的安装数量,随之带来维护成本和购置成本的增加。
2. 共享数据库,独立 Schema
也就是说 共同使用一个数据库 使用表进行数据隔离
多个或所有租户共享Database,但是每个租户一个Schema(也可叫做一个user)。底层库比如是:DB2、ORACLE等,一个数据库下可以有多个SCHEMA。
优点:为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离;每个数据库可支持更多的租户数量。
缺点:如果出现故障,数据恢复比较困难,因为恢复数据库将牵涉到其他租户的数据;
3. 共享数据库,共享 Schema,共享数据表
也就是说 共同使用一个数据库一个表 使用字段进行数据隔离
即租户共享同一个Database、同一个Schema,但在表中增加TenantID多租户的数据字段。这是共享程度最高、隔离级别最低的模式。
简单来讲,即每插入一条数据时都需要有一个客户的标识。这样才能在同一张表中区分出不同客户的数据,这也是我们系统目前用到的(tenant_id)
优点:三种方案比较,第三种方案的维护和购置成本最低,允许每个数据库支持的租户数量最多。
缺点:隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量;数据备份和恢复最困难,需要逐表逐条备份和还原。
项目依赖Springboot+Mybatisplus
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>cloud</artifactId>
<groupId>com.gton</groupId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>ManyUser</artifactId>
<description>Spring Boot 集成 Mybatis-Plus 多租户架构实战</description>
<properties>
<knife4j.version>3.0.3</knife4j.version>
<mybatisplus.verison>3.5.1</mybatisplus.verison>
<mysql-version>8.0.25</mysql-version>
<fastJson-version>2.0.18</fastJson-version>
</properties>
<dependencies>
<!--web项目驱动-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring-boot-start-version}</version>
</dependency>
<!--Knife4j(增强Swagger)-->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>${knife4j.version}</version>
</dependency>
<!--Mybatis-plus 代码生成器-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatisplus.verison}</version>
</dependency>
<!--Mysql数据库-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql-version}</version>
</dependency>
<!--lombok-实体类简化依赖-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.16</version>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastJson-version}</version>
</dependency>
</dependencies>
</project>
配置文件
application.properties
#数据源
spring.datasource.url=jdbc:mysql://120.53.238.87:3366/cloud_market?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=guotong199114
# 应用名称
spring.application.name=more-user-use
# 启动环境
spring.profiles.active=mybatis
# 应用服务 WEB 访问端口
server.port=8889
#Springboot2.6以上需要手动设置
spring.mvc.pathmatch.matching-strategy=ant_path_matcher
# 配置数据库连接池
spring.datasource.type=com.zaxxer.hikari.HikariDataSource
spring.datasource.hikari.minimum-idle=3
spring.datasource.hikari.maximum-pool-size=10
# 不能小于30秒,否则默认回到1800秒
spring.datasource.hikari.max-lifetime=30000
spring.datasource.hikari.connection-test-query=SELECT 1
mybatis.pwd.key=d1104d7c3b616f0b
application-mybatis.yaml
mybatis-plus:
type-aliases-package: com.gton.user.entity
mapper-locations: classpath*:com/gton/user/mapper/xml/*Mapper.xml,classpath*:/mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true #开启驼峰命名
cache-enabled: false #开启二级缓存
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 控制台日志
check-config-location: true # 检查xml是否存在
type-enums-package: com.gton.enumPackage #通用枚举开启
global-config:
db-config:
logic-not-delete-value: 1
logic-delete-field: isDel
logic-delete-value: 0
测试表
CREATE TABLE `tenant_auth_login_user` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键-自增',
`username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户名',
`password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '密码:AES加密',
`rule` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '权限',
`sex` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '性别',
`head_portrait` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '头像',
`phone` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '电话',
`address` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '地址',
`email` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '邮箱',
`description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '个人介绍',
`is_del` int NOT NULL COMMENT '辑删除(0-标识删除,1-标识可用)',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '修改时间',
`tenant_id` bigint NOT NULL COMMENT '多租户下的租户ID',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci STATS_PERSISTENT=1 COMMENT='多租户表';
实现多租户:参考官方
https://baomidou.com/pages/aef2f2/#tenantlineinnerinterceptor
第一步 implements TenantLineHandler
package com.gton.user.handler;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import lombok.extern.slf4j.Slf4j;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import net.sf.jsqlparser.schema.Column;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* @description: 租户处理器 -主要实现mybatis-plus TenantLineHandler
* <p>
* 如果用了分页插件注意先 add TenantLineInnerInterceptor
* 再 add PaginationInnerInterceptor
* 用了分页插件必须设置 MybatisConfiguration#useDeprecatedExecutor = false
* @author: GuoTong
* @createTime: 2023-06-22 16:43
* @since JDK 1.8 OR 11
**/
@Slf4j
@Component
public class SysTenantHandlerImpl implements TenantLineHandler {
/**
* 多租户标识
*/
private static final String SYSTEM_TENANT_ID = "tenant_id";
/**
* 需要过滤的表
*/
private static final List<String> IGNORE_TENANT_TABLES = new ArrayList<>();
/**
* 获取租户 ID 值表达式,只支持单个 ID 值
* <p>
*
* @return 租户 ID 值表达式
*/
@Override
public Expression getTenantId() {
// 获取当前租户信息
String tenantId = TenantRequestContext.getTenantLocal();
String requestUser = StringUtils.defaultIfEmpty(tenantId, "1001");
return new LongValue(requestUser);
}
/**
* 获取租户字段名
* <p>
* 默认字段名叫: tenant_id
*
* @return 租户字段名
*/
@Override
public String getTenantIdColumn() {
return SYSTEM_TENANT_ID;
}
/**
* 根据表名判断是否忽略拼接多租户条件
* <p>
* 默认都要进行解析并拼接多租户条件
*
* @param tableName 表名
* @return 是否忽略, true:表示忽略,false:需要解析并拼接多租户条件
*/
@Override
public boolean ignoreTable(String tableName) {
return IGNORE_TENANT_TABLES.contains(tableName);
}
@Override
public boolean ignoreInsert(List<Column> columns, String tenantIdColumn) {
// 新增排除自己携带了这个多租户字段的新增
for (Column column : columns) {
if (column.getColumnName().equalsIgnoreCase(tenantIdColumn)) {
return true;
}
}
return false;
}
}
建立一个基础类,用于同一个线程上下文变量恒定
package com.gton.user.handler;
/**
* @description: 保存当前请求用户的的信息,
* 使用threadlocal来实现,
* 和当前请求线程绑定
* @author: GuoTong
* @createTime: 2023-06-22 16:59
* @since JDK 1.8 OR 11
**/
public class TenantRequestContext {
private static ThreadLocal<String> tenantLocal = new ThreadLocal<>();
public static void setTenantLocal(String tenantId) {
tenantLocal.set(tenantId);
}
public static String getTenantLocal() {
return tenantLocal.get();
}
public static void remove() {
tenantLocal.remove();
}
}
第二步拦截器拦截请求,获取请求头里面的租户标识放入线程上下文
package com.gton.user.handler;
import com.gton.user.handler.TenantRequestContext;
import com.mysql.cj.util.StringUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* @description: 拦截器主要是获取请求头中的租户id,
* 然后放到上下文中,
* 供mybatisPlus获取
* @author: GuoTong
* @createTime: 2023-06-22 17:02
* @since JDK 1.8 OR 11
**/
@Slf4j
public class TenantUserInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String userId = request.getHeader("tenant_id");
if (!StringUtils.isNullOrEmpty(userId)) {
// 当前上下文的线程私有域注入多租户信息
TenantRequestContext.setTenantLocal(userId);
log.info("当前租户ID:" + userId);
}
return HandlerInterceptor.super.preHandle(request, response, handler);
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 当前上下文的线程私有域释放多租户信息
TenantRequestContext.remove();
}
}
注册拦截器到Spring容器中
/**
* @description: SpringBoot-Web配置
* @author: GuoTong
* @createTime: 2021-10-05 15:37
* @since JDK 1.8 OR 11
**/
@Configuration
public class SpringBootConfig implements WebMvcConfigurer {
/**
* Description: 添加全局跨域CORS处理
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
// 设置允许跨域的路径
registry.addMapping("/**")
//设置允许跨域请求的域名
.allowedOrigins("http://127.0.0.1:8787")
// 是否允许证书
.allowCredentials(true)
// 设置允许的方法
.allowedMethods("GET", "POST", "DELETE", "PUT")
// 设置允许的header属性
.allowedHeaders("*")
// 跨域允许时间
.maxAge(3600);
}
/**
* Description: 静态资源过滤
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
//ClassPath:/Static/** 静态资源释放
registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
//释放swagger
registry.addResourceHandler("doc.html").addResourceLocations("classpath:/META-INF/resources/");
//释放webjars
registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
}
/**
* Description: 过滤器
*
* @param registry
* @author: GuoTong
* @date: 2023-06-03 12:32:39
* @return:void
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new TenantUserInterceptor()).addPathPatterns("/**")
.excludePathPatterns("/doc.html")
.excludePathPatterns("/swagger-resources/**")
.excludePathPatterns("/webjars/**")
.excludePathPatterns("/v2/**")
.excludePathPatterns("/favicon.ico")
.excludePathPatterns("/sso/**")
.excludePathPatterns("/swagger-ui.html/**");
}
}
注册mybatisplus的多租户实现到 MybatisPlusInterceptor
package com.gton.user.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
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.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.gton.user.handler.EasySqlInjector;
import com.gton.user.handler.SysTenantHandlerImpl;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* @description: Mybatis相关组件配置
* @author: GuoTong
* @createTime: 2022-11-25 15:33
* @since JDK 1.8 OR 11
**/
@Configuration
public class MybatisConfig {
@Value("${spring.jackson.date-format:yyyy-MM-dd HH:mm:ss}")
private String pattern;
/**
* Description: 新的分页插件
*
* @author: GuoTong
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 添加多租户插件
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new SysTenantHandlerImpl()));
// 添加乐观锁插件
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
// 向Mybatis过滤器链中添加分页拦截器
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
/**
* Description: 批量插入优化
*
* @author: GuoTong
*/
@Bean
public EasySqlInjector sqlInjector() {
return new EasySqlInjector();
}
/**
* Description: localDateTime 序列化器
*
* @author: GuoTong
* @return:
*/
@Bean
public LocalDateTimeSerializer localDateTimeSerializer() {
return new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(pattern));
}
/**
* Description: localDateTime 反序列化器
*
* @author: GuoTong
* @return:
*/
@Bean
public LocalDateTimeDeserializer localDateTimeDeserializer() {
return new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(pattern));
}
/**
* Description: Json序列化JDK8新时间APILocalDateTime
*
* @author: GuoTong
* @date: 2022-12-05 16:20:01
*/
@Bean
public Jackson2ObjectMapperBuilderCustomizer customizer() {
return builder -> {
//返回时间数据序列化
builder.serializerByType(LocalDateTime.class, localDateTimeSerializer());
//接收时间数据反序列化
builder.deserializerByType(LocalDateTime.class, localDateTimeDeserializer());
builder.simpleDateFormat(pattern);
};
}
}
测试Controller
/**
* 多租户表(TenantAuthLoginUser)表控制层
*
* @author 郭童
* @since 2023-06-22 16:28:10
*/
@RestController
@RequestMapping("tenantAuthLoginUser")
@SwaggerScanClass
public class TenantAuthLoginUserController {
/**
* 服务对象
*/
@Autowired
private TenantAuthLoginUserService tenantAuthLoginUserService;
@Value("${mybatis.pwd.key:d1104d7c3b616f0b}")
private String mybatiskey;
/**
* 分页查询数据
*
* @param limitRequest 查询实体
* @return 所有数据
*/
@PostMapping("/queryLimit")
public Resp<BaseLimitResponse<TenantAuthLoginUser>> queryPage(@RequestBody BaseLimitRequest<TenantAuthLoginUser> limitRequest) {
// 分页查询
IPage<TenantAuthLoginUser> page = this.tenantAuthLoginUserService.queryLimitPage(limitRequest);
// 封装返回结果集
BaseLimitResponse<TenantAuthLoginUser> data = BaseLimitResponse.getInstance(page.getRecords(), page.getTotal(), page.getPages(), limitRequest.getPageIndex(), limitRequest.getPageSize());
return Resp.Ok(data);
}
/**
* 通过主键查询单条数据
*
* @param id 主键
* @return 单条数据
*/
@GetMapping("/queryOne/{id}")
public Resp<TenantAuthLoginUser> selectOne(@PathVariable("id") Serializable id) {
return Resp.Ok(this.tenantAuthLoginUserService.getById(id));
}
/**
* 新增数据
*
* @param tenantAuthLoginUser 实体对象
* @return 新增结果
*/
@PostMapping("/save")
public Resp<String> insert(@RequestBody TenantAuthLoginUser tenantAuthLoginUser) {
boolean save = false;
String executeMsg = ContextCommonMsg.USER_NAME_EXITS;
;
try {
String username = tenantAuthLoginUser.getUsername();
Long count = tenantAuthLoginUserService.lambdaQuery().eq(StringUtils.isNotEmpty(username), TenantAuthLoginUser::getUsername, username).count();
if (count >= 1) {
return Resp.error(executeMsg);
}
tenantAuthLoginUser.setPassword(AES.encrypt(tenantAuthLoginUser.getPassword(), mybatiskey));
save = this.tenantAuthLoginUserService.save(tenantAuthLoginUser);
executeMsg = "新增成功,id 是:" + tenantAuthLoginUser.getId();
} catch (Exception e) {
executeMsg = e.getMessage();
}
return save ? Resp.Ok(executeMsg) : Resp.error(executeMsg);
}
}
省略单表的CRUD的三层架构,各种Mybatisx或者EasyCode都可以全自动生成
测试数据
INSERT INTO `tenant_auth_login_user`(`id`, `username`, `password`, `rule`, `sex`, `head_portrait`, `phone`, `address`, `email`, `description`, `is_del`, `create_time`, `update_time`, `tenant_id`) VALUES (1671817343047143426, '全球最强', 'JRaZunLuzVfNLNfCpe/Ahg==', 'ADMIN', '中立', 'http://localhost:8889/', '110-7654321', '重庆市神魂村天之痕路一号', 'guotong@qq.com', '黑暗万岁', 1, '2023-06-22 17:48:10', '2023-06-22 17:48:10', 1100);
INSERT INTO `tenant_auth_login_user`(`id`, `username`, `password`, `rule`, `sex`, `head_portrait`, `phone`, `address`, `email`, `description`, `is_del`, `create_time`, `update_time`, `tenant_id`) VALUES (1671817697516163073, '地表最强', 'JRaZunLuzVfNLNfCpe/Ahg==', 'ADMIN', '中立', 'http://localhost:8889/', '110-7654321', '天之痕路一号', 'guotong@qq.com', '光明万岁', 1, '2023-06-22 17:49:35', '2023-06-22 17:49:35', 1001);
Mybatisplus帮我们在增删改查的所有操作都给拼接上携带租户条件。。一劳永逸,当你不需要使用多租户的条件时,TenantLineHandler实现类里
/**
* 根据表名判断是否忽略拼接多租户条件
* <p>
* 默认都要进行解析并拼接多租户条件
*
* @param tableName 表名
* @return 是否忽略, true:表示忽略,false:需要解析并拼接多租户条件
*/
@Override
public boolean ignoreTable(String tableName) {
return IGNORE_TENANT_TABLES.contains(tableName);
}
@Override
public boolean ignoreInsert(List<Column> columns, String tenantIdColumn) {
// 新增排除自己携带了这个多租户字段的新增
for (Column column : columns) {
if (column.getColumnName().equalsIgnoreCase(tenantIdColumn)) {
return true;
}
}
return false;
}
swagger测试接口新增;如果参数传了租户值就使用当前租户ID,否则使用默认租户ID完成新增
swagger测试接口查询(请求头里设置租户信息就使用该租户信息查询,否则就使用默认租户ID实现查询)
作者:隔壁老郭
个性签名:独学而无友,则孤陋而寡闻。做一个灵魂有趣的人!
如果觉得这篇文章对你有小小的帮助的话,记得在右下角点个“推荐”哦,博主在此感谢!
Java入门到入坟
万水千山总是情,打赏一分行不行,所以如果你心情还比较高兴,也是可以扫码打赏博主,哈哈哈(っ•̀ω•́)っ✎⁾⁾!