java库级隔离Saas化多租户解决方案

多租户方案及对比
1、行级隔离
行隔离,存在数据融合,数据库性能是考研。
2、表级隔离
技术复杂度高,改造难度打。
3、库级隔离
数据隔离,数据安全性得到保证,单个租户数据量少,会造成资源浪费。

最终方案:库级隔离,动态数据源

Spring框架自带多数据源支持,提供AbstractRoutingDataSource抽象类,与我们现有系统整合简单,易于自定义扩展。
配置中心配置是否Saas化部署,则区分与特殊租户,单独部署服务。

Saas部署,需要支持多数据源热加载和删除及动态切换数据源,增加租户管理模块,租户管理相关数据(包含模块租户信息管理、数据源信息管理、定时任务管理等相关功能),租户管理相关数据配置在核心(默认)库,只有登录用户租户ID为1才能到核心数据库查询。
数据库选择,租户独立数据库和租户共享数据库区别:

最后选择,租户独立数据库。

配置中心,配置默认数据源(租户管理数据源),启动过程中加载数据库中已启用的租户数据源。

用户操作流程:

公共模块

import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;

/**
 * @description :配置常量
 */
@RefreshScope
@Component
public class SaasConstants implements InitializingBean {

    @Value("${saas.enable:false}")
    private String saasEnable;

    @Value("${saas.default.tenant:1}")
    private String defaultTenantId;

    /**
     * saas开启区分
     */
    public static String SAAS_ENABLE;
    
    /**
     * 默认租户Id
     */
    public static String DEFAULT_TENANT_ID;
    
    @Override
    public void afterPropertiesSet() {
        SAAS_ENABLE = this.saasEnable;
        DEFAULT_TENANT_ID = this.defaultTenantId;
    }
}

数据源配置,支持事务

import com.ruoyi.system.datasource.DynamicDataSource;
import org.apache.ibatis.mapping.DatabaseIdProvider;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
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.io.IOException;
import java.util.concurrent.ConcurrentHashMap;

/**
 * @description :数据源配置
 */
@Configuration
@EnableTransactionManagement
public class DataSourceConfig {

    @Value("${mybatis.typeAliasesPackage:com.ruoyi.**.mapper}")
    private String typeAliasesPackage;

    @Value("${mybatis.mapperLocations:classpath*:mapper/system-cloud/*.xml}")
    private String mapperLocations;
    
    /**
     * @return {@link DataSource }  
     * @description 默认数据源
     */
    @Bean("defaultSource")
    @ConfigurationProperties("spring.datasource")
    public DataSource defaultSource() {
        return DataSourceBuilder.create().build();
    }


    /**
     * @return {@link DataSource }
     * @description 自定义动态数据源
     */
    @Primary
    @Bean("dynamicDataSource")
    public DataSource dynamicDataSource() {
        return new DynamicDataSource(defaultSource(), new ConcurrentHashMap<>(16));
    }

    /**
     * @return {@link SqlSessionFactoryBean }
     * @description 修改Mybatis数据源配置
     */
    @Bean("sqlSessionFactoryBean")
    public SqlSessionFactoryBean sqlSessionFactoryBean(@Qualifier("databaseIdProvider") DatabaseIdProvider databaseIdProvider) throws IOException {
        SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
        // 配置自定义动态数据源
        sessionFactory.setDataSource(dynamicDataSource());
        // 实体、Mapper类映射
        sessionFactory.setTypeAliasesPackage(typeAliasesPackage);
        sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources(mapperLocations));
        sessionFactory.setDatabaseIdProvider(databaseIdProvider);
        return sessionFactory;
    }


    /**
     * @return {@link PlatformTransactionManager }
     * @description 开启动态数据源@Transactional注解事务管理的支持
     */
    @Bean
    public PlatformTransactionManager transactionManager() {
        return new DataSourceTransactionManager(dynamicDataSource());
    }
}

ThreadLocal实现线程内数据共享

/**
 * @description: 动态数据源管理
 * @version: 1.0.0
 */
public class DataSourceContextHolder {

    /**
     * 数据源key
     */
    private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();

    /**
     * @param key 数据源key
     * @description 切换数据源
     */
    public static void setDataSourceKey(String key) {
        CONTEXT_HOLDER.set(key);
    }

    /**
     * @return {@link String }
     * @description 获取数据源key
     */
    public static String getDataSourceKey() {
        return CONTEXT_HOLDER.get();
    }

    /**
     * @description 重置数据源
     */
    public static void clearDataSourceKey() {
        CONTEXT_HOLDER.remove();
    }
}

spring管理多数据源

import com.ruoyi.common.core.constant.UnimisConstants;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.system.config.SaasConstants;
import com.ruoyi.system.domain.SysTenantDto;
import com.zaxxer.hikari.HikariDataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.util.StringUtils;

import javax.sql.DataSource;
import java.util.Date;
import java.util.Map;

/**
 * @description: 动态数据源实现类 切换数据源必须在调用service之前进行,也就是开启事务之前
 * @version: 1.0.0
 */
public class DynamicDataSource extends AbstractRoutingDataSource {

    private static final Logger logger = LoggerFactory.getLogger(DynamicDataSource.class);

    /**
     * 租户数据源列表
     */
    private Map<Object, Object> dynamicTargetDataSources;

    /**
     * @param defaultTargetDataSource 默认数据源
     * @param targetDataSources       目标数据源
     * @description 决定使用哪个数据源之前需要把多个数据源的信息以及默认数据源信息配置好
     * @date 2023-04-06 15:40:24
     */
    public DynamicDataSource(DataSource defaultTargetDataSource, Map<Object, Object> targetDataSources) {
        setDefaultTargetDataSource(defaultTargetDataSource);
        targetDataSources.put(String.valueOf(UnimisConstants.DEFAULT_TENANT_ID), defaultTargetDataSource);
        setTargetDataSources(targetDataSources);
        this.dynamicTargetDataSources = targetDataSources;
        super.afterPropertiesSet();
    }

    /**
     * @return {@link DataSource }
     * @description 如果不希望数据源在启动配置时就加载好,可以定制这个方法,从任何你希望的地方读取并返回数据源
     *              比如从数据库、文件、外部接口等读取数据源信息,并最终返回一个DataSource实现类对象即可
     */
    @Override
    protected DataSource determineTargetDataSource() {
        return super.determineTargetDataSource();
    }

    /**
     * @return {@link Object }
     * @description :
     * 如果希望所有数据源在启动配置时就加载好,这里通过设置数据源Key值来切换数据,定制这个方法
     *
     * 实现数据源切换要扩展的方法,该方法的返回值就是项目中所要用的DataSource的key值,
     * 拿到该key后就可以在resolvedDataSource中取出对应的DataSource,如果key找不到对应的DataSource就使用默认的数据源。
     */
    @Override
    protected Object determineCurrentLookupKey() {
        String dataSourceName = DataSourceContextHolder.getDataSourceKey();
        if (Boolean.TRUE.toString().equals(SaasConstants.SAAS_ENABLE)) {
            if (!StringUtils.isEmpty(dataSourceName)) {
                if (this.dynamicTargetDataSources.containsKey(dataSourceName)) {
                    logger.info("当前数据源为:{}", dataSourceName);
                } else {
                    logger.info("不存在的数据源:{}", dataSourceName);
                }
            } else {
                logger.info("当前数据源为:默认数据源");
            }
        }
        return dataSourceName;
    }

    /**
     * @param defaultDataSource Object
     * @description 设置默认数据源
     */
    @Override
    public void setDefaultTargetDataSource(Object defaultDataSource) {
        super.setDefaultTargetDataSource(defaultDataSource);
    }

    /**
     * @param dataSources Map<Object, Object>
     * @description 设置数据源集合
     */
    @Override
    public void setTargetDataSources(Map<Object, Object> dataSources) {
        super.setTargetDataSources(dataSources);

        this.dynamicTargetDataSources = dataSources;
    }

    /**
     * @param tenant 租户
     * @description 新版租户热加载
     */
    public R<Boolean> setDataSources(SysTenantDto tenant) {
        try {
            Date expiryDate = tenant.getDowntime();

            // 比对是否过期
            if (expiryDate != null && !expiryDate.after(new Date())) {
                logger.warn("[{}]已经超出有效期,请续签之后再试", tenant.getTenantName());
            }

            HikariDataSource dataSource = DynamicDataSourceFactory.buildHikariDatasource(tenant);
            if (dataSource == null) {
                return R.fail("数据源获取失败");
            }
            // 测试连接
            dataSource.getConnection();

            this.dynamicTargetDataSources.put(tenant.getTenantId().toString(), dataSource);
            setTargetDataSources(this.dynamicTargetDataSources);
            super.afterPropertiesSet();
            logger.info("数据源初始化成功------>" + tenant.getTenantId());
            return R.ok(Boolean.TRUE);
        } catch (Exception e) {
            logger.error("[{}]:数据源连接不上, 可能是连接参数有误!", tenant.getTenantId(), e);
            return R.fail("数据源连接不上");
        }
    }

    /**
     * description 删除租户数据源
     *
     * @param tenantId 租户Id
     **/
    public void removeDataSources(String tenantId) {
        try {
            if (this.dynamicTargetDataSources.containsKey(tenantId)) {
                this.dynamicTargetDataSources.remove(tenantId);
                setTargetDataSources(this.dynamicTargetDataSources);
                super.afterPropertiesSet();
                logger.info("数据源删除成功------>{}", tenantId);
            }
        } catch (Exception e) {
            logger.error("[{}]:数据源删除失败!", tenantId, e);
        }
    }
    
    /**
     * description 判断租户数据源是否存在
     *
     * @param tenantId 租户Id
     * @return java.lang.Boolean
     **/
    public Boolean existsDataSource(String tenantId) {
        return this.dynamicTargetDataSources.containsKey(tenantId);
    }
}

获取HikariDataSource数据源

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.common.core.utils.StringUtils;
import com.ruoyi.system.domain.DataBasePoolParams;
import com.ruoyi.system.domain.SysDatasource;
import com.ruoyi.system.domain.SysTenantDto;
import com.zaxxer.hikari.HikariDataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * @description :数据源工厂
 */
public class DynamicDataSourceFactory {

    private static final Logger logger = LoggerFactory.getLogger(DynamicDataSourceFactory.class);
    
    /**
     * 默认超时 毫秒
     */
    private static final Long IDLE_TIMEOUT_MS_DEFAULT = 180000L;

    /**
     * 连接在连接池中的最大生命周期 默认值
     */
    private static final Long MAX_LIFETIME_MS_DEFAULT = 900000L;
    
    /**
     * 连接超时时间
     */
    private static final Long CONNECTION_TIMEOUT_MS_DEFAULT = 60000L;
    
    /**
     * 验证超时时间
     */
    private static final Long VALIDATION_TIMEOUT_MS_DEFAULT = 3000L;
    
    /**
     * 测试查询sql
     */
    private static final String CONNECTION_TEST_QUERY = "select 1 from dual";

    /**
     * @param tenantDto 租户dto
     * @return {@link HikariDataSource }
     * @description 构建线程池数据源
     **/
    public static HikariDataSource buildHikariDatasource(SysTenantDto tenantDto) {
        SysDatasource datasource = tenantDto.getDatasource();
        if (datasource != null) {
            HikariDataSource hikariDataSource = new HikariDataSource();
            hikariDataSource.setDriverClassName(datasource.getDriver());
            hikariDataSource.setJdbcUrl(datasource.getUrl());
            hikariDataSource.setUsername(datasource.getUsername());
            hikariDataSource.setPassword(datasource.getPassword());
            String poolParams = datasource.getPoolParams();
            DataBasePoolParams params = new DataBasePoolParams();
            if (StringUtils.isNotEmpty(poolParams)) {
                try {
                    logger.info("DataBasePoolParams:{}", poolParams);
                    ObjectMapper objectMapper = new ObjectMapper();
                    params = objectMapper.readValue(poolParams, DataBasePoolParams.class);
                } catch (JsonProcessingException e) {
                    logger.error("JSON对象转换失败", e);
                }
            }
            setPoolParams(hikariDataSource, params);    
            return hikariDataSource;
        }
        return null;
    }
    
    /**
     * description 数据源设置连接池参数
     *
     * @param hikariDataSource 数据源
     * @param params 连接池参数
     **/
    private static void setPoolParams(HikariDataSource hikariDataSource, DataBasePoolParams params) {
        // 最小线程数
        if (params.getMinimumIdle() != null) {
            hikariDataSource.setMinimumIdle(params.getMinimumIdle());
        } else {
            hikariDataSource.setMinimumIdle(2);
        }
        
        // 最大线程数
        if (params.getMaximumPoolSize() != null) {
            hikariDataSource.setMaximumPoolSize(params.getMaximumPoolSize());
        } else {
            hikariDataSource.setMaximumPoolSize(5);
        }
        
        // 超时设置
        if (params.getIdleTimeoutMs() != null) {
            hikariDataSource.setIdleTimeout(params.getIdleTimeoutMs());
        } else {
            hikariDataSource.setIdleTimeout(IDLE_TIMEOUT_MS_DEFAULT);
        }

        // 连接在连接池中的最大生命周期
        if (params.getMaxLifetimeMs() != null) {
            hikariDataSource.setMaxLifetime(params.getMaxLifetimeMs());
        } else {
            hikariDataSource.setMaxLifetime(MAX_LIFETIME_MS_DEFAULT);
        }

        // 自动提交区分
        if (params.getAutoCommit() != null) {
            hikariDataSource.setAutoCommit(params.getAutoCommit());
        } else {
            hikariDataSource.setAutoCommit(Boolean.TRUE);
        }

        // 连接超时时间
        if (params.getConnectionTimeoutMs() != null) {
            hikariDataSource.setConnectionTimeout(params.getConnectionTimeoutMs());
        } else {
            hikariDataSource.setConnectionTimeout(CONNECTION_TIMEOUT_MS_DEFAULT);
        }

        // 连接超时时间
        if (params.getConnectionTimeoutMs() != null) {
            hikariDataSource.setConnectionTimeout(params.getConnectionTimeoutMs());
        } else {
            hikariDataSource.setConnectionTimeout(CONNECTION_TIMEOUT_MS_DEFAULT);
        }

        // 验证超时时间
        if (params.getValidationTimeoutMs() != null) {
            hikariDataSource.setValidationTimeout(params.getValidationTimeoutMs());
        } else {
            hikariDataSource.setConnectionTimeout(VALIDATION_TIMEOUT_MS_DEFAULT);
        }
        // 默认测试查询语句
        hikariDataSource.setConnectionTestQuery(CONNECTION_TEST_QUERY);
    }
}

服务器启动初始化数据源

import com.ruoyi.common.core.constant.Constants;
import com.ruoyi.common.core.constant.UnimisConstants;
import com.ruoyi.system.domain.SysTenantDto;
import com.ruoyi.system.service.ISysTenantService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

/**
 * @description: 租户数据源初始化
 * @version: 1.0.0
 */
@ConditionalOnProperty(prefix = "saas", name = "enable", havingValue = "true")
@Configuration
public class DynamicDataSourceInit {
    private static final Logger logger = LoggerFactory.getLogger(DynamicDataSourceInit.class);

    @Resource
    private ISysTenantService tenantService;

    @Resource
    private DynamicDataSource dynamicDataSource;

    @Resource(name = Constants.THREAD_POOL_NAME)
    private ThreadPoolTaskExecutor poolTaskExecutor;

    /**
     * 初始化租户数据源
     */
    @PostConstruct
    public void initDataSource() {
        
        logger.info("=====初始化租户数据源=====");
        // 加载默认数据源外的其他数据源
        List<SysTenantDto> tenantList = tenantService.selectTenantAll();

        if (tenantList.size() <= 0) {
            logger.info("初始化租户数据源完成, 没有有效租户");
            return;
        }
        
        CountDownLatch countDownLatch = new CountDownLatch(tenantList.size());
        
        tenantList.forEach(sysTenantDto -> {
            poolTaskExecutor.execute(() -> {
                if (sysTenantDto.getTenantId() != UnimisConstants.DEFAULT_TENANT_ID) {
                    dynamicDataSource.setDataSources(sysTenantDto);
                }                
                countDownLatch.countDown();
            });
        });
        try {
            boolean await = countDownLatch.await(2, TimeUnit.MINUTES);
            logger.info("初始化租户数据源完成:{}", await);
        } catch (InterruptedException e) {
           logger.error("初始化租户数据源超时", e);
        }
    }
}

动态数据源切换,中间及程序需要切换模块
1、页面请求,Controller添加代理实现多数据源切换。

import com.ruoyi.common.security.utils.SecurityUtils;
import com.ruoyi.system.datasource.DataSourceContextHolder;
import com.ruoyi.system.domain.SysOperLog;
import com.ruoyi.system.service.ISysTenantService;
import jodd.util.ArraysUtil;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
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.annotation.Resource;
import javax.servlet.http.HttpServletRequest;

/**
 * @description: Aop动态切换多数据源
 * 请注意:这里order一定要小于tx:annotation-driven的order,即先执行DynamicDataSourceAspect切面,再执行事务切面,才能获取到最终的数据源
 * @version: 1.0.0
 */
@ConditionalOnProperty(prefix = "saas", name = "enable", havingValue = "true")
@Aspect
@Order(1)
@Component
public class ControllerDynamicDsAspect {
    
    private static final Logger logger = LoggerFactory.getLogger(ControllerDynamicDsAspect.class);

    @Resource
    private ISysTenantService tenantService;
    
    /**
     * 无需切换数据源请求
     */
    private static final String[] FILTER_URI = {"/operlog"};
    
    /**
     * 日志存储无需切换数据源title
     */
    private static final String[] OPERATE_LOG_TITLE = {"租户管理", "数据源管理"};

    /**
     * 切换数据源
     */
    @Before("execution(public * com.ruoyi.system.controller..*.*(..)) && !execution(* com.ruoyi.system.controller.tenant.*.*(..))")
    public void switchDataSource(JoinPoint joinPoint) {
        // 判断是否登录
        if (SecurityUtils.getLoginUser() == null || SecurityUtils.getLoginUser().getUserDTO() == null 
            || SecurityUtils.getLoginUser().getUserDTO().getSysUser() == null) {
            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            if (attributes == null) {
                throw new RuntimeException("请重新登录");
            }
            HttpServletRequest request = attributes.getRequest();
            String uri = request.getRequestURI();
            if (ArraysUtil.contains(FILTER_URI, uri)) {
                for (Object arg : joinPoint.getArgs()) {
                    if (arg instanceof SysOperLog) {
                        if (ArraysUtil.contains(OPERATE_LOG_TITLE, ((SysOperLog) arg).getTitle())) {
                            return;
                        }
                        // 日志对象中存在租户Id
                        tenantService.switchDataSource(((SysOperLog) arg).getTenantId());
                        return;
                    }
                }
            }
            logger.error("请重新登录租户Id不存在,uri:{}", uri);
            throw new RuntimeException("请重新登录");
        }
        // 租户Id
        Long tenantId = SecurityUtils.getTenantId();
        tenantService.switchDataSource(tenantId);
    }

    /**
     * 重置数据源
     */
    @After("execution(public * com.ruoyi.system.controller..*.*(..)) && !execution(* com.ruoyi.system.controller.tenant.*.*(..))")
    public void restoreDataSource() {
        // 将数据源置为默认数据源
        logger.info("重置数据源");
        DataSourceContextHolder.clearDataSourceKey();
    }
}

2、消息消费(mq)需要支持动态切换数据源。

import com.ruoyi.system.datasource.DataSourceContextHolder;
import com.ruoyi.system.domain.SysTenantDto;
import com.ruoyi.system.service.ISysTenantService;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;

/**
 * @description :动态数据源切面
 */
@ConditionalOnProperty(prefix = "saas", name = "enable", havingValue = "true")
@Component
@Aspect
public class DynamicDataSourceAspect {

    private static final Logger logger = LoggerFactory.getLogger(DynamicDataSourceAspect.class);

    @Resource
    private ISysTenantService tenantService;
    
    @Pointcut("@annotation(com.ruoyi.system.config.annotation.DynamicDataSource)")
    public void dbPointCut(){

    }

    @Before("dbPointCut()")
    public void beforeSwitchDs(JoinPoint point){
        Object[] args = point.getArgs();
        for (Object arg : args) {
            // 租户对象切换
            if (arg instanceof SysTenantDto) {
                tenantService.switchDataSource(((SysTenantDto) arg).getTenantId());
                return;
            } 
            // 租户Id切换
            else if (arg instanceof Long) {
                tenantService.switchDataSource((Long) arg);
                return;
            }
        }
    }

    @After("dbPointCut()")
    public void afterSwitchDs(){
        // 清空数据源
        DataSourceContextHolder.clearDataSourceKey();
    }
}

3、异步线程,线程切换需要子线程继承父线程数据源。
使用spring提供的线程池,增加装饰器解决线程间共享数据。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.ThreadPoolExecutor;

/**
 * @Description 线程池配置
 */
@Configuration(proxyBeanMethods = false)
@EnableAsync
public class ThreadPoolConfig {

    /**
     * 线程池配置
     * @return
     */
    @Bean(Constants.THREAD_POOL_NAME)
    @Primary
    public ThreadPoolTaskExecutor threadPoolTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        //配置核心线程数
        executor.setCorePoolSize(8);
        //配置最大线程数
        executor.setMaxPoolSize(32);
        //配置队列大小
        executor.setQueueCapacity(200);
        //线程池维护线程所允许的空闲时间
        executor.setKeepAliveSeconds(30);
        //配置线程池中的线程的名称前缀
        executor.setThreadNamePrefix("corePool:");
        // 线程池装饰器用于,父子线程数据传递
        executor.setTaskDecorator(new ContextTaskDecorator());
        //设置线程池关闭的时候等待所有任务都完成再继续销毁其他的Bean
        executor.setWaitForTasksToCompleteOnShutdown(true);
        //设置线程池中任务的等待时间,如果超过这个时候还没有销毁就强制销毁,以确保应用最后能够被关闭,而不是阻塞住
        executor.setAwaitTerminationSeconds(60);
        // rejection-policy:当pool已经达到max size的时候,如何处理新任务
        // CALLER_RUNS:不在新线程中执行任务,而是由调用者所在的线程来执行
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        //执行初始化
        executor.initialize();
        return executor;
    }
}

import com.ruoyi.system.datasource.DataSourceContextHolder;
import org.springframework.core.task.TaskDecorator;

/**
 * @description :线程池装饰器
 */
public class ContextTaskDecorator implements TaskDecorator {
    
    @Override
    public Runnable decorate(Runnable runnable) {
        String dataSourceKey = DataSourceContextHolder.getDataSourceKey();
        return () -> {
            try {
                // 将主线程的请求信息,设置到子线程中
                DataSourceContextHolder.setDataSourceKey(dataSourceKey);
                // 执行子线程,这一步不要忘了
                runnable.run();
            } finally {
                // 线程结束,清空这些信息,否则可能造成内存泄漏
                DataSourceContextHolder.clearDataSourceKey();
            }
        };
    }
}

4、单点登录需要支持动态切换数据源。

5、任务调度需要动态切换数据源。

@ConditionalOnProperty(prefix = "saas", name = "enable", havingValue = "true")
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface JobDataSource {
    
}
// 动态代理实现租户并发执行定时任务(QuartzJob)
import com.ruoyi.common.core.constant.Constants;
import com.ruoyi.job.config.datasource.DataSourceContextHolder;
import com.ruoyi.job.domain.SysJob;
import com.ruoyi.job.service.ISysTenantService;
import com.ruoyi.job.util.AbstractQuartzJob;
import com.ruoyi.job.util.SysJobLogUtil;
import com.ruoyi.system.domain.SysTenantDto;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.Date;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

/**
 * @description :动态数据源切面
 */
@ConditionalOnProperty(prefix = "saas", name = "enable", havingValue = "true")
@Component
@Aspect
public class DynamicDataSourceAspect {

    private static final Logger logger = LoggerFactory.getLogger(DynamicDataSourceAspect.class);

    @Resource
    private ISysTenantService tenantService;
    
    @Resource(name = Constants.THREAD_POOL_NAME)
    private ThreadPoolTaskExecutor executor;
    
    @Pointcut("@annotation(com.ruoyi.job.config.annotation.JobDataSource)")
    public void dbPointCut(){

    }

    @Around("dbPointCut()")
    public Object aroundSwitchDs(ProceedingJoinPoint point){
        
        // 任务执行开始时间
        Date startDate = AbstractQuartzJob.threadLocal.get();
        long start = startDate.getTime();
        // 获取当前任务
        SysJob sysJob = SysJobContextHolder.getSysJob();        
        // 获取所有租户
        List<SysTenantDto> tenantList = tenantService.selectTenantAll(Boolean.FALSE);
        CountDownLatch countDownLatch = new CountDownLatch(tenantList.size());
        // 遍历租户 动态切换数据源
        tenantList.forEach(sysTenantDto -> {
            executor.execute(() -> {
                Exception e = null;
                try {
                    DataSourceContextHolder.setDataSourceKey(sysTenantDto.getTenantId().toString());
                    point.proceed(point.getArgs());
                } catch (Throwable throwable) {
                    logger.error("[{}-{}]执行任务调度异常", sysTenantDto.getTenantName(), sysTenantDto.getTenantId(), throwable);
                    e = new RuntimeException("[" + sysTenantDto.getTenantName() + "-" + sysTenantDto.getTenantId() + "]执行任务调度异常");
                } finally {
                    DataSourceContextHolder.clearDataSourceKey();
                    SysJobLogUtil.logHandler(sysTenantDto, startDate, sysJob, e);
                }
                countDownLatch.countDown();
            });
        });
        try {
            boolean await = countDownLatch.await(10, TimeUnit.MINUTES);
            logger.info("[{}]任务执行完成:{} end {} ms.", sysJob.getJobName(), await, System.currentTimeMillis() - start);
        } catch (InterruptedException e) {
            logger.error("[{}]任务执行超时", sysJob.getJobName(), e);
        }
        return null;
    }
}

6、对外接口需要支持动态切换数据源。
同1,controller的实现,header增加租户信息。

import com.alibaba.fastjson.JSONObject;
import com.ruoyi.common.core.constant.HttpStatus;
import com.ruoyi.common.core.constant.UnimisConstants;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.utils.StringUtils;
import com.ruoyi.system.constant.SecurityConstant;
import com.ruoyi.system.utils.AppSecurityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.GenericFilterBean;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.io.PrintWriter;

/**
 * 外部请求过滤器
 *
 * @author ruoyi
 */
@Component
public class ExternalRequestFilter extends GenericFilterBean {

    private static final Logger logger = LoggerFactory.getLogger(ExternalRequestFilter.class);
    
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
            throws IOException, ServletException {

        HttpServletRequest request = (HttpServletRequest) servletRequest;

        String appId = request.getHeader(SecurityConstant.APP_ID);
        String appSecret = request.getHeader(SecurityConstant.APP_SECRET);
        String tenantId = request.getHeader(SecurityConstant.TENANT_ID);

        JSONObject res;
        // 参数非空校验
        if (StringUtils.isEmpty(appId) || StringUtils.isEmpty(appSecret)) {
            logger.error("ExternalRequestFilter|doFilter|appId = {}|appSecret={}", appId, appSecret);
            res = createJSONObject(HttpStatus.BAD_REQUEST, "认证参数缺失");
            outputErrorMsg(servletResponse, res);
            return;
        }
        
        // saas判断
        if (Boolean.TRUE.toString().equals(super.getEnvironment().getProperty(UnimisConstants.SAAS_ENABLE_KEY))) {
            R<?> checkResult = AppSecurityUtils.checkTenantId(tenantId);
            if (checkResult.getCode() != R.SUCCESS) {
                res = createJSONObject(HttpStatus.BAD_REQUEST, checkResult.getMsg());
                outputErrorMsg(servletResponse, res);
                return;
            }
        } else {
            tenantId = String.valueOf(UnimisConstants.DEFAULT_TENANT_ID);
        }

        String appSecretCached;
        try {
            appSecretCached = AppSecurityUtils.getAppSecretCache(tenantId, appId);
        } catch (Exception e) {
            res = createJSONObject(HttpStatus.ERROR, "Redis服务异常");
            logger.error("ExternalRequestFilter|doFilter|appId = {}|appSecret={}|e={}", appId, appSecret, "Redis服务异常", e);
            outputErrorMsg(servletResponse, res);
            return;
        }

        // 参数有效性校验
        if (StringUtils.isEmpty(appSecretCached) || !appSecret.equals(appSecretCached)) {
            res = createJSONObject(HttpStatus.UNAUTHORIZED, "认证失败");
            outputErrorMsg(servletResponse, res);
            logger.error("ExternalRequestFilter|doFilter|appId = {}|appSecret={}|e={}", appId, appSecret, "认证失败");
            return;
        }
        // 设置租户默认值
        HeaderMapRequestWrapper requestWrapper = new HeaderMapRequestWrapper(request);
        if (StringUtils.isEmpty(requestWrapper.getHeader(SecurityConstant.TENANT_ID))) {
            requestWrapper.addHeader(SecurityConstant.TENANT_ID, tenantId);
        }        
        filterChain.doFilter(requestWrapper, servletResponse);
    }

    /**
     * description 错误输出
     *
     * @param servletResponse 返回	
     * @param res 返回信息
     **/
    private void outputErrorMsg(ServletResponse servletResponse, JSONObject res) throws IOException {

        servletResponse.setCharacterEncoding("UTF-8");
        servletResponse.setContentType("application/json; charset=utf-8");

        PrintWriter out = servletResponse.getWriter();
        out.append(res.toString());
    }
    
    /**
     * description 创建JSONObject
     *
     * @param code 编码
     * @param msg 消息
     * @return com.alibaba.fastjson.JSONObject
     **/
    private JSONObject createJSONObject(int code, String msg) {
        JSONObject res = new JSONObject();
        res.put("msg", msg);
        res.put("code", code);
        return res;
    }
}
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * @description :重写header请求
 */
public class HeaderMapRequestWrapper extends HttpServletRequestWrapper {

    public HeaderMapRequestWrapper(HttpServletRequest request) {
        super(request);
    }

    private Map<String, String> headerMap = new HashMap<>();

    public void addHeader(String name, String value) {
        headerMap.put(name, value);
    }

    @Override
    public String getHeader(String name) {
        String headerValue = super.getHeader(name);
        if (headerMap.containsKey(name)) {
            headerValue = headerMap.get(name);
        }
        return headerValue;
    }

    @Override
    public Enumeration<String> getHeaderNames() {
        List<String> names = Collections.list(super.getHeaderNames());
        names.addAll(headerMap.keySet());
        return Collections.enumeration(names);
    }

    @Override
    public Enumeration<String> getHeaders(String name) {
        List<String> values = Collections.list(super.getHeaders(name));
        if (headerMap.containsKey(name)) {
            values.add(headerMap.get(name));
        }
        return Collections.enumeration(values);
    }
}

参照:https://blog.csdn.net/u014528861/article/details/116655292

多租户架构设计:https://mp.weixin.qq.com/s/XS4gqDjNslU1TRhnogZHvw

posted @ 2023-06-30 15:30  倔强的老铁  阅读(1939)  评论(0编辑  收藏  举报