3

SAAS简介

【简介引自】Spring Boot实现SAAS平台的基本思路
一、SAAS什么

 SaaS是Software-as-a-service(软件即服务)它是一种通过Internet提供软件的模式,厂商将应用软件统一部署在自己的服务器

   上,客户可以根据自己实际需求,通过互联网向厂商定购所需的应用软件服务,按定购的服务多少和时间长短向厂商支付费用,

 并通过互联网获得厂商提供的服务。用户不用再购买软件,而改用向提供商租用基于Web的软件,来管理企业经营活动,且无需

 对软件进行维护,服务提供商会全权管理和维护软件。

二、SAAS模式有哪些角色

 ①服务商:服务商主要是管理租户信息,按照不同的平台需求可能还需要统合整个平台的数据,作为大数据的基础。服务商在SAAS

 模式中是提供服务的厂商。

 ②租户:租户就是购买/租用服务商提供服务的用户,租户购买服务后可以享受相应的产品服务。现在很多SAAS化的产品都会划分

 系统版本,不同的版本开放不同的功能,还有基于功能收费之类的,不同的租户购买不同版本的系统后享受的服务也不一样。

   三、SAAS模式有哪些特点

①独立性:每个租户的系统相互独立。

②平台性:所有租户归平台统一管理。

③隔离性:每个租户的数据相互隔离。

在以上三个特性里面,SAAS系统中最重要的一个标志就是数据隔离性,租户间的数据完全独立隔离。

四、数据隔离有哪些方案

①独立数据库

   即一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本较高。

   优点:

   为不同的租户提供独立的数据库,有助于简化数据模型的扩展设计,满足不同租户的独特需求,如果出现故障,恢复数据比较简单。

   缺点:

   增多了数据库的安装数量,随之带来维护成本和购置成本的增加。 如果定价较低,产品走低价路线,这种方案一般对运营商来说是无法承受的。

②共享数据库,隔离数据架构

   即多个或所有租户共享数据库,但是每个租户一个Schema。

   优点:

   为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离,每个数据库可支持更多的租户数量。

   缺点:

   如果出现故障,数据恢复比较困难,因为恢复数据库将牵涉到其他租户的数据 如果需要跨租户统计数据,存在一定困难。

③共享数据库,共享数据架构

   即租户共享同一个数据库、同一个Schema,但在表中增加TenantID多租户的数据字段。这是共享程度最高、隔离级别最低的模式。 

   优点:

   三种方案比较,第三种方案的维护和购置成本最低,允许每个数据库支持的租户数量最多。

缺点:

隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量,数据备份和恢复最困难,需要逐表逐条备份和还原。

如果希望以最少的服务器为最多的租户提供服务,并且租户接受牺牲隔离级别换取降低成本,这种方案最适合。

基于spring boot 、Mybatis-Plus实现动态切换数据源实现多租户SaaS系统

注: 该模块基于此项目 搭建Springboot项目并集成Mybatis-Plus 的环境基础上,进行搭建部署。

创建数据库表

创建 people_config 表结构如下:
表结构
其对应的数据库 Schema 脚本如下:

DROP TABLE IF EXISTS `people_config`;
CREATE TABLE `people_config`  (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键Id',
  `empl_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '职员Id',
  `phone` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '手机号',
  `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '姓名',
  `area_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '区域Id',
  `area_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '区域名称',
  `dept_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '部门Id',
  `dept_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '部门名称',
  `create_time` datetime(0) NULL DEFAULT NULL COMMENT '提交时间',
  `create_user` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '创建人',
  `create_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '创建人Id',
  `update_time` datetime(0) NULL DEFAULT NULL COMMENT '修改时间',
  `update_user` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '修改人',
  `update_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '修改人_id',
  `is_delete` int NULL DEFAULT NULL COMMENT '是否删除',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;

SET FOREIGN_KEY_CHECKS = 1;

其对应的数据库 Data 脚本如下:

DELETE FROM people_config;

INSERT INTO `mybatis_plus`.`tbl_people_config`(`id`, `empl_id`, `phone`, `name`, `area_id`, `area_name`, `dept_id`, `dept_name`, `create_time`, `create_user`, `create_id`, `update_time`, `update_user`, `update_id`, `is_delete`) VALUES (1, '职员Id 1', '17343759359', 'isWulongbo', '波波区域', '1区块链', '1', '开发部', '2020-12-05 15:12:00', '波波', '1', NULL, NULL, NULL, 0);
INSERT INTO `mybatis_plus`.`tbl_people_config`(`id`, `empl_id`, `phone`, `name`, `area_id`, `area_name`, `dept_id`, `dept_name`, `create_time`, `create_user`, `create_id`, `update_time`, `update_user`, `update_id`, `is_delete`) VALUES (2, '职员Id 2', '13549553864', 'isWulongtao', '涛涛区域', '2区块链', '2', '军区部', '2020-12-05 16:07:59', '涛涛', '2', NULL, NULL, NULL, 0);

创建 tenant_info 表结构如下:
数据库表结构
其对应的数据库 Schema 脚本如下:

DROP TABLE IF EXISTS `tenant_info`;
CREATE TABLE `tenant_info`  (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键id',
  `tenant_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '租户id',
  `tenant_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '租户名称',
  `datasource_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '数据源url',
  `datasource_username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '数据源用户名',
  `datasource_password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '数据源密码',
  `datasource_driver` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '数据源驱动',
  `system_account` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '系统账号',
  `system_password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '账号密码',
  `system_project` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '系统PROJECT',
  `status` int NULL DEFAULT NULL COMMENT '是否启用(1是0否)',
  `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;

其对应的数据库 Data 脚本如下:

DELETE FROM tenant_info;

INSERT INTO `tenant_info` VALUES (1, '1', 'test1', 'jdbc:mysql://127.0.0.1:3306/mybatis_plus?serverTimezone=Asia/Shanghai&allowMultiQueries=true&zeroDateTimeBehavior=convertToNull', 'root', 'root', 'com.mysql.cj.jdbc.Driver', 'baba', '123456', '管理', 1, '2020-12-05 14:50:50', NULL);
INSERT INTO `tenant_info` VALUES (2, '2', 'test2', 'jdbc:mysql://云服务器ip:3306/mybatis_plus?useUnicode=true&characterEncoding=utf-8&autoReconnect=true&useSSL=false&serverTimezone=Asia/Shanghai', '云服务器数据库连接账号', '云服务器数据库连接密码', 'com.mysql.cj.jdbc.Driver', 'baba2', '123456', 'admin', 1, '2020-12-05 15:06:38', NULL);
INSERT INTO `tenant_info` VALUES (3, '3', 'test3', 'jdbc:mysql://127.0.0.1:3306/mybatis_plus_one?serverTimezone=Asia/Shanghai&allowMultiQueries=true&zeroDateTimeBehavior=convertToNull', 'root', 'root', 'com.mysql.cj.jdbc.Driver', 'baba3', '123456', 'root', 1, '2020-12-05 16:16:21', NULL);

在额外创建一个数据库名:mybatis_plus_one 用作展示切换数据源,复制 people_config 表并稍作数据调整,或者直接copy下面的 Schema 脚本Data 脚本

Schema 脚本:

DROP TABLE IF EXISTS `people_config`;
CREATE TABLE `people_config`  (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键Id',
  `empl_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '职员Id',
  `phone` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '手机号',
  `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '姓名',
  `area_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '区域Id',
  `area_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '区域名称',
  `dept_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '部门Id',
  `dept_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '部门名称',
  `create_time` datetime(0) NULL DEFAULT NULL COMMENT '提交时间',
  `create_user` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '创建人',
  `create_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '创建人Id',
  `update_time` datetime(0) NULL DEFAULT NULL COMMENT '修改时间',
  `update_user` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '修改人',
  `update_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '修改人_id',
  `is_delete` int NULL DEFAULT NULL COMMENT '是否删除',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;

SET FOREIGN_KEY_CHECKS = 1;

Data 脚本:

INSERT INTO `people_config` VALUES (1, '职员Id 1 one', '17343759359', 'isWulongbo one', '波波区域 one', '1区块链', '1', '开发部 one', '2020-12-05 15:12:00', '波波 one', '1', NULL, NULL, NULL, 0);
INSERT INTO `people_config` VALUES (2, '职员Id 2 one', '13549553864', 'isWulongtao one', '涛涛区域 one', '2区块链', '2', '军区部 one', '2020-12-05 16:07:59', '涛涛 one', '2', NULL, NULL, NULL, 0);

创建好后目录结构如下:

对比差异

生成mybatisplus的相关代码

执行 GeneratorCodeConfig 并输入表名:people_configtenant_info
自动生成
回车后,目录结构如下:
关山点酒
注意TenantInfo表: 数据库 datetime 类型的会映射为 LocalDateTime我们将其修改为 Date类型

核心代码

在该项目中 新建一个 package 命名为:datasource
创建 动态数据源 DynamicDataSource

package com.mybatis.plus.datasource;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import javax.sql.DataSource;
import java.util.Map;
/**
 * 动态数据源
 *
 * @author wulongbo
 * @version 1.0
 * @Description 动态数据源
 * @date 2020/12/5 21:48
 */
public class DynamicDataSource extends AbstractRoutingDataSource {
    /**
 * 如果不希望数据源在启动配置时就加载好,可以定制这个方法,从任何你希望的地方读取并返回数据源
 * 比如从数据库、文件、外部接口等读取数据源信息,并最终返回一个DataSource实现类对象即可
 * @return
 */
 @Override
 protected DataSource determineTargetDataSource() {
        return super.determineTargetDataSource();
 }
    /**
 * 如果希望所有数据源在启动配置时就加载好,这里通过设置数据源Key值来切换数据,定制这个方法
 * @return
 */
 @Override
 protected Object determineCurrentLookupKey() {
        return DynamicDataSourceContextHolder.getDataSourceKey();
 }
    /**
 * 设置默认数据源
 * @param defaultDataSource
 */
 public void setDefaultDataSource(Object defaultDataSource) {
        super.setDefaultTargetDataSource(defaultDataSource);
 }
    public void setDataSources(Map<Object, Object> dataSources) {
        super.setTargetDataSources(dataSources);
 // TODO 将数据源的 key 放到数据源上下文的 key 集合中,用于切换时判断数据源是否有效
 DynamicDataSourceContextHolder.addDataSourceKeys(dataSources.keySet());
 }
}

创建动态数据源切面拦截 DynamicDataSourceAspect

package com.mybatis.plus.datasource;
import com.mybatis.plus.base.BaseResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
/**
 * 动态数据源
 *
 * @author wulongbo
 * @version 1.0
 * @Description 动态数据源切面拦截
 * @date 2020/12/5 18:47
 */@Slf4j
@Aspect
@Component
@Order(1) // 请注意:这里order一定要小于tx:annotation-driven的order,即先执行DynamicDataSourceAspectAdvice切面,再执行事务切面,才能获取到最终的数据源
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class DynamicDataSourceAspect {
    @Around("execution(* com.mybatis.plus.controller.*.*(..)) || execution(* com.mybatis.plus.*.*(..))")
    public Object doAround(ProceedingJoinPoint jp) throws Throwable {
        ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
 Object result = null;
 try {
            HttpServletRequest request = sra.getRequest();
 HttpSession session = sra.getRequest().getSession(true);
 String tenantId = (String)session.getAttribute("tenantId");
 if (StringUtils.isEmpty(tenantId)) {
                tenantId = request.getParameter("tenantId");
 }
            log.info("当前租户Id:{}", tenantId);
 if (!StringUtils.isEmpty(tenantId)) {
                DynamicDataSourceContextHolder.setDataSourceKey(tenantId);
 result = jp.proceed();
 } else {
                result = "查询失败,当前租户信息未取到,请联系技术专家!";
 }
        } catch (Exception ex) {
            ex.printStackTrace();
 result = "系统异常,请联系技术专家!";
 } finally {
           DynamicDataSourceContextHolder.clearDataSourceKey();
 }
        return result;
 }
}

创建动态数据源上下文 DynamicDataSourceContextHolder

package com.mybatis.plus.datasource;
import org.apache.commons.lang3.StringUtils;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
 * 动态数据源
 *
 * @author wulongbo
 * @version 1.0
 * @Description 动态数据源上下文
 * @date 2020/12/5 14:11
 */
 public class DynamicDataSourceContextHolder {
    private static final ThreadLocal<String> contextHolder = new ThreadLocal<String>() {
        /**
 * 将 master 数据源的 key作为默认数据源的 key
 */ @Override
 protected String initialValue() {
            return "master";
 }
    };
 /**
 * 数据源的 key集合,用于切换时判断数据源是否存在
 */
 public static List<Object> dataSourceKeys = new ArrayList<>();
 /**
 * 切换数据源
 * @param key 数据源
 */
 public static void setDataSourceKey(String key) {
        if (!StringUtils.isEmpty(key)) {
            contextHolder.set(key);
 }
    }
    /**
 * 获取数据源
 * @return
 */
 public static String getDataSourceKey() {
        return contextHolder.get();
 }
    /**
 * 重置数据源
 */
 public static void clearDataSourceKey() {
        contextHolder.remove();
 }
    /**
 * 判断是否包含数据源
 * @param key 数据源
 * @return
 */
 public static boolean containDataSourceKey(String key) {
        return dataSourceKeys.contains(key);
 }
    /**
 * 添加数据源Keys
 * @param keys
 * @return
 */
 public static boolean addDataSourceKeys(Collection<? extends Object> keys) {
        return dataSourceKeys.addAll(keys);
 }
}

动态数据源初始化 DynamicDataSourceInit

package com.mybatis.plus.datasource;
import com.mybatis.plus.entity.TenantInfo;
import com.mybatis.plus.service.ITenantInfoService;
import com.mybatis.plus.util.SpringContextUtils;
import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
 * 动态数据源
 *
 * @author wulongbo
 * @version 1.0
 * @Description 动态数据源初始化
 * @date 2020/12/5 11:11
 */
@Slf4j
@Configuration
public class DynamicDataSourceInit {
    @Autowired
 private ITenantInfoService tenantInfoService;
 @Bean
 public void initDataSource() {
        log.info("======初始化动态数据源=====");
 DynamicDataSource dynamicDataSource = (DynamicDataSource) SpringContextUtils.getBean("dynamicDataSource");
 HikariDataSource master = (HikariDataSource) SpringContextUtils.getBean("master");
 Map<Object, Object> dataSourceMap = new HashMap<>();
 dataSourceMap.put("master", master);
 List<TenantInfo> tenantList = tenantInfoService.list();
 for (TenantInfo tenantInfo : tenantList) {
            log.info(tenantInfo.toString());
 HikariDataSource dataSource = new HikariDataSource();
 dataSource.setDriverClassName(tenantInfo.getDatasourceDriver());
 dataSource.setJdbcUrl(tenantInfo.getDatasourceUrl());
 dataSource.setUsername(tenantInfo.getDatasourceUsername());
 dataSource.setPassword(tenantInfo.getDatasourcePassword());
 dataSource.setDataSourceProperties(master.getDataSourceProperties());
 dataSourceMap.put(tenantInfo.getTenantId(), dataSource);
 }
        // 设置数据源
 dynamicDataSource.setDataSources(dataSourceMap);
 /**
 * 必须执行此操作,才会重新初始化AbstractRoutingDataSource 中的 resolvedDataSources,也只有这样,动态切换才会起效
 */
 dynamicDataSource.afterPropertiesSet();
 }
}

创建MyBatisPlus配置类 MybatisPlusConfig

package com.mybatis.plus.datasource;
import com.baomidou.mybatisplus.core.parser.ISqlParser;
import com.baomidou.mybatisplus.extension.parsers.BlockAttackSqlParser;
import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.apache.ibatis.plugin.Interceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import javax.sql.DataSource;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
 * 动态数据源
 *
 * @author wulongbo
 * @version 1.0
 * @Description MyBatisPlus配置
 * @date 2020/12/5 15:07
 */
@EnableTransactionManagement
@Configuration
@MapperScan({"com.mybatis.plus.mapper","com.mybatis.plus.*.*.mapper"})
public class MybatisPlusConfig {
    @Bean("master")
    @Primary
 @ConfigurationProperties(prefix = "spring.datasource.hikari")
    public DataSource master() {
        return DataSourceBuilder.create().build();
 }
    @Bean("dynamicDataSource")
    public DataSource dynamicDataSource() {
        DynamicDataSource dynamicDataSource = new DynamicDataSource();
 Map<Object, Object> dataSourceMap = new HashMap<>();
 dataSourceMap.put("master", master());
 // 将 master 数据源作为默认指定的数据源
 dynamicDataSource.setDefaultDataSource(master());
 // 将 master 和 slave 数据源作为指定的数据源
 dynamicDataSource.setDataSources(dataSourceMap);
 return dynamicDataSource;
 }
    @Bean
 public MybatisSqlSessionFactoryBean sqlSessionFactoryBean() throws Exception {
        MybatisSqlSessionFactoryBean sessionFactory = new MybatisSqlSessionFactoryBean();
 /**
 * 重点,使分页插件生效
 */
 Interceptor[] plugins = new Interceptor[1];
 plugins[0] = paginationInterceptor();
 sessionFactory.setPlugins(plugins);
 //配置数据源,此处配置为关键配置,如果没有将 dynamicDataSource作为数据源则不能实现切换
 sessionFactory.setDataSource(dynamicDataSource());
 // 扫描Model
 sessionFactory.setTypeAliasesPackage("com.mybatis.plus.*.*.entity,com.mybatis.plus.entity");
 PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
 // 扫描映射文件
 sessionFactory.setMapperLocations(resolver.getResources("classpath*:mapper/*.xml"));
 return sessionFactory;
 }
    @Bean
 public PlatformTransactionManager transactionManager() {
        // 配置事务管理, 使用事务时在方法头部添加@Transactional注解即可
 return new DataSourceTransactionManager(dynamicDataSource());
 }
    @Bean
 public PaginationInterceptor paginationInterceptor() {
        PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
 List<ISqlParser> sqlParserList = new ArrayList<>();
 // 攻击 SQL 阻断解析器、加入解析链
 sqlParserList.add(new BlockAttackSqlParser());
 paginationInterceptor.setSqlParserList(sqlParserList);
 return paginationInterceptor;
 }
}

并在 application.properties 文件中配置默认数据源配置

spring.datasource.hikari.username=root
spring.datasource.hikari.password=root
spring.datasource.hikari.jdbc-url=jdbc:mysql://127.0.0.1:3306/mybatis_plus?serverTimezone=Asia/Shanghai&allowMultiQueries=true&zeroDateTimeBehavior=convertToNull
spring.datasource.hikari.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.hikari.connection-test-query = SELECT 1
spring.datasource.hikari.idle-timeout = 30000
spring.datasource.hikari.max-lifetime = 1880000
spring.datasource.hikari.connection-timeout = 30000
spring.datasource.hikari.minimum-idle = 5
spring.datasource.hikari.validation-timeout = 60000

创建好后的目录结构如下:
目录结构

由于我们在 MybatisPlusConfig 会去扫 mapper 包 以及读取 application.properties 配置,所以切记我们在启动类 MybatisPlusApplication中别再重复扫描DAO,并且要排除SpringBootApplication 的默认数据源加载

综上所述:MybatisPlusApplication 修改如下:

package com.mybatis.plus;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
//@MapperScan(basePackages = {"com.mybatis.plus"}) //扫描DAO
public class MybatisPlusApplication {
    public static void main(String[] args) {
        SpringApplication.run(MybatisPlusApplication.class, args);
 }
}

下面简单写一下控制页面测试代码:

PeopleConfigController

package com.mybatis.plus.controller;
import com.alibaba.fastjson.JSONObject;
import com.mybatis.plus.base.BaseApiService;
import com.mybatis.plus.base.BaseResponse;
import com.mybatis.plus.entity.PeopleConfig;
import com.mybatis.plus.service.IPeopleConfigService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
 * <p>
 * 前端控制器
 * </p>
 *
 * @author wulongbo
 * @since 2020-12-05
 */
@RestController
@RequestMapping("/peopleConfig")
public class PeopleConfigController extends BaseApiService {
    @Autowired
 private IPeopleConfigService peopleConfigService;
 @GetMapping("/info")
    public BaseResponse<List<PeopleConfig>> getInfo() {
        List<PeopleConfig> list = peopleConfigService.list();
 return setResultSuccess(list);
 }
}

TenantInfoController

package com.mybatis.plus.controller;
import com.mybatis.plus.base.BaseApiService;
import com.mybatis.plus.base.BaseResponse;
import com.mybatis.plus.datasource.DynamicDataSource;
import com.mybatis.plus.entity.TenantInfo;
import com.mybatis.plus.service.ITenantInfoService;
import com.mybatis.plus.util.SpringContextUtils;
import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
 * <p>
 * 前端控制器
 * </p>
 *
 * @author wulongbo
 * @since 2020-12-05
 */
@Slf4j
@RestController
@RequestMapping("/tenantInfo")
public class TenantInfoController  extends BaseApiService {
    @Autowired
 private ITenantInfoService tenantInfoService;
 @GetMapping("/info")
    public BaseResponse<TenantInfo> getInfo() {
        List<TenantInfo> list = tenantInfoService.list();
 return setResultSuccess(list);
 }
    @GetMapping("/save")
    public BaseResponse<?> saveInfo() {
        TenantInfo tenantInfo = new TenantInfo();
 tenantInfo.setTenantName("动态新增");
 tenantInfo.setDatasourceUrl("jdbc:mysql://localhost:3306/mybatis_plus?serverTimezone=Asia/Shanghai&allowMultiQueries=true&zeroDateTimeBehavior=convertToNull");
 tenantInfo.setDatasourceUsername("root");
 tenantInfo.setDatasourcePassword("root");
 tenantInfo.setDatasourceDriver("com.mysql.cj.jdbc.Driver");
 tenantInfo.setStatus(1);
 tenantInfo.setCreateTime(new Date(System.currentTimeMillis()));
 tenantInfo.setUpdateTime(new Date(System.currentTimeMillis()));
 boolean b = tenantInfoService.save(tenantInfo);
 if (b) {
            log.info("======初始化动态数据源=====");
 DynamicDataSource dynamicDataSource = (DynamicDataSource) SpringContextUtils.getBean("dynamicDataSource");
 HikariDataSource master = (HikariDataSource) SpringContextUtils.getBean("master");
 Map<Object, Object> dataSourceMap = new HashMap<>();
 dataSourceMap.put("master", master);
 List<TenantInfo> tenantList = tenantInfoService.list();
 for (TenantInfo tenantInfos : tenantList) {
                log.info(tenantInfos.getTenantId() + "     " + tenantInfos.getTenantName());
 HikariDataSource dataSource = new HikariDataSource();
 dataSource.setDriverClassName(tenantInfos.getDatasourceDriver());
 dataSource.setJdbcUrl(tenantInfos.getDatasourceUrl());
 dataSource.setUsername(tenantInfos.getDatasourceUsername());
 dataSource.setPassword(tenantInfos.getDatasourcePassword());
 dataSource.setDataSourceProperties(master.getDataSourceProperties());
 dataSourceMap.put(tenantInfos.getTenantId(), dataSource);
 }
            // 设置数据源
 dynamicDataSource.setDataSources(dataSourceMap);
 /**
 * 必须执行此操作,才会重新初始化AbstractRoutingDataSource 中的 resolvedDataSources,也只有这样,动态切换才会起效
 */
 dynamicDataSource.afterPropertiesSet();
 }
        return b ? setResultSuccess("初始化动态数据源成功!") : setResultError("初始化动态数据源失败!");
 }
}

OK! 现在动态数据源配置就已完成了!

启动Springboot项目

启动 MybatisPlusApplication
image.png

测试

流程分析

  1. 事务管理器加载 master 数据源作为默认指定的数据源
  2. 通过 dynamicDataSource beanId 在 applicationContext上下文容器中获得 master 数据源,在租户信息表 tenant_info 中查出租户列表,做为数据源配置信息给后续接口访问。
  3. 访问接口时,在指定包下的路径会被拦截,这里做了环绕增强(包含前置和后置),ProceedingJoinPoint jp 核心方法:
Object[] objs = jp.getArgs(); // 获取参数
String[] argNames = ((MethodSignature) jp.getSignature()).getParameterNames(); // 参数名
Object result = jp.proceed(); // 执行接口方法

预期结果

我们成功新建了两个本地数据库(有云服务器的也可以在云MySQL中新建),包含一张表名相同的表,请求期望效果如下:

  1. 访问 localhost:8080/peopleConfig/info ,当传递 tenantId 值时,由于做了AOP切面环绕通知,将匹配其对应的数据源,并执行相应的数据操作;
  2. 修改传递的 tenantId 值,匹配的数据源不一致,得到的数据也是不一致的。

Postman测试结果

使用 Postman 访问 localhost:8080/peopleConfig/info 需要我们在Body上带上tenantId
tenantId=1
核对我们我们的数据源信息 jdbc:mysql://127.0.0.1:3306/mybatis_plus?serverTimezone=Asia/Shanghai&allowMultiQueries=true&zeroDateTimeBehavior=convertToNull
对比 mybatis_plus 数据库 people_config 表信息:
不明白刘德华和关之琳为啥不是情侣啊

一致!OK,更改 tenantId 值为3,并访问 swagger
世上已不再有梅艳芳
核对我们我们的数据源信息 jdbc:mysql://127.0.0.1:3306/mybatis_plus_one?serverTimezone=Asia/Shanghai&allowMultiQueries=true&zeroDateTimeBehavior=convertToNull
对比 mybatis_plus_one 数据库 people_config 表信息:
那一年,迷上了郭富城
一致!说明数据源已实现动态切换。

至此Springboot动态切换数据源实现多租户SaaS案例就演示完毕!


isWulongbo
228 声望26 粉丝

在人生的头三十年,你培养习惯,后三十年,习惯铸就你