前言

saas  软件即服务   现在的软件服务提供商提供一套页面给各个租户,通过一个申请页面

填写租户的租户信息,点击生成,租户就可以有一套自己的系统,可以自己去新建用户,角色,授权等操作。

其实这就是所谓的多租户技术。

多租户,通俗点说,多个租户共用同一套服务提供商提供系统资源,即跟现在流行的共享单车,充电宝差不多。

多租户更多跟云计算在一起,因为你有的客户需求大,付费多,那它分配的计算资源和功能更多,比如有自己独立的应用实例,数据库,硬盘空间等。

这个跟云计算的概念就差不多,云计算就是计算资源,理论上云上的资源的无限大的。

多租户隔离级别

多租户主要就是数据隔离,具体来说有三种:

1. 独立数据库    
2. 共享数据库,独立 Schema
3. 共享数据库,共享 Schema,共享数据表

第一种消耗资源最多,就是租户有独立的数据库实例。

第二种在一个数据库实例中每一个租户建立一个Schema数据库,这个也有个问题,当你租户很多的话,对应的表也更多,堆数据库性能也有影响

第三种实例,数据库,表都共享,基于一个租户字段进行隔离,这种成本最低,隔离性也最差。

架构图

第三种网上有很多demo,大部分都是通过mybatis plus 在数据库操作时增加一个租户字段。

第一和第二种差不多,都可以通过动态切换数据源的方法来达到。下面我就只要来讲第二种

下面是一个架构图

java sass多租户原理 saas 多租户模式_多租户

如上图所示:租户可以通过多租户系统申请应用和资源,审核通过后,租户信息同步到redis当中,同时根据的租户类型在对应的数据库实例中初始化库和表,

你也可以自己手动在mysql库中自己增加几个一样的数据库,数据初始脚本在项目里。

ps 我的应用系统项目来自 https://github.com/Heeexy/SpringBoot-Shiro-Vue  这是一个简单的springboot +vue的项目,为了增加动态数据源,我讲spring-boot-starter-parent版本升级为2.3.4.RELAERS版本。

java sass多租户原理 saas 多租户模式_java sass多租户原理_02

可以导入我修改后的代码

1,增加了 shrek-tanent模块,改模块是spring-boot-starter模块,通过启动类加入注解@EnableTenant启动租户模式,

主要代码shrek-tanent代码如下:

DynamicRoutingDataSourceHolder.java  动态数据源持有类
public class DynamicRoutingDataSourceHolder {

    public static final String PRIMARY_DATASOURCE = "primaryDatasource";


    private static final ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<>();

    public static void putKey(String name) {
        THREAD_LOCAL.set(name);
    }

    public static String getKey() {
        String key = THREAD_LOCAL.get();
        if (key == null) {
            key = PRIMARY_DATASOURCE;
            putKey(key);
        }
        return key;
    }

    public static void removeKey() {
        THREAD_LOCAL.remove();
    }
}
DynamicRoutingDataSource.java  动态数据源类
public class DynamicRoutingDataSource extends AbstractRoutingDataSource {

//获取当前请求线程的数据源持有
    @Override
    protected Object determineCurrentLookupKey() {
        return DynamicRoutingDataSourceHolder.getKey();
    }
//设置多数据源map
    @Override
    public void setTargetDataSources(Map<Object, Object> targetDataSources) {
        super.setTargetDataSources(targetDataSources);
        super.afterPropertiesSet();
    }
}
@Configuration
public class DatasourceConfig {
    @Value("${spring.datasource.url}")
    private String url;
    @Value("${spring.datasource.username}")
    private String username;
    @Value("${spring.datasource.password}")
    private String password;
    @Value("${spring.datasource.driver-class-name}")
    private String driverClassName;

    /**
     * 设置默认数据源
     * @return
     */
    @Primary
    @Bean(name = "datasource")
    public DynamicRoutingDataSource dynamicRoutingDataSource() {
        DynamicRoutingDataSource dataSource = new DynamicRoutingDataSource();
        Map<Object, Object> targetDataSources = new HashMap<>(16);
        HikariDataSource hikariDataSource = new HikariDataSource();
        hikariDataSource.setJdbcUrl(url);
        hikariDataSource.setUsername(username);
        hikariDataSource.setPassword(password);
        hikariDataSource.setDriverClassName(driverClassName);
        targetDataSources.put(DynamicRoutingDataSourceHolder.PRIMARY_DATASOURCE, hikariDataSource);
        dataSource.setTargetDataSources(targetDataSources);
        //设置动态数据源的默认数据源,也就是配置文件里的数据源
        dataSource.setDefaultTargetDataSource(hikariDataSource);
        return dataSource;
    }

    /**
     * 加入事务管理? 没试过
     * @return
     */
    @Bean
    public PlatformTransactionManager transactionManager() {
        return new DataSourceTransactionManager(dynamicRoutingDataSource());
    }
}
@Configuration
@Slf4j
public class DynamicSourceConfig implements ApplicationContextAware {


    @Autowired
    private DynamicRoutingDataSource dynamicRoutingDataSource;
    public static ApplicationContext applicationContext;
    public static List<TenantUser> tenantUsers = new ArrayList();

    //演示用  静态初始化,  可以考虑一个定时任务从redis通过,跟我上面的图形一样
    static {
        tenantUsers.add(new TenantUser(1,"aaa","jdbc:mysql://127.0.0.1:3306/shrek_example_1348915432900841474?allowMultiQueries=true&useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai","root","root"));
        tenantUsers.add(new TenantUser(2,"bbb","jdbc:mysql://127.0.0.1:3306/shrek_example_1349659685910249474?allowMultiQueries=true&useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai","root","root"));
    }

    @PostConstruct
    public void init() {
        List<TenantUser> list = tenantUsers;

        ConfigurableApplicationContext applicationContext = (ConfigurableApplicationContext) DynamicSourceConfig.applicationContext;
        DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) applicationContext.getBeanFactory();
        Map<Object, DataSource> dataSourceMap = dynamicRoutingDataSource.getResolvedDataSources();
        Map<Object, Object> map = new HashMap<>(dataSourceMap);
        for (TenantUser user : list) {
            BeanDefinitionBuilder beanDefinitionBuilder = BeanDefinitionBuilder.genericBeanDefinition(HikariDataSource.class);
            beanDefinitionBuilder.addPropertyValue("username", user.getDatasourceUsername());
            beanDefinitionBuilder.addPropertyValue("password", user.getDatasourcePassword());
            beanDefinitionBuilder.addPropertyValue("jdbcUrl", user.getDatasourceUrl());
            beanDefinitionBuilder.addPropertyValue("driverClassName", "com.mysql.cj.jdbc.Driver");
            String beanName = user.getPrefix();
            beanFactory.registerBeanDefinition(beanName, beanDefinitionBuilder.getRawBeanDefinition());
            DataSource dataSource = DynamicSourceConfig.applicationContext.getBean(beanName, DataSource.class);
            map.put(beanName, dataSource);
        }
        dynamicRoutingDataSource.setTargetDataSources(Collections.unmodifiableMap(map));
        log.info("dynamic datasource init success");
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        DynamicSourceConfig.applicationContext = applicationContext;
    }
}

还有一个拦截类,就是拦截前端的请求,获取分域名,存入的ThredLocal中,这里可以做个判断,可以判断为空,直接返回租户未注册,再根据租户类型是基于字段还是库的,这里我没判断

@Component
public class RequestHander implements HandlerInterceptor {
    //请求前
    @Override
    public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o) throws Exception {

        //获取域名,从本地缓存对象(从redis同步过来)判断是否过期,租户ID和多租户类型,存入ThreadLocal
        URL url = new URL(httpServletRequest.getRequestURL().toString());
        DynamicRoutingDataSourceHolder.putKey(url.getHost().split("\\.")[0]);

        return true;
    }

}

修改你的host文件。

127.0.0.1  aaa.shrek.com
127.0.0.1  bbb.shrek.com
127.0.0.1  ccc.shrek.com

启动项目后,不同的域名就会操作不同的数据库了,这样就差不多是可以实现多租户了,一套应用,多个租户共用,可以基于多种数据隔离模式。