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

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.22</druid.version>         <fastjson.version>2.0.39</fastjson.version>         <dynamic.ds.version>3.5.1</dynamic.ds.version>         <mybatis-plus.generator.version>3.5.1</mybatis-plus.generator.version>  </properties>    <dependency>             <groupId>com.baomidou</groupId>             <artifactId>mybatis-plus-boot-starter</artifactId>             <version>3.5.2</version>  </dependency> <dependency>             <groupId>mysql</groupId>             <artifactId>mysql-connector-java</artifactId>             <version>8.0.33</version> </dependency>  <dependency>             <groupId>com.baomidou</groupId>             <artifactId>dynamic-datasource-spring-boot-starter</artifactId>             <version>${dynamic.ds.version}</version> </dependency>  <dependency>             <groupId>com.alibaba</groupId>             <artifactId>druid-spring-boot-starter</artifactId>             <version>${druid.version}</version> </dependency>  <dependency>             <groupId>com.baomidou</groupId>             <artifactId>mybatis-plus-generator</artifactId>             <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  

代码如下

首先我的代码框架具体如下
MyBatis-Plus 实现多租户管理的实践

其中实现动态切换数据源的操作主要在我们的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<TenantMapper, TenantEntity> 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<UserInfoEntity> query(@PathVariable String tenantId) {         tenantService.changeDsByTenantId(tenantId);         return userInfoService.list();     }      @GetMapping("/query")     public List<UserInfoEntity> queryAll() {         return userInfoService.list();     } }  

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

最后

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

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

发表评论

评论已关闭。

相关文章