• MyBatis-Plus 实现多租户管理的实践


    本文主要讲解使用Mybatis-Plus结合dynamic-datasource来实现多租户管理

    在现代企业应用中,多租户(Multi-Tenant)架构已经成为一个非常重要的设计模式。多租户架构允许多个租户共享同一应用程序实例,但每个租户的数据彼此隔离。实现这一点可以大大提高资源利用率并降低运营成本。在本文中,我们将探讨如何使用 MyBatis-Plus 结合 Dynamic-Datasource 来实现多租户管理。

    MyBatis-Plus 是 MyBatis 的增强工具,提供了很多开箱即用的功能,如 CRUD 操作、分页插件、逻辑删除等,使开发人员能够更加专注于业务逻辑,而无需过多关注底层的数据库操作细节。Dynamic-Datasource 是一个功能强大的动态数据源切换框架,能够方便地在多个数据源之间进行切换,非常适合实现多租户数据库管理。

    本文将通过一个具体的例子,详细讲解如何配置和使用 MyBatis-Plus 以及 Dynamic-Datasource 来实现多租户管理。我们将首先创建租户信息表,并为每个租户分别创建用户信息表。然后,我们将配置 MyBatis-Plus 和 Dynamic-Datasource 实现动态数据源切换和多租户数据隔离。最后,我们会展示如何通过代码动态地切换数据源,以确保每个租户的数据操作都在各自的数据库中进行。

    通过本文的学习,您将掌握:

    • 如何配置 MyBatis-Plus 和 Dynamic-Datasource 实现动态数据源切换
    • 如何在代码中实现多租户数据隔离

    让我们开始吧!

    环境

    本文演示开发工具环境如下

    IntelliJ IDEA 2023.3.6
    Maven 3.8.6
    JDK 17
    

    依赖包如下

    
     <properties>
    	    <druid.version>1.1.22druid.version>
            <fastjson.version>2.0.39fastjson.version>
            <dynamic.ds.version>3.5.1dynamic.ds.version>
            <mybatis-plus.generator.version>3.5.1mybatis-plus.generator.version>
     properties>
    
    
     <dependency>
                <groupId>com.baomidougroupId>
                <artifactId>mybatis-plus-boot-starterartifactId>
                <version>3.5.2version>
     dependency>
    <dependency>
                <groupId>mysqlgroupId>
                <artifactId>mysql-connector-javaartifactId>
                <version>8.0.33version>
    dependency>
    
    <dependency>
                <groupId>com.baomidougroupId>
                <artifactId>dynamic-datasource-spring-boot-starterartifactId>
                <version>${dynamic.ds.version}version>
    dependency>
    
    <dependency>
                <groupId>com.alibabagroupId>
                <artifactId>druid-spring-boot-starterartifactId>
                <version>${druid.version}version>
    dependency>
    
    <dependency>
                <groupId>com.baomidougroupId>
                <artifactId>mybatis-plus-generatorartifactId>
                <version>${mybatis-plus.generator.version}version>
    dependency>
    

    初始sql语句如下

    CREATE TABLE `tenant` (
        `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
         `tenant_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '租户名称',
         `tenant_desc` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '租户详情',
         `db_info` varchar(2047) COLLATE utf8mb4_general_ci DEFAULT NULL,
         `redis_info` varchar(2047) COLLATE utf8mb4_general_ci DEFAULT NULL,
         `version` int NOT NULL DEFAULT '0' COMMENT '版本号',
         `created_time` datetime NOT NULL COMMENT '创建时间',
         `created_by` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
         `modified_time` datetime NOT NULL COMMENT '修改时间',
         `modified_by` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人',
         `is_deleted`TINYINT(4) not null DEFAULT 0 COMMENT '是否删除',
         PRIMARY KEY (`id`) USING BTREE
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='租户信息';
    
    
    
    INSERT INTO `tenant` ( `tenant_name`, `tenant_desc`, `db_info`, `redis_info`, `version`, `created_time`, `created_by`, `modified_time`, `modified_by` )
    VALUES
        ( '测试租户1', '租户说明信息', '{\"dbUrl\": \"jdbc:mysql://127.0.0.1:3306/tenant-one?rewriteBatchedStatements=true\",\"dbUsername\": \"root\",\"dbPassword\": \"0c0bb39488e6dbfb\"}', '{\"host\": \"localhost\",\"port\": 6379,\"pwd\": \"123456\",\"db\": 1}', 0, NOW(), '1', NOW(), '1' );
    
    
    INSERT INTO `tenant` (  `tenant_name`, `tenant_desc`, `db_info`, `redis_info`, `version`, `created_time`, `created_by`, `modified_time`, `modified_by` )
    VALUES
        (  '测试租户2', '租户说明信息', '{\"dbUrl\": \"jdbc:mysql://127.0.0.1:3306/tenant-two?rewriteBatchedStatements=true\",\"dbUsername\": \"root\",\"dbPassword\": \"0c0bb39488e6dbfb\"}', '{\"host\": \"localhost\",\"port\": 6379,\"pwd\": \"123456\",\"db\": 1}', 0, NOW(), '1', NOW(), '1' );
    
    
    
    use `tenant-one`;
    CREATE TABLE IF NOT EXISTS user_info (
        id BIGINT NOT NULL PRIMARY KEY COMMENT '主键Id',
        user_no VARCHAR(255) NOT NULL DEFAULT '' COMMENT '编号',
        user_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '姓名',
        description VARCHAR(512) DEFAULT '' COMMENT '备注',
        created_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
        created_by BIGINT NOT NULL DEFAULT 0 COMMENT '记录创建者Id,默认为0',
        modified_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录修改时间',
        modified_by BIGINT DEFAULT NULL COMMENT '记录修改者Id,可以为空',
        is_deleted TINYINT(4) NOT NULL DEFAULT 0 COMMENT '是否删除,默认为0,1表示删除'
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='用户信息';
    
    
    
    use `tenant-two`;
    CREATE TABLE IF NOT EXISTS user_info (
        id BIGINT NOT NULL PRIMARY KEY COMMENT '主键Id',
        user_no VARCHAR(255) NOT NULL DEFAULT '' COMMENT '编号',
        user_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '姓名',
        description VARCHAR(512) DEFAULT '' COMMENT '备注',
        created_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
        created_by BIGINT NOT NULL DEFAULT 0 COMMENT '记录创建者Id,默认为0',
        modified_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录修改时间',
        modified_by BIGINT DEFAULT NULL COMMENT '记录修改者Id,可以为空',
        is_deleted TINYINT(4) NOT NULL DEFAULT 0 COMMENT '是否删除,默认为0,1表示删除'
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='用户信息';
    
    
    use `tenant-one`;
    INSERT INTO `user_info` (`id`, `user_no`, `user_name`, `description`, `created_time`, `created_by`, `modified_time`, `modified_by`, `is_deleted`) VALUES (1, 'test_no', '租户1测试用户', '租户1测试用户', '2024-05-15 03:45:06', 0, '2024-05-15 03:45:06', NULL, 0);
    
    use `tenant-two`;
    INSERT INTO `user_info` (`id`, `user_no`, `user_name`, `description`, `created_time`, `created_by`, `modified_time`, `modified_by`, `is_deleted`) VALUES (1, 'test_no', '租户2测试用户', '租户2测试用户', '2024-05-15 03:45:06', 0, '2024-05-15 03:45:06', NULL, 0);
    

    配置文件如下

    server:
      port: 8080
      servlet:
        context-path: /
      # undertow 配置
      undertow:
        # HTTP post内容的最大大小。当值为-1时,默认值为大小是无限的
        max-http-post-size: -1
        # 每块buffer的空间大小,越小的空间被利用越充分
        buffer-size: 512
        # 是否分配的直接内存
        direct-buffers: true
        threads:
          # 设置IO线程数, 它主要执行非阻塞的任务,它们会负责多个连接, 默认设置每个CPU核心一个线程
          io: 8
          # 阻塞任务线程池, 当执行类似servlet请求阻塞操作, undertow会从这个线程池中取得线程,它的值设置取决于系统的负载
          worker: 256
    
    base:
      redis:
        host: 127.0.0.1
        port: 6379
        password: 123456
        db: 0
      db:
        url: jdbc:mysql://127.0.0.1:3306/tenant?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
        username: root
        pwd: 0c0bb39488e6dbfb
    
    
    spring:
      datasource:
        dynamic:
          primary: 0
          strict: true
          hikari:
            connection-timeout: 30000
            max-pool-size: 10
            min-idle: 5
            idle-timeout: 180000
            max-lifetime: 1800000
            connection-test-query: SELECT 1
          datasource:
            0:
              url: ${base.db.url}
              username: ${base.db.username}
              password: ${base.db.pwd}
              driver-class-name: com.mysql.cj.jdbc.Driver
        type: com.alibaba.druid.pool.DruidDataSource
    
    mybatis-plus:
      configuration:
        map-underscore-to-camel-case: true
        cache-enabled: true
        use-generated-keys: true
        default-executor-type: simple
        log-impl: org.apache.ibatis.logging.log4j2.Log4j2Impl
      mapperLocations: classpath*:mapper/*Mapper.xml
      typeAliasesPackage: com.simple.mybaitsdynamicdatasource.infrastructure.db.entity
      type-aliases-package: ${application.base-package}.entity
      global-config:
        db-config:
          logic-delete-field: is_deleted
          logic-not-delete-value: 0
          logic-delete-value: 1
    
    logging:
      level:
        org.springframework: warn
    
    

    代码如下

    首先我的代码框架具体如下

    其中实现动态切换数据源的操作主要在我们的TenantServiceImpl中,具体代码如下,其中主要是我们会通过当前获取到的TenantId来调用changeDsByTenantId方法进行修改动态数据源

    package com.simple.mybaitsdynamicdatasource.infrastructure.service.impl;
    
    
    import com.alibaba.fastjson2.JSON;
    import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
    import com.baomidou.dynamic.datasource.creator.DefaultDataSourceCreator;
    import com.baomidou.dynamic.datasource.spring.boot.autoconfigure.DataSourceProperty;
    import com.baomidou.dynamic.datasource.spring.boot.autoconfigure.druid.DruidConfig;
    import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder;
    import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
    import com.simple.mybaitsdynamicdatasource.infrastructure.config.TenantContext;
    import com.simple.mybaitsdynamicdatasource.infrastructure.db.entity.TenantEntity;
    import com.simple.mybaitsdynamicdatasource.infrastructure.db.mapper.TenantMapper;
    import com.simple.mybaitsdynamicdatasource.infrastructure.db.model.DbInfo;
    import com.simple.mybaitsdynamicdatasource.infrastructure.service.TenantService;
    import lombok.AllArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Service;
    import org.springframework.util.ObjectUtils;
    import org.springframework.util.StringUtils;
    
    import javax.sql.DataSource;
    
    @Slf4j
    @Service
    @AllArgsConstructor
    public class TenantServiceImpl extends ServiceImpl implements TenantService {
    
        private TenantMapper tenantMapper;
    
        private DynamicRoutingDataSource dataSource;
    
        private DefaultDataSourceCreator dataSourceCreator;
    
    
        /**
         * 根据租户ID切换数据源
         *
         * @param tenantId 租户ID
         */
        @Override
        public void changeDsByTenantId(String tenantId) {
            //当前租户ID对应的数据源已存在,则直接切换
            if (existInMemory(tenantId)) {
                //切换数据源
                changeTenantDs(tenantId);
                return;
            }
            DataSource dataSource = queryTenantIdToDataSource(tenantId);
            if (!ObjectUtils.isEmpty(dataSource)) {
                //动态添加数据源
                this.dataSource.addDataSource(tenantId, dataSource);
                //切换数据源
                this.changeTenantDs(tenantId);
                return;
            }
            // todo 抛出异常信息
            throw new RuntimeException("数据源不存在");
        }
    
        /**
         * 判断是否存在内存中
         * @param dsName
         * @return
         */
        @Override
        public Boolean existInMemory(String dsName) {
            return StringUtils.hasText(dsName) && dataSource.getDataSources().containsKey(dsName);
        }
    
        /**
         * 清理当前调用上下文中的数据源缓存
         */
        @Override
        public void clearDsContext() {
            //清空当前线程数据源
            DynamicDataSourceContextHolder.clear();
            TenantContext.remove();
        }
    
        /**
         * 移除对应的数据源信息
         *
         * @param dsName 数据源名称
         */
        @Override
        public void removeDs(String dsName) {
            dataSource.removeDataSource(dsName);
        }
    
    
        /**
         * 切换租户对应的数据源
         *
         * @param tenantId 租户ID即对应数据源名称
         */
        private void changeTenantDs(String tenantId) {
            log.debug("切换数据源:{}", tenantId);
            //设置租户上下文
            TenantContext.setTenant(tenantId);
            //根据tenantId切换数据源
            DynamicDataSourceContextHolder.push(tenantId);
        }
    
        /**
         * 根据租户ID查询数据源连接信息,并生成数据源
         *
         * @param tenantId
         * @return
         */
        private DataSource queryTenantIdToDataSource(String tenantId) {
            TenantEntity tenant = tenantMapper.selectById(tenantId);
            log.debug("find db tenant info by tenantId:{}", tenantId);
            //租户为空则直接返回空
            if (!StringUtils.hasText(tenantId) || ObjectUtils.isEmpty(tenant)) {
                // todo 返回业务异常信息
                return null;
            }
            DbInfo dbInfo = JSON.parseObject(tenant.getDbInfo(), DbInfo.class);
            DataSourceProperty dataSourceProperty = new DataSourceProperty();
            dataSourceProperty.setUrl(dbInfo.getDbUrl());
            dataSourceProperty.setUsername(dbInfo.getDbUsername());
            dataSourceProperty.setPassword(dbInfo.getDbPassword());
            dataSourceProperty.setDriverClassName("com.mysql.cj.jdbc.Driver");
    
            dataSourceProperty.setDruid(new DruidConfig());
            return this.dataSourceCreator.createDataSource(dataSourceProperty);
        }
    }
    

    然后我们会通过实现HandlerInterceptor创建我们自己的TenantDsInterceptor来处理每个请求来的时候TenantId信息

    package com.simple.mybaitsdynamicdatasource.infrastructure.config.handler;
    
    import com.simple.mybaitsdynamicdatasource.infrastructure.config.TenantContext;
    import com.simple.mybaitsdynamicdatasource.infrastructure.service.TenantService;
    import lombok.AllArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.stereotype.Component;
    import org.springframework.web.servlet.HandlerInterceptor;
    
    import javax.annotation.Resource;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    
    @Slf4j
    @Component
    @AllArgsConstructor
    public class TenantDsInterceptor implements HandlerInterceptor {
    
        private TenantService tenantDsService;
    
        /**
         * 在请求处理之前进行调用(Controller方法调用之前)
         */
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
            //todo 从请求中获取租户ID
            String tenantId = "1";
            TenantContext.setTenant(tenantId);
            //根据tenantId切换数据源
            tenantDsService.changeDsByTenantId(tenantId);
            return true;
        }
    
        /**
         * 在整个请求结束之后被调用,也就是在DispatcherServlet 渲染了对应的视图之后执行(主要是用于进行资源清理工作)
         */
        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
            //清空当前线程数据源
            tenantDsService.clearDsContext();
        }
    }
    
    

    然后将我们的TenantDsInterceptor进行注册,

    package com.simple.mybaitsdynamicdatasource.infrastructure.config;
    
    import com.simple.mybaitsdynamicdatasource.infrastructure.config.handler.TenantDsInterceptor;
    import lombok.AllArgsConstructor;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
    
    @Configuration
    @AllArgsConstructor
    public class WebConfigurer implements WebMvcConfigurer {
    
        private TenantDsInterceptor tenantDsInterceptor;
    
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(tenantDsInterceptor).addPathPatterns("/**");
        }
    }
    

    最后我们通过如下方法来进行测试

    
    package com.simple.mybaitsdynamicdatasource.web.controller;
    
    import com.simple.mybaitsdynamicdatasource.infrastructure.db.entity.UserInfoEntity;
    import com.simple.mybaitsdynamicdatasource.infrastructure.service.TenantService;
    import com.simple.mybaitsdynamicdatasource.infrastructure.service.UserInfoService;
    import lombok.AllArgsConstructor;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.PathVariable;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import java.util.ArrayList;
    import java.util.List;
    
    
    @RestController
    @RequestMapping("/user-info")
    @AllArgsConstructor
    public class UserInfoController {
        private UserInfoService userInfoService;
    
        private TenantService tenantService;
    
        @GetMapping("/query/{tenantId}")
        public List query(@PathVariable String tenantId) {
            tenantService.changeDsByTenantId(tenantId);
            return userInfoService.list();
        }
    
        @GetMapping("/query")
        public List queryAll() {
            return userInfoService.list();
        }
    }
    
    


    最后

    我们需要约定好获取TenantId的方式,通过再TenantDsInterceptor中来给上下文进行注入让其能够依据不同的TenantId进行切换数据库

    如有哪里讲得不是很明白或是有错误,欢迎指正
    本文所有的演示代码皆在github 地址如下:https://github.com/benxionghu/mybaits-dynamic-datasource
    如您喜欢的话不妨点个赞收藏一下吧🙂

  • 相关阅读:
    Linux shell编程学习笔记5:变量命名规则、变量类型、使用变量时要注意的事项
    21款奔驰GLS450升级23P驾驶辅助 提升安全出行
    网络信息安全岗位NISP应该如何面试(三)NISP管理中心
    暑假如何保护孩子的视力?适合暑假学习的护眼灯推荐
    开源WIFI继电器之硬件电路
    Vue3 模板语法
    java计算机毕业设计基于springboo+vue的电脑城销售系统
    143.如何个性化推荐系统设计-3
    【毕业设计】基于java+SSH+jsp的网上体育商城设计与实现(毕业论文+程序源码)——网上体育商城
    多目标粒子群算法及其MATLAB实现
  • 原文地址:https://www.cnblogs.com/ancold/p/18195437