MyBatis-Plus 實現多租戶管理的實踐

初夏的阳光丶發表於2024-05-16

本文主要講解使用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

程式碼如下

首先我的程式碼框架具體如下

其中實現動態切換資料來源的操作主要在我們的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();
    }
}


最後

我們需要約定好獲取TenantId的方式,透過再TenantDsInterceptor中來給上下文進行注入讓其能夠依據不同的TenantId進行切換資料庫

如有哪裡講得不是很明白或是有錯誤,歡迎指正
本文所有的演示程式碼皆在github 地址如下:https://github.com/benxionghu/mybaits-dynamic-datasource
如您喜歡的話不妨點個贊收藏一下吧🙂

相關文章