前言
今天来了解下如何使用 mybatis-plus 实现我们的多租户实战,从多租户概念引入到SpringBoot项目中的实际应用。这块也之前已经在项目中应用,将这部分功能摘取出来进行demo 演示。
一、多租户概念
1.1 云服务模式
要想了解下多租户的概念,我们需要了解下几种云服务模式,常见的有 IAAS、PAAS、SAAS 等服务。
而我们的多租户是 SAAS 服务特有的产物。SAAS 服务是部署在云端,客户可以同时使用同一套系统。
1.1.1 IAAS
含义为 Infrastructure as a server。即基础设施就是服务,意思就是把客户需要的基础设施环境搭建好后,然后开放虚拟机或硬件的租赁服务,消费者不管理或控制任何云计算基础设施,但能控制操作系统的选择、存储空间、部署的应用,也有可能获得有限制的网络组件(例如路由器、防火墙、负载均衡器等)的控制。
一般使用云服务器就是这样。
- 优点:IAAS 的自由度、灵活度非常高,用户可以自行安装操作系统、数据库及各类软件。
- 缺点:维护成本比较高,cpu、内存等资源跑不满可能会浪费,需要投入运维成本。
1.1.2 PAAS
平台及服务,在云端把客户所需的软件等环境整合的平台出租给用户,进行收费。
云厂商已经给大家搭建好了平台,这个平台出租给你一个空间,这个云端空间里面已经装好了各类所需的软件,比如操作系统、云数据库、云中间件、网关、云负载均衡器等相关的内容。
- PAAS 优点:减少搭建环境的各种成本,用户可以减少资源。
- 缺点:自由度和灵活度很低。
小结:
- 其实我们平时使用云服务器,大多数是采用 IAAS+PASS 相互结合。
1.2.3 SAAS
软件即服务,也就是多用户的 web 系统。
对于用户来说,不需要关心技术问题,只要用你提供的服务就行。
- 优点:方便便捷,可以有效的对资源进行利用,用户可以直接使用并且管理这些软件产生的数据就可以了,而且可以按需使用,选择需要功能付费不付费都行。可以有多个用户或者企业用户存在。
- 缺点:用户数据在云端,自己不能完全有效的掌握
总结:
- IaaS,是提供最底层的服务,因为最接近服务器硬件资源,这样用户可以以最大的自由度接入构建网络以及服务器配置;
- PaaS,是提供了更高一层的服务。整体服务并没有向用户展示底层网络与硬件资源,整个底层是透明的,直接向用户开放云端产品软件以及开发运行环境;
- SaaS,提供最上层服务。对于用户来说最简单,所见即所得,不需要技术开发人员也可以拥有自己的一套软件。
1.2 多租户 VS 单租户
何为多租户?说到租户,就来说说租房子。
二房东将房子租来后,进行装修、将房子分隔成 5 个隔断间,然后将每个隔断间的用户出租给张三、李四、王五... ,而这些租户他们是合租的,对方的房间他们进不去,这就是保证了各自的私密性,也就是数据隔离。
但是,对于公共区域是可以随时进入的,比如客厅、卫生间、厨房,这些数据就是共享数据,大家都可以访问,比如说掘金其实就是个多租户平台,对于共享的小册、活动大家都能看到,而对于创作者自身的数据就是只能通过用户自己的 id 自己查看。
所以,其实对于SAAS多租户系统,要比单一系统来的更加节省硬件资源,因为我们只需要部署一套系统就可以了,所有的硬件设备也只需要采购一次。但是相对来说,我们不能为企业提供定制化的需求方案,对于特定的要求,多租户不好去满足,但是一般来说,我们可以收集各 方需求,去把各个租户的需求整合,然后根据收取不同的费用,提供可选的软件服务即可,那么这个就是saas的体现。
而单租户就是整租的概念,所有设施都是自己在用,定制化要求高,同时对于互联网来说,很多老系统还是单租户,每年的维护费用也是非常高的,给不同的企业进行定制化开发和部署。
不难看出,多租户一定是 SAAS 模式,因为软件为租户提供了服务。SAAS 不一定是多租户。
| 多租户 | 单租户 | |
|---|---|---|
| 数据隔离性 | 低 | 高 |
| 数据共享性 | 高 | 低 |
| 数据库复杂度 | 高 | 低 |
| 可定制化度 | 低 | 高 |
| 版本迭代 | 简单 | 复杂 |
| 硬件成本 | 低 | 高 |
1.3 设计方案
针对多租户,一般有三种设计方案:
- 独立数据库:
- 也就是一个租户一个数据库,这种级别和单租户是差不多 适合数据隔离性很高企业:医院、金融
- 同一数据库,不同的 schema
- 相同数据库,同一张表(用的最多,本文采用该方式)
- 隔离性最低,成本最低
二、Mybatis-plus多租户实战
2.1 环境搭建
1、引入依赖
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3</version>
</dependency>
复制代码
2、创建两张测试表(内容一致)
CREATE TABLE `tenant` (
`id` int NOT NULL AUTO_INCREMENT,
`email` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,
`account` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,
`tenant_id` int DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
CREATE TABLE `tenant_2` (
`id` int NOT NULL AUTO_INCREMENT,
`email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
`account` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
`tenant_id` int DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
复制代码
3、新建对应实体和 mapper 方法
@Data
@TableName("tenant")
public class TestTenant {
@TableId(type = IdType.AUTO)
private Integer id;
private String account;
private String email;
private Integer tenantId;
}
@Data
@TableName("tenant_2")
public class TestTenant2 {
@TableId(type = IdType.AUTO)
private Integer id;
private String account;
private String email;
private Integer tenantId;
}
复制代码
2.2 mybaits配置
动态传入忽略的表和自定义化的多租户列名
/**
* 多租户设置
* @Author xiaolei
* @Date 2023/3/24 10:11
**/
public class TenantConfig extends TenantLineInnerInterceptor {
public static ThreadLocal<String> tenantContext = new ThreadLocal();
/**
* 多租户初始化设置
* @param ignoreTable 忽略的表
* @param idName 租户id
*/
public TenantConfig(final List<String> ignoreTable, final String idName) {
super(new TenantLineHandler() {
// 获取租户id ,若没有则传入 -1
@Override
public Expression getTenantId() {
String tenantId = (String)TenantConfig.tenantContext.get();
return new LongValue(tenantId == null ? "-1" : tenantId);
}
// 忽略表,true 表示忽略该表
@Override
public boolean ignoreTable(String tableName) {
return ignoreTable.contains(tableName);
}
// 多租户的列名自定义
@Override
public String getTenantIdColumn() {
return idName;
}
});
}
}
复制代码
/**
* @Author xiaolei
* @Date 2023/2/21 15:20
**/
@Configuration
public class MybatisPlusConfig {
protected final Log log = LogFactory.getLog(this.getClass());
@Value("${spring.datasource.tenant.ignoreTable:''}")
private List<String> ignoreTable;
@Value("${spring.datasource.tenant.idName:tenant_id}")
private String idName;
@Value("${spring.datasource.tenant.enable:true}")
private Boolean enable;
/**
* 新多租户插件配置,一缓和二缓遵循mybatis的规则,需要设置 MybatisConfiguration#useDeprecatedExecutor = false 避免缓存万一出现问题
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
this.log.debug("租户ID " + this.getIdName());
this.log.debug("忽略表 " + String.join(",", this.getIgnoreTable()));
if(this.getEnable()){
interceptor.addInnerInterceptor(new TenantConfig(this.getIgnoreTable(),this.idName));
te
复制代码
2.3 配置文件
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://xxxx:3306/lottery?characterEncoding=utf-8&useSSL=false
username: xxxx
password: xxxx
tenant:
enable: true // 是否开启多租户
idName: tenant_id
ignoreTable: tenant_2 // 逗号拼接
复制代码
2.4 测试
偷懒直接在 controller 层写了。
@RestController
@RequestMapping("/ten")
public class TenantController {
@Autowired
TenantMapper tenantMapper;
@Autowired
TenantMapper2 tenantMapper2;
@GetMapping("v1/{id}")
public String insertUser(@PathVariable String id){
TenantConfig.tenantContext.set(id);
System.out.println(TenantConfig.tenantContext.get());
TestTenant testTenant = new TestTenant();
testTenant.setEmail("123@qq");
testTenant.setAccount("xiaolei");
tenantMapper.insert(testTenant);
return "success";
}
@GetMapping("v2/{id}")
public String insertUser2(@PathVariable String id){
TenantConfig.tenantContext.set(id);
System.out.println(TenantConfig.tenantContext.get());
TestTenant2 testTenant = new TestTenant2();
testTenant.setEmail("123@qq");
testTenant.setAccount("xiaolei");
tenantMapper2.insert(testTenant);
return "success";
}
}
复制代码
开始访问两个版本的接口,可以发现tenant_2 该表的多租户没有设置上该id
而 tenant 表的该字段就会自动填充上。
2.5 AOP 拦截优化
前面也看到,我们不可能每次都会执行
TenantConfig.tenantContext.set(id); 这对我们来说太繁琐了,而需要做的就是通过 AOP 拦截得到用户的租户id,将其自动赋值上,对于我们业务来说就是无感知的,这一部分做好了,我们的多租户也就完成了。因此,在 AOP 中可以对用户的身份进行获取,可以从请求头中获取,这块根据自己业务来实现。
这块测试就从常量中获取该用户id。
@Aspect
@Component
@Slf4j
public class CompanyAspect {
static String redisUserId = "100";
private static final String ignoreUrl = "/login";
@Around("execution(* com.xiaolei.*.controller..*.*(..))")
public Object Around(ProceedingJoinPoint joinPoint) throws Throwable {
// 1.方法执行前的处理,相当于前置通知
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
Map<String,Object> joinPointInfo=getJoinPointInfoMap(joinPoint);
Object obj = joinPointInfo.get("paramMap");
if(!request.getRequestURI().equalsIgnoreCase(ignoreUrl)){
if(obj!=null){
TenantConfig.tenantContext.set(redisUserId);
}
}
Object proceed = joinPoint.proceed();
TenantConfig.tenantContext.remove();
return proceed;
}
private static Map<String, Object> getJoinPointInfoMap(JoinPoint joinPoint) {
Map<String,Object> joinPointInfo=new HashMap<>();
String classPath=joinPoint.getTarget().getClass().getName();
String methodName=joinPoint.getSignature().getName();
joinPointInfo.put("classPath",classPath);
Class<?> clazz=null;
CtMethod ctMethod=null;
LocalVariableAttribute attr=null;
int length=0;
int pos = 0;
try {
clazz = Class.forName(classPath);
String clazzName=clazz.getName();
ClassPool pool=ClassPool.getDefault();
ClassClassPath classClassPath=new ClassClassPath(clazz);
pool.insertClassPath(classClassPath);
CtClass ctClass=pool.get(clazzName);
ctMethod=ctClass.getDeclaredMethod(methodName);
MethodInfo methodInfo=ctMethod.getMethodInfo();
CodeAttribute codeAttribute=methodInfo.getCodeAttribute();
attr=(LocalVariableAttribute) codeAttribute.getAttribute(LocalVariableAttribute.tag);
if(attr==null){
return joinPointInfo;
}
length=ctMethod.getParameterTypes().length;
pos= Modifier.isStatic(ctMethod.getModifiers())?0:1;
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (NotFoundException e) {
e.printStackTrace();
}
Map<String,Object> paramMap=new HashMap<>();
Object[] paramsArgsValues=joinPoint.getArgs();
String[] paramsArgsNames=new String[length];
for (int i=0;i<length;i++){
paramsArgsNames[i]=attr.variableName(i+pos);
String paramsArgsName=attr.variableName(i+pos);
Object paramsArgsValue = paramsArgsValues[i];
paramMap.put(paramsArgsName,paramsArgsValue);
joinPointInfo.put("paramMap", JSON.toJSONString(paramsArgsValue));
}
return joinPointInfo;
}
}
复制代码
我们可以把前面的
TenantConfig.tenantContext.set(id); 注释掉,然后经过代理后,就会给我们自动拼接上具体用户的id。注意要把 ThreadLocal 清除。
开启 mybatis-plus日志,可以看到插入的够都默认拼接上了100
数据库中也加上了对应的值
总结:以上就是有关 mybatis-plus 的多租户的实际应用