多租户基于Springboot+MybatisPlus实现使用一个数据库一个表 使用字段进行数据隔离

多租户基于Springboot+MybatisPlus实现使用一个数据库一个表 使用字段进行数据隔离

多租户实现方式

多租户在数据存储上主要存在三种方案,分别是:

1. 独立数据库

即一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本较高。

优点:为不同的租户提供独立的数据库,有助于简化数据模型的扩展设计,满足不同租户的独特需求;如果出现故障,恢复数据比较简单。

缺点:增多了数据库的安装数量,随之带来维护成本和购置成本的增加。

2. 共享数据库,独立 Schema

也就是说 共同使用一个数据库 使用表进行数据隔离

多个或所有租户共享Database,但是每个租户一个Schema(也可叫做一个user)。底层库比如是:DB2、ORACLE等,一个数据库下可以有多个SCHEMA。

优点:为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离;每个数据库可支持更多的租户数量。

缺点:如果出现故障,数据恢复比较困难,因为恢复数据库将牵涉到其他租户的数据;

3. 共享数据库,共享 Schema,共享数据表

也就是说 共同使用一个数据库一个表 使用字段进行数据隔离

即租户共享同一个Database、同一个Schema,但在表中增加TenantID多租户的数据字段。这是共享程度最高、隔离级别最低的模式。

简单来讲,即每插入一条数据时都需要有一个客户的标识。这样才能在同一张表中区分出不同客户的数据,这也是我们系统目前用到的(tenant_id)

优点:三种方案比较,第三种方案的维护和购置成本最低,允许每个数据库支持的租户数量最多。

缺点:隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量;数据备份和恢复最困难,需要逐表逐条备份和还原。

项目依赖Springboot+Mybatisplus

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">

cloud

com.gton

0.0.1-SNAPSHOT

4.0.0

ManyUser

Spring Boot 集成 Mybatis-Plus 多租户架构实战

3.0.3

3.5.1

8.0.25

2.0.18

org.springframework.boot

spring-boot-starter-web

${spring-boot-start-version}

com.github.xiaoymin

knife4j-spring-boot-starter

${knife4j.version}

com.baomidou

mybatis-plus-boot-starter

${mybatisplus.verison}

mysql

mysql-connector-java

${mysql-version}

org.projectlombok

lombok

1.18.16

com.alibaba.fastjson2

fastjson2

${fastJson-version}

配置文件

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://gitee.com/baomidou/mybatis-plus-samples/blob/master/mybatis-plus-sample-tenant/src/main/java/com/baomidou/mybatisplus/samples/tenant/config/MybatisPlusConfig.java

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

*

* 如果用了分页插件注意先 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 IGNORE_TENANT_TABLES = new ArrayList<>();

/**

* 获取租户 ID 值表达式,只支持单个 ID 值

*

*

* @return 租户 ID 值表达式

*/

@Override

public Expression getTenantId() {

// 获取当前租户信息

String tenantId = TenantRequestContext.getTenantLocal();

String requestUser = StringUtils.defaultIfEmpty(tenantId, "1001");

return new LongValue(requestUser);

}

/**

* 获取租户字段名

*

* 默认字段名叫: tenant_id

*

* @return 租户字段名

*/

@Override

public String getTenantIdColumn() {

return SYSTEM_TENANT_ID;

}

/**

* 根据表名判断是否忽略拼接多租户条件

*

* 默认都要进行解析并拼接多租户条件

*

* @param tableName 表名

* @return 是否忽略, true:表示忽略,false:需要解析并拼接多租户条件

*/

@Override

public boolean ignoreTable(String tableName) {

return IGNORE_TENANT_TABLES.contains(tableName);

}

@Override

public boolean ignoreInsert(List 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 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> queryPage(@RequestBody BaseLimitRequest limitRequest) {

// 分页查询

IPage page = this.tenantAuthLoginUserService.queryLimitPage(limitRequest);

// 封装返回结果集

BaseLimitResponse 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 selectOne(@PathVariable("id") Serializable id) {

return Resp.Ok(this.tenantAuthLoginUserService.getById(id));

}

/**

* 新增数据

*

* @param tenantAuthLoginUser 实体对象

* @return 新增结果

*/

@PostMapping("/save")

public Resp 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实现类里

/**

* 根据表名判断是否忽略拼接多租户条件

*

* 默认都要进行解析并拼接多租户条件

*

* @param tableName 表名

* @return 是否忽略, true:表示忽略,false:需要解析并拼接多租户条件

*/

@Override

public boolean ignoreTable(String tableName) {

return IGNORE_TENANT_TABLES.contains(tableName);

}

@Override

public boolean ignoreInsert(List columns, String tenantIdColumn) {

// 新增排除自己携带了这个多租户字段的新增

for (Column column : columns) {

if (column.getColumnName().equalsIgnoreCase(tenantIdColumn)) {

return true;

}

}

return false;

}

swagger测试接口新增;如果参数传了租户值就使用当前租户ID,否则使用默认租户ID完成新增

swagger测试接口查询(请求头里设置租户信息就使用该租户信息查询,否则就使用默认租户ID实现查询)

相关推荐

蓝信封邮筒
365bet足球即时比分网

蓝信封邮筒

🌍 09-14 👁️ 1686
14岁就有白头发,父母多注意这三件事,避免孩子早生华发
365bet足球即时比分网

14岁就有白头发,父母多注意这三件事,避免孩子早生华发

🌍 09-01 👁️ 4429
8、梦幻69级满强身是多少
365bet足球即时比分网

8、梦幻69级满强身是多少

🌍 10-03 👁️ 8452