Mybatis-plus 实现多租户业务实战

1,058 阅读8分钟

前言

今天来了解下如何使用 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 的多租户的实际应用