《SAAS多租户框架实现总结》《SpringBoot多数据源解决方案》

最近一次更新时间2021-05-08 18:00

环境

SpringBoot+Mybatis+MongoDB(没有用到MongoDB去掉即可,设计本身就是解耦的,方便集成与卸下)

引言

多租户框架的本质与关键点

多租户,它的本质其实就是识别不同租户的请求,在与数据库交互前,动态切换目标租户数据源
它的关键点有三:

1.如何根据请求【区分不同租户】?(区分规则,标识保存位置)
2.如何根据租户【动态切换数据源】?(动态数据源如何配置,动态切换如何实现)
3.如何在Spring【服务不重启】的情况下,还能动态增加新的租户?

附上参考资料链接:

SpringBoot+Mybatis多租户参考

1.SpringBoot多租户的实现(2016年1月文章):
https://fizzylogic.nl/2016/01/24/make-your-spring-boot-application-multi-tenant-aware-in-2-steps/
2.Spring Boot 构建多租户SaaS平台核心技术指南(2019年5月文章):
https://segmentfault.com/a/1190000019300288?utm_source=tag-newest
3.Spring Boot + Mybatis 实现动态数据源
https://www.cnblogs.com/xifengxiaoma/p/9888240.html
4.Spring多数据源配置
https://www.jianshu.com/p/44ec6d939428
5.【精】Springboot项目使用动态切换数据源实现多租户SaaS方案
https://blog.csdn.net/qq_36521507/article/details/103452961

MongoDB多租户参考

1.Multiple MongoDB connectors with SpringBoot(英文博客)
(讲述多数据源配置的,而非多租户)
https://blog.marcosbarbero.com/multiple-mongodb-connectors-in-spring-boot/
【精】2.MongoDB多数据源动态配置(深入源代码的一篇文章,但是例子中不支持各个租户配置Mongodb的用户名密码,做思路参考)
https://blog.csdn.net/qq_36882793/article/details/103582644
3.springboot集成mongodb实现动态切换数据源(亲测,可实现,但有严重的线程安全问题,只能单线程用,所以也只能做思路参考,不能实际用)
https://www.cnblogs.com/jiawen010/p/12664494.html
4.实现MongoDB多数据源的自动切换
https://blog.csdn.net/QQ994406030/article/details/52861421?locationNum=13&fps=1
5.SpringBoot+Mongodb不同用户动态切换数据源(MongoTemplate)(二)
https://blog.csdn.net/qq_40384470/article/details/103437628

拓展参考资料链接:

1.JDBC:从原理到应用(讲述java与数据库连接最底层原理,清晰透彻)
https://www.cnblogs.com/fzz9/p/8970210.html
2.Mybatis官方文档
http://mybatis.org/spring/zh/factorybean.html
3.Spring-MongoDB 关键类的源码分析
https://www.cnblogs.com/huangzejun/p/8676934.html
4.HikariCP数据库连接池GIT官网介绍
https://github.com/brettwooldridge/HikariCP#configuration-knobs-baby

关键点实现思路梳理:

1.租户标识区分规则的确定

常见区分规则有以下几种思路:

  1. 可以通过域名或url路径的方式来识别租户:我们可以为每个租户的请求设置一个路径前缀,比如 http://www.tianzhihen.com/tenant1/sys/gethttp://www.tianzhihen.com/tenant2/sys/get 两个url路径,其中的 tenant1tenant2 就是我们识别不同租户的关键信息
  2. 可以将租户信息作为一个固定的请求参数传递给服务端:比如 http://www.tianzhihen.com/sys/get?tenantId=tenant1 ,参数 tenantId=tenant1就是我们识别不同租户的关键信息
  3. 可以在请求头 Header 中设置租户信息,例如JWT等技术,服务端通过解析Header中token以获得租户标识(我个人比较推荐这种)
  4. 在用户成功登录系统后,将租户信息保存在Session中,在需要的时候从Session取出租户信息。

总之,不论是那种思路,目的只有一个:后端能够获取到租户标识:tenantId,并封装至线程上下文中保存,从而识别不同租户.

2.如何实现动态切换租户数据源 以及 如何在Spring【服务不重启】的情况下,还能动态增加新的租户?【核心2个关键点】

实现了这两个核心关键点,整个框架70%就做完了
【最完美方案】,是执行数据库交互时,才动态切换数据源,不交互就不切换,这样的开销最小.
要实现这一点,其实只要解决下面这三个问题即可,我就以Spring举例(此三个问题的答案在后面揭晓):

1.【Spring管理的数据源dataSource总库"藏"在哪里?】
Spring它肯定有个管理所有dataSource的"总库"(通常是个map)。新增一个租户,如果我们不想重启程序,想在内存中动态加一个"租户数据源",那么我们只需要找到这个"总库"位置,更新替换一下"总库"存储的数据源即可。
2.【如何更新Spring管理"总库"中的数据源?】
启动程序的时候,从外部配置文件application.properties中获取数据源信息加载dataSource后,Spring肯定会调用某个方法,来把这些dataSource放到它管理的内存"总库"中,那么我们如果找到了这个方法,并在需要时主动调用这个方法,是不是就实现了更新Spring管理"总库"中的数据源,1和2两点是解决《如何在Spring【服务不重启】的情况下,还能动态增加新的租户?》这个问题的关键
3.【Spring底层在与数据库交互时是如何确定实际数据源的?】
既然Spring有个管理所有dataSource的"总库",那么在和数据库交互前,Spring肯定会先要从这个"总库"中定位到实际的dataSource,然后从dataSource中取出一根connection与数据库交互,没毛病吧~! 而Spring这个寻找定位dataSource的过程,它肯定是用了某个方法,如果我们能改造这个方法,让它变成根据租户标识tenantId找到该租户的dataSource,就成功实现了动态切换数据源。

我们来看看Spring是怎么实现这三点的:【源码解析开始】
首先列一张继承关系图,里头的AbstractRoutingDataSource就是本次我们要继承改造的核心类

继承关系图.jpg

关键类源码解析:

1.AbstractRoutingDataSource

我们先来了解一下AbstractRoutingDataSource类,看名字是一个数据源的路由,Spring实际上就是由它来确定数据源的,我们看下源码

public abstract class AbstractRoutingDataSource extends AbstractDataSource implements InitializingBean {
    @Nullable
    private Map<Object, Object> targetDataSources;
    @Nullable
    private Object defaultTargetDataSource;
    private boolean lenientFallback = true;
    private DataSourceLookup dataSourceLookup = new JndiDataSourceLookup();
    @Nullable
    private Map<Object, DataSource> resolvedDataSources;
    @Nullable
    private DataSource resolvedDefaultDataSource;

    public AbstractRoutingDataSource() {
    }

    public void setTargetDataSources(Map<Object, Object> targetDataSources) {
        this.targetDataSources = targetDataSources;
    }

    public void setDefaultTargetDataSource(Object defaultTargetDataSource) {
        this.defaultTargetDataSource = defaultTargetDataSource;
    }

    public void setLenientFallback(boolean lenientFallback) {
        this.lenientFallback = lenientFallback;
    }

    public void setDataSourceLookup(@Nullable DataSourceLookup dataSourceLookup) {
        this.dataSourceLookup = (DataSourceLookup)(dataSourceLookup != null ? dataSourceLookup : new JndiDataSourceLookup());
    }

    public void afterPropertiesSet() {
        if (this.targetDataSources == null) {
            throw new IllegalArgumentException("Property 'targetDataSources' is required");
        } else {
            this.resolvedDataSources = new HashMap(this.targetDataSources.size());
            this.targetDataSources.forEach((key, value) -> {
                Object lookupKey = this.resolveSpecifiedLookupKey(key);
                DataSource dataSource = this.resolveSpecifiedDataSource(value);
                this.resolvedDataSources.put(lookupKey, dataSource);
            });
            if (this.defaultTargetDataSource != null) {
                this.resolvedDefaultDataSource = this.resolveSpecifiedDataSource(this.defaultTargetDataSource);
            }

        }
    }

    protected Object resolveSpecifiedLookupKey(Object lookupKey) {
        return lookupKey;
    }

    protected DataSource resolveSpecifiedDataSource(Object dataSource) throws IllegalArgumentException {
        if (dataSource instanceof DataSource) {
            return (DataSource)dataSource;
        } else if (dataSource instanceof String) {
            return this.dataSourceLookup.getDataSource((String)dataSource);
        } else {
            throw new IllegalArgumentException("Illegal data source value - only [javax.sql.DataSource] and String supported: " + dataSource);
        }
    }

    public Connection getConnection() throws SQLException {
        return this.determineTargetDataSource().getConnection();
    }

    public Connection getConnection(String username, String password) throws SQLException {
        return this.determineTargetDataSource().getConnection(username, password);
    }

    public <T> T unwrap(Class<T> iface) throws SQLException {
        return iface.isInstance(this) ? this : this.determineTargetDataSource().unwrap(iface);
    }

    public boolean isWrapperFor(Class<?> iface) throws SQLException {
        return iface.isInstance(this) || this.determineTargetDataSource().isWrapperFor(iface);
    }

    protected DataSource determineTargetDataSource() {
        Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
        Object lookupKey = this.determineCurrentLookupKey();
        DataSource dataSource = (DataSource)this.resolvedDataSources.get(lookupKey);
        if (dataSource == null && (this.lenientFallback || lookupKey == null)) {
            dataSource = this.resolvedDefaultDataSource;
        }

        if (dataSource == null) {
            throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
        } else {
            return dataSource;
        }
    }

    @Nullable
    protected abstract Object determineCurrentLookupKey();
}

(1) afterPropertiesSet()

这个方法来自于InitializingBean类,会在【程序初始化-属性设置完成后】被自动调用。
我么可以看到里面维护了一个 targetDataSources 和 defaultTargetDataSource,初始化时将数据源分别进行复制到resolvedDataSources和resolvedDefaultDataSource中,代码如下

public void afterPropertiesSet() {
    if (this.targetDataSources == null) {
        throw new IllegalArgumentException("Property 'targetDataSources' is required");
    } else {
        this.resolvedDataSources = new HashMap(this.targetDataSources.size());
        this.targetDataSources.forEach((key, value) -> {
            Object lookupKey = this.resolveSpecifiedLookupKey(key);
            DataSource dataSource = this.resolveSpecifiedDataSource(value);
            this.resolvedDataSources.put(lookupKey, dataSource);
        });
        if (this.defaultTargetDataSource != null) {
            this.resolvedDefaultDataSource = this.resolveSpecifiedDataSource(this.defaultTargetDataSource);
        }

    }
}

(2) getConnection()

调用此方法获取连接的时候,如下代码determineTargetDataSource().getConnection(),先调用determineTargetDataSource()方法返回当前的DataSource,然后再调用getConnection()

public Connection getConnection() throws SQLException {
    return this.determineTargetDataSource().getConnection();
}

(3) determineTargetDataSource()

此方法的就是根据lookupkey获取map中的dataSource,而lookupkey是从determineCurrentLookupKey方法返回的,如下:

protected DataSource determineTargetDataSource() {
    Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
    Object lookupKey = this.determineCurrentLookupKey();
    DataSource dataSource = (DataSource)this.resolvedDataSources.get(lookupKey);
    if (dataSource == null && (this.lenientFallback || lookupKey == null)) {
        dataSource = this.resolvedDefaultDataSource;
    }

    if (dataSource == null) {
        throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
    } else {
        return dataSource;
    }
}

@Nullable
protected abstract Object determineCurrentLookupKey();

(4) determineCurrentLookupKey()

此方法要我们自己实现,是切换数据源的方法,通过自己的实现返回lookupKey,根据lookupKey获取对应数据源达到切换动态切换的功能。

@Nullable
protected abstract Object determineCurrentLookupKey();

到这一步为止,我们来回答刚刚三个关键点的答案:

1.【Spring管理的数据源dataSource总库"藏"在哪里?】Spring管理的dataSource"总库"就是resolvedDataSources这个Map
2.【如何更新Spring管理"总库"中的数据源?】使用setTargetDataSources + afterPropertiesSet两个方法,可以动态更新Spring管理的resolvedDataSources
3.【Spring底层在与数据库交互时是如何确定实际数据源的?】Spring是通过一个lookupKey,从resolvedDataSources确定实际调用数据源,我们可以通过覆写determineCurrentLookupKey()方法来自定义lookupkey的来源,或者直接覆写determineTargetDataSource()方法,从而实现动态切换租户数据源。

思路梳理完毕。开始实现

方案具体实现

1.pom.xml以及配置文件application.properties,数据库结构一览

pom.xml文件中其他的都是SpringBoot的或者MongoDB或者Mybatis的,这些基本的我就不贴了,就贴一个lombok插件,这个插件可以让我们不用写get,set方法,也不用写日志类的代码
全部用@Data和@Slf4j代替即可,省事方便,强烈推荐
pom.xml

<!-- lombok插件,用注解简化getter,setter,toString,Logger等代码 -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.12</version>
    <scope>provided</scope>
</dependency>

application.properties

#Mysql基础数据源配置(存租户信息)
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/master?characterEncoding=UTF-8&serverTimezone=GMT%2B8
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

#MongoDb默认数据源配置(如果没有用到MongoDB这配置可以不要)
spring.data.default.mongodb.uri=mongodb://master:master@127.0.0.1:27017/master

数据库设计
基础数据源(存租户数据源信息,共计两张表):


租户信息表.jpg

租户配置表.png

2.自定义DynamicDataSource类(未优化懒加载防御机制前)

/**
 * 模块名称: 【Mysql】动态数据源实现类
 * 模块描述: 关键在于determineCurrentLookupKey方法,就是这个方法实现了Spring动态切换数据源
 *
 * @author xinchi.wang
 * @date 2020/4/29 14:10
 */
public class DynamicDataSource extends AbstractRoutingDataSource {
    /**
     * 如果不希望数据源在启动配置时就加载好,可以覆写定制这个方法.
     */
    @Override
    protected DataSource determineTargetDataSource() {
        return super.determineTargetDataSource();
    }

    /**
     * 如果希望所有数据源在启动配置时就加载好,这里通过设置数据源Key值来切换数据源,定制这个方法
     */
    @Override
    protected Object determineCurrentLookupKey() {
        return TenantContext.getTenantId();
    }
}

还记得这个关键点嘛《如何在Spring【服务不重启】的情况下,还能动态增加新的租户?》
如果你的工程中,在【程序运行】期间,不会再动态新增新的租户。或者新增新的租户数据源后,需要重新启动程序新的租户才能生效。那么你无需优化懒加载防御机制,做到上面这一步就可以了。否则,需要将这个点考虑进去,于是有了下面优化后的版本:

2.自定义DynamicDataSource类(懒加载防御机制优化版)

package com.eastrobot.aicc.commons.multitenant.config;

import com.eastrobot.aicc.commons.constants.CommonConstants;
import com.eastrobot.aicc.commons.entity.Tenant;
import com.eastrobot.aicc.commons.multitenant.context.TenantContext;
import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.List;
import java.util.Map;

/**
 * 模块名称: 【Mysql】动态数据源实现类
 * 模块描述: 关键在于determineCurrentLookupKey方法,就是这个方法实现了Spring动态切换数据源
 *
 * @author xinchi.wang
 * @date 2020/4/29 14:10
 */
@Slf4j
public class DynamicDataSource extends AbstractRoutingDataSource {
    /**
     * 【核心】动态数据源维护的所有数据源信息
     * 备注:Spring维护的所有数据源,实际上放在了AbstractRoutingDataSource抽象类中的targetDataSources属性中
     * 但是可能是出于安全考虑,AbstractRoutingDataSource只提供了setTargetDataSources的方法,却没有提供getTargetDataSources的方法
     * 这带来了非常大的不便,于是我们在这个子类中单独定义一个私有属性,并额外提供一个getTargetDataSources的方法
     * 而setTargetDataSources本质上仍然是调用父级的。
     */
    private Map<Object, Object> targetDataSources;

    @Override
    public void setTargetDataSources(Map<Object, Object> targetDataSources) {
        this.targetDataSources = targetDataSources;
        super.setTargetDataSources(targetDataSources);
    }

    public Map<Object, Object> getTargetDataSources() {
        return this.targetDataSources;
    }

    /**
     * 如果不希望数据源在启动配置时就加载好,可以覆写定制这个方法.
     * 我们此处并没有覆写,用的仍然是父类的方法,只是针对找不到数据源的情况,多做了一层【懒加载防御】,从而实现【运行过程中】【动态更新】Spring管理的数据源,就不用重启程序了
     */
    @Override
    protected DataSource determineTargetDataSource() {
        HikariDataSource dataSource = null;
        super.setLenientFallback(false);
        boolean needReinit = false;
        String tenantId = TenantContext.getTenantId();
        try {
            dataSource = (HikariDataSource) super.determineTargetDataSource();
        } catch (IllegalStateException e) {
            // 捕获了这个异常说明【内存中】没有找到对应tenantId的数据源,则触发防御机制:去[基础数据库]中查一把该租户信息
            needReinit = true;
        }
        // 【防御机制】内存动态数据源中不存在该租户对应的dataSource----------------------------防御开始
        if (needReinit) {
            DynamicDataSourceInit dynamicDataSourceInit = (DynamicDataSourceInit) ApplicationContextProvider.getBean("dynamicDataSourceInit");
            HikariDataSource baseDataSource = dynamicDataSourceInit.getBaseDataSource();
            if (StringUtils.isNotBlank(tenantId)) {
                try {
                    // 【防御机制】字符串null值不合法防御
                    if ("null".equals(tenantId)) {
                        log.error("【警告】检测到传入的【tenantId】是【null字符串】,而非正常的null值,不合法!");
                    }
                    Connection conn = baseDataSource.getConnection();
                    // 1.先去数据库查一下是否有该tenantId对应租户的信息
                    List<Tenant> dbTenants = dynamicDataSourceInit.getTenant(conn, tenantId);
                    // 2.如果数据库里有信息,说明是最近新添加的租户,则判断一下是否已启用,如果已启用,就重新初始化一下内存中的动态数据源,然后再调用一次同样的获取方法返回数据;
                    if (dbTenants != null && dbTenants.size() > 0) {
                        if (CommonConstants.TENANT_ENABLED.equals(dbTenants.get(0).getEnabledStatus())) {
                            // 已启用状态,则初始化数据源
                            dynamicDataSourceInit.initDataSource();
                            return super.determineTargetDataSource();
                        } else {
                            // 非已启用状态,则返回错误提示
                            throw new RuntimeException("【" + tenantId + "】租户当前为非启用状态,可能是尚未开通或已失效");
                        }
                    } else {
                        // 如果数据库里也没有该租户信息,则该租户确实不存在,抛异常
                        throw new RuntimeException("数据库不存在tenantId=" + tenantId + "的租户信息");
                    }
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
        }
        // ------------------------------------------------------------------------------防御结束
        return dataSource;
    }

    /**
     * 如果希望所有数据源在启动配置时就加载好,这里通过设置数据源Key值来切换数据源,定制这个方法
     */
    @Override
    protected Object determineCurrentLookupKey() {
        // 如果线程上下文中没有设置tenantId,则传入默认值baseDataSource
        return StringUtils.isNotBlank(TenantContext.getTenantId()) ? TenantContext.getTenantId() : CommonConstants.BASE_DATASOURCE_KEY;
    }
}


3.ApplicationContextProvider--Spring线程上下文工具类

Spring线程上下文工具类,只要能获取到ApplicationContext上下文中管理的Bean即可,网上写法多的是,我参考的一种写法如下

package com.eastrobot.aicc.commons.multitenant.config;

import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

/**
 * 模块名称: Spring上下文工具类
 * 模块描述: 用于获取Spring上下文
 *
 * @author xinchi.wang
 * @date 2020/4/30 14:54
 */
@Component
public class ApplicationContextProvider implements ApplicationContextAware {
    /**
     * 上下文对象实例
     */
    private static ApplicationContext applicationContext;

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

    /**
     * 获取applicationContext
     *
     * @return
     */
    public static ApplicationContext getApplicationContext() {
        return applicationContext;
    }

    /**
     * 通过name获取 Bean.
     *
     * @param name
     * @return
     */
    public static Object getBean(String name) {
        return getApplicationContext().getBean(name);
    }

    /**
     * 通过class获取Bean.
     *
     * @param clazz
     * @param <T>
     * @return
     */
    public static <T> T getBean(Class<T> clazz) {
        return getApplicationContext().getBean(clazz);
    }

    /**
     * 通过name,以及Clazz返回指定的Bean
     *
     * @param name
     * @param clazz
     * @param <T>
     * @return
     */
    public static <T> T getBean(String name, Class<T> clazz) {
        return getApplicationContext().getBean(name, clazz);
    }
}

4.DynamicDataSourceConfig--动态数据源相关配置类(必要配置+可选配置)

主要用于统一提供一些与数据库连接池相关配置,其中必要配置是必须的(比如数据源地址),还有一些是可选的(比如最大连接数,最小连接数等)

package com.eastrobot.aicc.commons.multitenant.config;

import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;

/**
 * 模块名称: 动态数据源外部配置类
 * 模块描述: 用于统一获取数据源相关的外部动态配置
 *
 * @author xinchi.wang
 * @date 2020/9/28 14:34
 */
@Data
@Configuration
public class DynamicDataSourceConfig {
    /**
     * HikariDataSource数据库连接池的必要连接配置
     */
    @Value("${spring.datasource.driver-class-name}")
    private String driverClass;
    @Value("${spring.datasource.url}")
    private String url;
    @Value("${spring.datasource.username}")
    private String username;
    @Value("${spring.datasource.password}")
    private String password;
    /**
     * MongoDB默认主库必要的连接配置
     */
    @Value("${spring.data.default.mongodb.uri:#{null}}")
    private String baseMongoDBUri;

    /**
     * 数据库连接校验sql:mysql 是【select 1】,oracle是【select 1 from dual】,需要外部来配置
     */
    @Value("${sql.validation-query}")
    private String validationQuery;


    /******************************** HikariDataSource数据库连接池的其他自定义配置-开始 ********************************/
    /**
     * 连接超时时间:30秒(Long类型)
     */
    @Value("${aicc.spring.datasource.hikari.connection-timeout:30000}")
    private Long connectionTimeout;
    /**
     * 最大生命时间:10分钟(Long类型)
     */
    @Value("${aicc.spring.datasource.hikari.max-lifetime:600000}")
    private Long maxLifetime;
    /**
     * 最大空闲时间:5分钟(Long类型)
     */
    @Value("${aicc.spring.datasource.hikari.idle-timeout:300000}")
    private Long idleTimeout;
    /**
     * 最大连接数(Integer)
     */
    @Value("${aicc.spring.datasource.hikari.maximum-pool-size:20}")
    private Integer maximumPoolSize;
    /**
     * 最小空闲连接数(Integer)
     */
    @Value("${aicc.spring.datasource.hikari.minimum-idle:1}")
    private Integer minimumIdle;
    /**
     * 启用泄漏检测超时判定标准(毫秒):3分钟(Long)
     */
    @Value("${aicc.spring.datasource.hikari.leak-detection-threshold:180000}")
    private Integer leakDetectionThreshold;

    /******************************** HikariDataSource数据库连接池的其他自定义配置-结束 ********************************/
}


4.MybatisConfig--Mybatis相关配置类+基础数据源初始化类【核心类1

这个类和下面的【动态数据源初始化类】DynamicDataSourceInit需要多提两句,一个负责初始化基础数据源(执行顺序最前),一个负责初始化动态数据源(在基础数据源初始化后再执行),我们在下面一起详细说,此处先略过

package com.eastrobot.aicc.acdservice.config;

import com.eastrobot.aicc.commons.constants.CommonConstants;
import com.eastrobot.aicc.commons.entity.Tenant;
import com.eastrobot.aicc.commons.multitenant.config.DynamicDataSource;
import com.eastrobot.aicc.commons.multitenant.config.DynamicDataSourceConfig;
import com.eastrobot.aicc.commons.multitenant.context.TenantContext;
import com.eastrobot.aicc.commons.multitenant.utils.MultiTenantUtils;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.mapping.DatabaseIdProvider;
import org.apache.ibatis.mapping.VendorDatabaseIdProvider;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.*;

/**
 * 模块名称: Mybatis配置类
 * 模块描述: 主要是配置动态数据源
 * 小知识:@Configuration注解的类中,@Bean注解的方法会被Spring重写生成代理,缓存返回的对象,所以即便在此处多次调用dynamicDataSource(),返回的都是同一个dynamicDataSource对象
 *
 * @author xinchi.wang
 * @date 2020/4/29 13:59
 */
@Slf4j
@Configuration
//@MapperScan(basePackages = {"com.eastrobot.aicc.*.dao"}) // 扫描DAO,由于在SpringBoot启动类中已配置过该句,故此处不再重复配置
public class MybatisConfig {
    /**
     * 注入数据源相关的外部动态配置
     */
    @Autowired
    private DynamicDataSourceConfig dynamicDataSourceConfig;

    /**
     * 基础数据源为单例
     */
    private static HikariDataSource singleBaseDataSource = null;

    @Value("${sql.validation-query:select 1}")
    private String validationQuery;
    /**
     * 初始化【基础数据源】的方法
     *
     * @return
     */
    public HikariDataSource getBaseDataSource() {
        if (null != singleBaseDataSource) {
            return singleBaseDataSource;
        }
        HikariConfig jdbcConfig = new HikariConfig();
        // 设置必要的连接属性
        jdbcConfig.setDriverClassName(dynamicDataSourceConfig.getDriverClass());
        jdbcConfig.setJdbcUrl(dynamicDataSourceConfig.getUrl());
        jdbcConfig.setUsername(dynamicDataSourceConfig.getUsername());
        jdbcConfig.setPassword(dynamicDataSourceConfig.getPassword());
        // 设置其他数据源配置属性
        jdbcConfig.setConnectionTimeout(dynamicDataSourceConfig.getConnectionTimeout());
        jdbcConfig.setMaxLifetime(dynamicDataSourceConfig.getMaxLifetime());
        jdbcConfig.setIdleTimeout(dynamicDataSourceConfig.getIdleTimeout());
        jdbcConfig.setMaximumPoolSize(dynamicDataSourceConfig.getMaximumPoolSize());
        jdbcConfig.setMinimumIdle(dynamicDataSourceConfig.getMinimumIdle());
        jdbcConfig.setLeakDetectionThreshold(dynamicDataSourceConfig.getLeakDetectionThreshold());
        jdbcConfig.setConnectionTestQuery(validationQuery);
        jdbcConfig.setConnectionInitSql(validationQuery);
        HikariDataSource baseDataSource = new HikariDataSource(jdbcConfig);
        singleBaseDataSource = baseDataSource;
        return baseDataSource;
    }

    /**
     * Mybatis多数据库支持适配器
     *
     * @return
     */
    @Bean
    public DatabaseIdProvider databaseIdProvider() {
        DatabaseIdProvider databaseIdProvider = new VendorDatabaseIdProvider();
        Properties p = new Properties();
        p.setProperty("Oracle", "oracle");
        p.setProperty("MySQL", "mysql");
        p.setProperty("PostgreSQL", "postgresql");
        p.setProperty("DB2", "db2");
        p.setProperty("SQL Server", "sqlserver");
        databaseIdProvider.setProperties(p);
        return databaseIdProvider;
    }

    /**
     * 动态数据源(切库专用)
     *
     * @return
     */
    @Bean("dynamicDataSource")
    public DataSource dynamicDataSource() {
        if (log.isDebugEnabled()) {
            log.debug("===== 新建dynamicDataSource Bean,初始化基础数据源(baseDataSource),并将基础数据源注入dynamicDataSource管理的TargetDataSources中,作为第一个默认数据源 =====");
        }
        DynamicDataSource dynamicDataSource = new DynamicDataSource();
        // 1.创建【基础数据源】baseDataSource
        HikariDataSource baseDataSource = getBaseDataSource();
        // 2.将【基础数据源】baseDataSource设置为【动态数据源】dynamicDataSource的默认值
        dynamicDataSource.setDefaultTargetDataSource(baseDataSource);
        // 3.将【基础数据源】放入【动态数据源】dynamicDataSource底层维护的TargetDataSources中,作为第一个目标数据源,其对应键名为【baseDatasource】(整个程序启动完毕后:TargetDataSources维护的数据源构成:1个基础数据源+n个租户数据源)
        Map<Object, Object> dataSourceMap = new HashMap<>(16);
        dataSourceMap.put(CommonConstants.BASE_DATASOURCE_KEY, baseDataSource);
        dynamicDataSource.setTargetDataSources(dataSourceMap);
        if (log.isDebugEnabled()) {
            log.debug("===== 将基础数据源也设置为租户库中一员(作为默认数据源),其键名tenantId=baseDataSource =====");
        }
        // 4.将【基础数据源】的连接配置信息,放入TenantContext中,也作为租户库中的一员,方便统一管理。其键名tenantId=baseDataSource
        Tenant tenant = new Tenant(UUID.randomUUID().toString().replaceAll("-", ""), CommonConstants.BASE_DATASOURCE_KEY, "默认主库");
        Map<String, String> configMap = new LinkedHashMap<>(16);
        configMap.put("driver", dynamicDataSourceConfig.getDriverClass());
        configMap.put("url", dynamicDataSourceConfig.getUrl());
        configMap.put("username", dynamicDataSourceConfig.getUsername());
        configMap.put("password", dynamicDataSourceConfig.getPassword());
        configMap.put("mongodb_uri", dynamicDataSourceConfig.getBaseMongoDBUri());
        tenant.setConfigMap(configMap);
        TenantContext.tenantInfoMap.put(tenant.getTenantId(), tenant);
        // 5.判断当前模式是【多租户】还是【单租户】,并将判断结果设置到全局工具类中(本质就是通过判断主库表中是否存在一个名为tenant的表,如果存在就是【多租户模式】,不存在就是【单租户模式】)
        Boolean isMultiTenantPattern;
        try {
            isMultiTenantPattern = validateTableNameExist(baseDataSource.getConnection(), CommonConstants.TENANT_TABLE_NAME);
            // -- 将判断结果设置到全局工具类中
            MultiTenantUtils.setMultiTenantEnabled(isMultiTenantPattern);
        } catch (SQLException e) {
            log.error("", e);
        }
        // 6.返回封装后的【动态数据源】(此时内部维护的数据源只有1个基础数据源,键名为baseDataSource)
        return dynamicDataSource;
    }

    /**
     * 验证表是否存在:返回true表示存在,false表示不存在
     *
     * @param conn
     * @param tableName
     * @return 返回true表示存在, false表示不存在
     */
    private boolean validateTableNameExist(Connection conn, String tableName) {
        PreparedStatement ps = null;
        ResultSet rs = null;
        boolean result = true;
        // 2.编写sql语句
        String sql = "select count(1) from " + tableName;
        try {
            // 3.获取sql预编译对象
            ps = conn.prepareStatement(sql);
            // 4.执行并保存结果集
            rs = ps.executeQuery();
        } catch (SQLException e) {
            // 如果抛出了table不存在异常,说明数据源中tenant表不存在,说明当前是【单机版】
            result = false;
        } finally {
            // 释放连接资源
            release(conn, ps, rs);
        }
        return result;
    }

    /**
     * 释放连接资源
     *
     * @param conn Connection
     * @param ps   PreparedStatement
     * @param rs   ResultSet
     */
    private static void release(Connection conn, PreparedStatement ps, ResultSet rs) {
        try {
            if (rs != null) {
                rs.close();
            }
            if (ps != null) {
                ps.close();
            }
            if (conn != null) {
                conn.close();
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

    /**
     * Mybatis sqlSession核心工厂
     *
     * @return
     * @throws Exception
     */
    @Bean
    public SqlSessionFactoryBean sqlSessionFactoryBean() throws Exception {
        SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
        // 配置数据源,此处配置为【关键配置】,如果没有此步,则dynamicDataSource动态数据源的切换无效
        // 小知识:@Configuration注解的类中,@Bean注解的方法会被Spring重写生成代理,缓存返回的对象,所以即便在此处多次调用dynamicDataSource(),返回的都是同一个dynamicDataSource对象
        sessionFactory.setDataSource(dynamicDataSource());
        sessionFactory.setDatabaseIdProvider(databaseIdProvider());
        PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
        // 扫描映射文件
        sessionFactory.setMapperLocations(resolver.getResources("classpath*:mapper/*.xml"));

        //加载mybatis的全局配置文件
        Resource mybatisConfigXml = resolver.getResource("classpath:mybatis-config.xml");
        sessionFactory.setConfigLocation(mybatisConfigXml);
        return sessionFactory;
    }

    /**
     * 事务管理器
     *
     * @return
     */
    @Bean
    public PlatformTransactionManager transactionManager() {
        // 配置事务管理, 使用事务时在方法头部添加@Transactional注解即可
        return new DataSourceTransactionManager(dynamicDataSource());
    }

    /**
     * JdbcTemplate
     *
     * @return
     */
    @Bean
    public JdbcTemplate jdbcTemplate() {
        return new JdbcTemplate(dynamicDataSource());
    }
}


4.DynamicDataSourceInit--动态数据源初始化类【核心类2

这个类和MybatisConfig必须得多提两句,因为他们是两个核心类,有几个点需要备注一下:
首先数据源初始化以及动态切换数据源整体思路先快速梳理一下

1.程序启动,MybatisConfig类会在创建DynamicDataSource Bean的时候,初始化【基础数据源】,并将它作为【动态数据源】中默认的一个租户(主库),其键名tenantId=baseDatasource,用一个统一的静态常量去表示这个key。此时【动态数据源】中只有这一个默认租户,其他租户数据源尚未初始化。
2.随后,@PostConstruct注解的方法生效,将从【基础数据源】中获取所有租户信息,初始化租户信息,封装【动态数据源】,并保存至一个Map中全局维护
程序启动完毕

请求进来:
1.从请求中获取tenantId,以tenantId为键,从【动态数据源库map】中取出指定【租户数据源】,绑定到【线程上下文中】,完成切换
2.请求结束,清除绑定

几个写给自己的备忘:
备注1:如果你的工程中不涉及MongoDB数据源的动态切换,则这个类中MongoDB部分的数据源初始化代码可以不要的

package com.eastrobot.aicc.commons.multitenant.config;

import com.eastrobot.aicc.commons.constants.CommonConstants;
import com.eastrobot.aicc.commons.entity.Tenant;
import com.eastrobot.aicc.commons.multitenant.context.TenantContext;
import com.eastrobot.aicc.commons.multitenant.utils.MultiTenantUtils;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;

import javax.annotation.PostConstruct;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.*;

/**
 * 模块名称: 【Mysql】动态数据源初始化类
 * 模块描述: 用于初始化所有租户的数据源
 *
 * @author xinchi.wang
 * @date 2020/4/30 14:34
 */
@Slf4j
@Configuration
public class DynamicDataSourceInit {
    /**
     * 注入数据源相关的外部动态配置
     */
    @Autowired
    private DynamicDataSourceConfig dynamicDataSourceConfig;

    @Autowired
    private DynamicDataSource dynamicDataSource;

    /**
     * 基础数据源为单例
     */
    private static HikariDataSource singleBaseDataSource = null;

    /**
     * 初始化【基础数据源】的方法
     *
     * @return
     */
    public HikariDataSource getBaseDataSource() {
        if (null != singleBaseDataSource) {
            return singleBaseDataSource;
        }
        HikariConfig jdbcConfig = new HikariConfig();
        jdbcConfig.setDriverClassName(dynamicDataSourceConfig.getDriverClass());
        jdbcConfig.setJdbcUrl(dynamicDataSourceConfig.getUrl());
        jdbcConfig.setUsername(dynamicDataSourceConfig.getUsername());
        jdbcConfig.setPassword(dynamicDataSourceConfig.getPassword());
        jdbcConfig.setConnectionTestQuery(dynamicDataSourceConfig.getValidationQuery());
        jdbcConfig.setConnectionInitSql(dynamicDataSourceConfig.getValidationQuery());
        // 设置其他数据源配置属性
        jdbcConfig.setConnectionTimeout(dynamicDataSourceConfig.getConnectionTimeout());
        jdbcConfig.setMaxLifetime(dynamicDataSourceConfig.getMaxLifetime());
        jdbcConfig.setIdleTimeout(dynamicDataSourceConfig.getIdleTimeout());
        jdbcConfig.setMaximumPoolSize(dynamicDataSourceConfig.getMaximumPoolSize());
        jdbcConfig.setMinimumIdle(dynamicDataSourceConfig.getMinimumIdle());
        jdbcConfig.setLeakDetectionThreshold(dynamicDataSourceConfig.getLeakDetectionThreshold());
        HikariDataSource baseDataSource = new HikariDataSource(jdbcConfig);
        singleBaseDataSource = baseDataSource;
        return baseDataSource;
    }

    /**
     * 初始化动态数据源的方法
     */
    @PostConstruct
    public void initDataSource() {
        if (log.isDebugEnabled()) {
            log.debug("=====执行InitDataSource方法,初始化动态数据源=====");
        }
        // 1.从Spring上下文中取出动态数据源bean(此时动态数据源内只有一个基础数据源,其键名为baseDataSource)

//        DynamicDataSource dynamicDataSource = (DynamicDataSource) ApplicationContextProvider.getBean("dynamicDataSource");//2021年01月06日,出现加载顺序的问题,改用@Autowired注入的方式来获取到dynamicDataSource对象,保证加载顺序
        // 2.初始化动态数据源dynamicDataSource管理的所有租户数据源(最终所有数据源=1个基础数据源+n个租户数据源)
        Map<Object, Object> dataSourceMap = dynamicDataSource.getTargetDataSources();
        HikariDataSource baseDataSource = (HikariDataSource) dataSourceMap.get(CommonConstants.BASE_DATASOURCE_KEY);
        Map<String, Tenant> tenantMap = new HashMap<>(16);
        Connection conn = null;
        // 3.从基础数据源中,获取连接,查出所有租户数据源,然后创建出租户数据源,并放入【动态数据源】维护的总库map中
        try {
            conn = baseDataSource.getConnection();
            // 【防御机制----判断当前是多租户还是单机版--原理:去基础数据源中查找是否存在tenant表】
            // 缓存机制:validateTableNameExist方法执行后,会将判断结果缓存起来,之后调用MultiTenantUtils.isMultiTenant()将直接获取缓存中的结果,避免重复查询数据库
            if (MultiTenantUtils.isMultiTenant() == null) {
                // 如果MultiTenantUtils工具类尚未初始化,则在此处初始化一下
                boolean isMultiTenantPattern = validateTableNameExist(conn, "tenant");
                MultiTenantUtils.setMultiTenantEnabled(isMultiTenantPattern);
            }
            // 从数据库中获取所有租户信息(筛选条件tenantId为空)
            if (MultiTenantUtils.isMultiTenant()) {
                if (conn.isClosed()) {
                    // validateTableNameExist方法内原先的conn会被close,我们再获取一根新的连接
                    conn = baseDataSource.getConnection();
                }
                // 获取所有租户信息,tenantId查询条件为null即为查询所有
                List<Tenant> tenantList = getTenant(conn, null);
                for (Tenant tenant : tenantList) {
                    // 只有【1启用态】的租户,并且【尚未初始化过数据源】的,才会被初始化
                    if (CommonConstants.TENANT_ENABLED.equals(tenant.getEnabledStatus()) && null == dataSourceMap.get(tenant.getTenantId())) {
                        // debug模式打印日志
                        if (log.isDebugEnabled()) {
                            log.debug("初始化租户:" + tenant.toString());
                        }
                        HikariConfig jdbcConfig = new HikariConfig();
                        jdbcConfig.setDriverClassName(tenant.getConfigMap().get("driver"));
                        jdbcConfig.setJdbcUrl(tenant.getConfigMap().get("url"));
                        jdbcConfig.setUsername(tenant.getConfigMap().get("username"));
                        jdbcConfig.setPassword(tenant.getConfigMap().get("password"));
                        jdbcConfig.setConnectionTestQuery(dynamicDataSourceConfig.getValidationQuery());
                        jdbcConfig.setConnectionInitSql(dynamicDataSourceConfig.getValidationQuery());
                        // 设置其他数据源配置属性
                        jdbcConfig.setConnectionTimeout(dynamicDataSourceConfig.getConnectionTimeout());
                        jdbcConfig.setMaxLifetime(dynamicDataSourceConfig.getMaxLifetime());
                        jdbcConfig.setIdleTimeout(dynamicDataSourceConfig.getIdleTimeout());
                        jdbcConfig.setMaximumPoolSize(dynamicDataSourceConfig.getMaximumPoolSize());
                        jdbcConfig.setMinimumIdle(dynamicDataSourceConfig.getMinimumIdle());
                        jdbcConfig.setLeakDetectionThreshold(dynamicDataSourceConfig.getLeakDetectionThreshold());
                        try {
                            HikariDataSource dataSource = new HikariDataSource(jdbcConfig);
                            dataSourceMap.put(tenant.getTenantId(), dataSource);
                            // 同时保存租户信息map
                            tenantMap.put(tenant.getTenantId(), tenant);
                        } catch (Exception e) {
                            // 如果在创建某个租户数据源的过程中,某个租户的数据源挂了(比如:DBA手抖把某个租户数据库用户名密码或者数据库名不小心改了,为了不影响其他租户的启动,此处单独try..catch)
                            log.error("初始化【" + tenant.getTenantId() + "】租户数据源失败,可能原因是该租户数据库不存在或者主库中该租户的数据源配置有误");
                            log.error("", e);
                        }
                    }
                }
                // 把租户信息map备份到全局变量中,可用于切换时判断数据源是否有效,也方便其他类获取租户数据源信息(此步非必要)
                TenantContext.tenantInfoMap.putAll(tenantMap);
            }
        } catch (SQLException e) {
            e.printStackTrace();
        } finally {
            try {
                if (conn != null && !conn.isClosed()) {
                    conn.close();
                }
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
        /**
         * 设置数据源,并执行afterPropertiesSet,备注:必须执行afterPropertiesSet此操作,才会重新初始化AbstractRoutingDataSource 中的 resolvedDataSources,
         * 只有这样,Spring管理的数据源总库中才真正set了这些数据源
         */
        dynamicDataSource.setTargetDataSources(dataSourceMap);
        dynamicDataSource.afterPropertiesSet();
    }

    /**
     * 获取基础数据库中租户信息
     *
     * @param conn          数据库连接对象
     * @param queryTenantId 需要查询的tenantId(可选参数,查询指定租户时可以用到)
     * @return
     */
    public List<Tenant> getTenant(Connection conn, String queryTenantId) {
        // 1.准备返回容器
        List<Tenant> resultList = null;
        PreparedStatement ps = null;
        ResultSet rs = null;
        try {
            // 2.编写sql语句
            StringBuilder sb = new StringBuilder("SELECT t.id,t.tenant_id,t.tenant_name,t.enabled_status,c.config_key,c.config_value FROM tenant t left join tenant_config c on t.tenant_id = c.tenant_id");
            if (StringUtils.isNotBlank(queryTenantId)) {
                sb.append(" where t.tenant_id = ?");
            }
            sb.append(" order by t.tenant_id");
            // 3.获取sql预编译对象
            ps = conn.prepareStatement(sb.toString());
            // 4.设置参数给占位符
            if (StringUtils.isNotBlank(queryTenantId)) {
                ps.setString(1, queryTenantId);
            }
            // 4.执行并保存结果集
            rs = ps.executeQuery();
            // 5.处理结果集
            Map<String, Tenant> resultMap = new HashMap<>(16);
            while (rs.next()) {
                String tenantId = rs.getString("tenant_id");
                if (null == resultMap.get(tenantId)) {
                    Tenant tenant = new Tenant();
                    tenant.setId(rs.getString("id"));
                    tenant.setTenantId(rs.getString("tenant_id"));
                    tenant.setTenantName(rs.getString("tenant_name"));
                    tenant.setEnabledStatus(rs.getInt("enabled_status"));
                    Map<String, String> configMap = new LinkedHashMap<>(16);
                    configMap.put(rs.getString("config_key"), rs.getString("config_value"));
                    tenant.setConfigMap(configMap);
                    resultMap.put(tenant.getTenantId(), tenant);
                } else {
                    Tenant tenant = resultMap.get(tenantId);
                    Map<String, String> configMap = tenant.getConfigMap();
                    configMap.put(rs.getString("config_key"), rs.getString("config_value"));
                }
            }
            // 封装成List返回
            resultList = new ArrayList<>();
            for (Tenant tenant : resultMap.values()) {
                resultList.add(tenant);
            }
        } catch (SQLException e) {
            e.printStackTrace();
        } finally {
            // 释放连接资源
            release(conn, ps, rs);
        }
        return resultList;
    }

    /**
     * 验证表是否存在:返回true表示存在,false表示不存在
     *
     * @param conn
     * @param tableName
     * @return 返回true表示存在, false表示不存在
     */
    private boolean validateTableNameExist(Connection conn, String tableName) {
        PreparedStatement ps = null;
        ResultSet rs = null;
        boolean result = true;
        // 2.编写sql语句
        String sql = "select count(1) from " + tableName;
        try {
            // 3.获取sql预编译对象
            ps = conn.prepareStatement(sql);
            // 4.执行并保存结果集
            rs = ps.executeQuery();
        } catch (SQLException e) {
            // 如果抛出了table不存在异常,说明数据源中tenant表不存在,说明当前是【单机版】
            result = false;
        } finally {
            // 释放连接资源
            release(conn, ps, rs);
        }
        return result;
    }

    /**
     * 释放连接资源
     *
     * @param conn Connection
     * @param ps   PreparedStatement
     * @param rs   ResultSet
     */
    private static void release(Connection conn, PreparedStatement ps, ResultSet rs) {
        try {
            if (rs != null) {
                rs.close();
            }
            if (ps != null) {
                ps.close();
            }
            if (conn != null) {
                conn.close();
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

}


5.TenantContext--租户相关线程上下文类

主要用于动态切换Mysql部分的数据源
这个类最核心的就是维护了一份全局唯一的【租户信息Map】tenantInfoMap
在初始化租户数据源的时候,所有租户的信息都会封装到该Map中,当需要租户数据源url等信息的时候,就可以很方便的从中取出来,而且可以用这个map判断请求中的tenantId是否有效,从而针对性的做一些防御机制

package com.eastrobot.aicc.commons.multitenant.context;


import com.eastrobot.aicc.commons.entity.Tenant;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;

import java.util.LinkedHashMap;
import java.util.Map;

/**
 * 模块名称: 【Mysql】动态数据源key值维护类
 * 模块描述: 在线程上下文对象中维护当前租户的key,实际上key=tenantId
 *
 * @author xinchi.wang
 * @date 2020/4/29 14:16
 */
@Slf4j
public class TenantContext {
    /**
     * 【维护租户信息的全局核心Map】以tenantId为键,以Tenant对象为值,在调用DynamicDataSourceInit内方法初始化数据源的时候会将租户信息备份到该Map,可用于后续判断租户数据源是否存在等
     */
    public static Map<String, Tenant> tenantInfoMap = new LinkedHashMap<>();

    private static ThreadLocal<String> currentTenantId = new ThreadLocal<>();

    /**
     * 切换当前租户id
     *
     * @param tenantId
     */
    public static void setTenantId(String tenantId) {
        // 【防御机制】tenantId=字符串的"null",也认定为null
        if("null".equals(tenantId)){
            tenantId = null;
        }
        if (log.isDebugEnabled()) {
            log.debug("租户上下文类TenantContext:执行setTenantId方法,当前租户id切换过程:{}-->{}", currentTenantId.get(), tenantId);
        }
        currentTenantId.set(tenantId);
    }

    /**
     * 获取当前租户Id
     *
     * @return
     */
    public static String getTenantId() {
        String tenantId = currentTenantId.get();
        // 【防御机制】tenantId=字符串的"null",也认定为null
        if("null".equals(tenantId)){
            tenantId = null;
        }
        // 为空(null或者空串)统一返回null
        return StringUtils.isNotBlank(tenantId) ? tenantId : null;
    }

    /**
     * 重置租户id
     */
    public static void removeTenantId() {
        currentTenantId.remove();
        MongoTemplateContext.removeMongoTemplate();
        if (log.isDebugEnabled()) {
            log.debug("租户上下文类TenantContext:执行removeTenantId方法,重置后动态数据源key值:{}", currentTenantId.get());
        }
    }

    /**
     * 判断当前租户上下文维护的租户信息中是否有某个租户id
     *
     * @param tenantId
     * @return
     */
    public static boolean containTenantId(String tenantId) {
        return tenantInfoMap.containsKey(tenantId);
    }
}


切库总过程.png

6.MongoTemplateContext--MongoTemplate线程上下文类

主要用于动态切换MongoDB部分的数据源,如果你的工程中不涉及MongoDB数据源的动态切换,则这个类可以不要
这个类最核心的就是维护了一份全局唯一的【MongoDB模板Map】templateMap,就是这个Map保存了所有租户对应的mongoTemplate,当要切换mongoDB数据源的时候,实际上就是通过tenantId从templateMap取出了指定mongoTemplate而已。

package com.eastrobot.aicc.commons.multitenant.context;

import com.eastrobot.aicc.commons.constants.CommonConstants;
import com.eastrobot.aicc.commons.entity.Tenant;
import com.eastrobot.aicc.commons.multitenant.config.ApplicationContextProvider;
import com.eastrobot.aicc.commons.multitenant.config.DynamicDataSourceInit;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.SimpleMongoClientDbFactory;

import java.util.HashMap;
import java.util.Map;

/**
 * 模块名称: 【MongoDB】动态MongoTemplate维护类
 * 模块描述: 维护一份全局静态Map<String, MongoTemplate>,用于存储不同租户对应的MongoTemplate.
 * 每个请求进来,在AOP切面中,会根据tenantId,从该templateMap中取出该租户对应的MongoTemplate,绑定到该线程上下文中,供dao层调用
 *
 * @author xinchi.wang
 * @date 2020/5/5 12:52
 */
@Slf4j
public class MongoTemplateContext {
    /**
     * 全局Map,维护不同租户对应的MongoTemplate;以【tenantId为键,MongoTemplate对象为值】
     */
    public static Map<String, MongoTemplate> templateMap = new HashMap<>();

    /**
     * 本线程绑定的MongoTemplate,用于处理本次线程对应租户与其mongodb数据库的交互
     */
    private static ThreadLocal<MongoTemplate> currentMongoTemplate = new ThreadLocal<>();

    public static void setMongoTemplate(MongoTemplate mongoTemplate) {
        if (log.isDebugEnabled()) {
            log.debug("线程上下文类MongoTemplateHolder:执行setMongoTemplate方法,本线程中MongoTemplate转变过程:{}-->{},本次请求tenantId为:{}", currentMongoTemplate.get(), mongoTemplate, TenantContext.getTenantId());
        }
        currentMongoTemplate.set(mongoTemplate);
    }

    /**
     * 获取指定租户id的mongoTemplate,如果租户id为空串或null(包括字符串null),则返回默认主库的mongoTemplate
     * @param tenantId 租户id
     * @return
     */
    public static MongoTemplate getMongoTemplate(String tenantId){
        switchMongoTemplate(tenantId);
        return currentMongoTemplate.get();
    }

    /**
     * 根据线程上下文TenantContext类中存储的tenantId,获取mongoTemplate的方法
     * @return
     */
    public static MongoTemplate getMongoTemplate() {
        // 先给本线程切换mongoTemplate,切换后再返回
        String tenantId = TenantContext.getTenantId();
        switchMongoTemplate(tenantId);
        return currentMongoTemplate.get();
    }

    public static void removeMongoTemplate() {
        currentMongoTemplate.remove();
        if (log.isDebugEnabled()) {
            log.debug("线程上下文类MongoTemplateHolder:执行removeCurrentMongoTemplate方法,重置后MongoTemplate为:{}", currentMongoTemplate.get());
        }
    }

    /**
     * 切换线程上下文中绑定的MongoTemplate为对应租户的方法
     *
     * @param tenantId 租户标识
     */
    private static void switchMongoTemplate(String tenantId) {
        if (StringUtils.isBlank(tenantId) || "null".equals(tenantId)) {
            if (log.isDebugEnabled()) {
                log.debug("tenantId为空,切换为默认MongoTemplate");
            }
            tenantId = CommonConstants.BASE_DATASOURCE_KEY;
        }
        MongoTemplate mongoTemplate = MongoTemplateContext.templateMap.get(tenantId);
        // 如果为空,则创建该租户对应的MongoTemplate对象
        if (mongoTemplate == null) {
            // 动态数据源上下文中备份了租户数据源信息,我们可以从中获取该租户mongodb_uri信息来创建出MongoTemplate
            Tenant tenant = TenantContext.tenantInfoMap.get(tenantId);
            // 【防御机制】如果动态数据源中找不到该租户信息,则有可能是最近新添加的租户,重新初始化一下内存中的动态数据源后再试一次----------防御开始
            if (tenant == null) {
                DynamicDataSourceInit dynamicDataSourceInit = (DynamicDataSourceInit) ApplicationContextProvider.getBean("dynamicDataSourceInit");
                dynamicDataSourceInit.initDataSource();
                tenant = TenantContext.tenantInfoMap.get(tenantId);
                // 如果重新初始化一下内存中的动态数据源后仍然没有找到该租户信息,则抛出异常
                if (tenant == null) {
                    if ("null".equals(tenantId)) {
                        log.error("【警告】检测到传入的【tenantId】是【null字符串】,而非正常的null值,不合法!");
                    }
                    throw new RuntimeException("数据库不存在tenantId=" + tenantId + "的租户信息");
                }
            }
            // 【防御机制】----------------------------------------------------------------------------------------------------防御结束
            String mongodbUri = tenant.getConfigMap().get("mongodb_uri");
            mongoTemplate = new MongoTemplate(new SimpleMongoClientDbFactory(mongodbUri));
            // 备份到全局map中
            MongoTemplateContext.templateMap.put(tenant.getTenantId(), mongoTemplate);
        }
        // 绑定到本线程上下文变量中,完成切换
        MongoTemplateContext.setMongoTemplate(mongoTemplate);
    }
}


7.TenantInterceptor--租户拦截器类

在拦截器里头拦截所有请求,保存好租户标识tenantId,供切换数据源时调用

package com.eastrobot.aicc.commons.multitenant.interceptor;

import com.eastrobot.aicc.commons.context.TenantContext;
import com.eastrobot.aicc.commons.context.MongoTemplateContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.Nullable;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * 模块名称: 租户拦截器
 * 模块描述: 保存线程上下文里tenantId变量
 *
 * @author xinchi.wang
 * @date 2020/4/29 11:22
 */
@Slf4j
public class TenantInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String[] tenantIds = request.getParameterMap().get("tenantId");
        if (tenantIds != null) {
            String tenantId = request.getParameterMap().get("tenantId")[0];
            // 模拟拦截器
            if(tenantId!=null){
                TenantContext.setTenantId(tenantId);
            }
            log.info("拦截器里头截获租户tenantId为:{}", tenantId);
        }
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {
        TenantContext.removeTenantId();
    }

}

8.SpringMvcConfig中别忘了注册租户拦截器

package com.eastrobot.aicc.commons.multitenant.config;

import com.alibaba.fastjson.serializer.SerializerFeature;
import com.alibaba.fastjson.support.config.FastJsonConfig;
import com.alibaba.fastjson.support.spring.FastJsonHttpMessageConverter;
import com.eastrobot.aicc.bp.interceptor.UserInfoInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;

import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;

@Configuration
public class SpringMvcConfig extends WebMvcConfigurationSupport {
    @Bean
    TenantInterceptor tenantInterceptor() {
        return new TenantInterceptor();
    }

    /**
     * 注册租户拦截器,拦截所有请求
     *
     * @param registry
     */
    @Override
    protected void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(userAuthInterceptor())
                .addPathPatterns("/**");
        super.addInterceptors(registry);
    }
}

9.Tenant--租户实体类

package com.eastrobot.aicc.commons.multitenant.domain;

import lombok.Data;

import java.util.Map;

/**
 * 模块名称: 租户实体类
 * 模块描述: 用于存储租户信息
 *
 * @author xinchi.wang
 * @date 2020/4/30 14:36
 */
@Data
public class Tenant {
    private String id;
    private String tenantId;
    private String tenantName;
    private Map<String,String> configMap;

    public Tenant() {
    }

    public Tenant(String id, String tenantId, String tenantName) {
        this.id = id;
        this.tenantId = tenantId;
        this.tenantName = tenantName;
    }
}

11.SpringBoot启动类中关闭自动注入数据源

DataSourceAutoConfiguration类是Spring的
MongoAutoConfiguration类是MongoDB的

package com.eastrobot.aicc;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;

/**
 * 模块名称: BP工程启动入口
 * 模块描述: SpringBoot启动入口
 *
 * @author xinchi.wang
 * @date 2020/4/5 10:19
 */
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class, MongoAutoConfiguration.class})
@EnableDiscoveryClient
@MapperScan("com.eastrobot.aicc.*.dao")
@EnableFeignClients
public class BasicPlatformApplication {
    public static void main(String[] args) {
        SpringApplication.run(BasicPlatformApplication.class, args);
    }

}

成功标志

成功标志.png

其他可能遇到的问题:

一、Hikari经常出现Failed to validate connection并阻塞应用线程问题【可能的巨坑】

1.Hikari源码分析解释Failed to validate connection阻塞现象
https://blog.csdn.net/liujianchen_linux/article/details/103957722
2.Mysql查询wait_timeout虚假信息坑点
https://blog.csdn.net/liujianchen_linux/article/details/103957722

当时生产环境一直时不时的复现这个Bug


打印现象.png

而本地环境和测试环境都OK,于是我百思不得其解,为啥只有生产环境有这个现象呢?用命令看看数据库的mysql配置默认连接时间wait_time=28800(8小时),也是OK的
直到【翻墙】在google上搜到了两篇有用的文章:
才得知用这个命令【show global variables like '%timeout'; 】查出来的【默认连接时间28800】可能是【假的,【假的】【假的】,
真正的【默认连接时间】需要DBA去mysql配置文件中查看。然后我在本地mysql上反复测试修改,最终验证了猜想的正确性。
解决方式也很简单:maxLifeTime<数据库默认连接时间即可,我设置的是10分钟,以下是额外配置

        HikariConfig jdbcConfig = new HikariConfig();
        jdbcConfig.setDriverClassName(driverClass);
        jdbcConfig.setJdbcUrl(url);
        jdbcConfig.setUsername(userName);
        jdbcConfig.setPassword(passWord);
        // 设置其他数据源配置属性
        jdbcConfig.setConnectionTimeout(connectionTimeout);
        jdbcConfig.setMaxLifetime(maxLifetime);
        jdbcConfig.setIdleTimeout(idleTimeout);
        jdbcConfig.setMaximumPoolSize(maximumPoolSize);
        jdbcConfig.setMinimumIdle(minimumIdle);
        jdbcConfig.setLeakDetectionThreshold(leakDetectionThreshold);
        HikariDataSource baseDataSource = new HikariDataSource(jdbcConfig);

二、包扫描注解@ComponentScan要注意

Spring有个注解叫做
@ComponentScan(base-package={"com.eastrobot.aicc","com.eastrobot.aicc"}),它是用来扫描包路径的,其中包路径可以配置多个,用逗号隔开。一般这个注解放在SpringBoot启动类上。
如果你没有在SpringBoot启动类上配置这个注解,那么默认是【扫描当前类所在包下的所有配置类】
举个例子:
如果你没有显式配置@ComponentScan,
假设启动类在com.aaa包下,那么该包下的com.aaa.config,com.aaa.controller,com.aaa.service三个包下的相关注解都可以被扫描到
但是com.bbb包下的相关的注解就扫描不到了。如果想要扫描到com.bbb包下的文件,有两种做法:

  • 1.启动类加注解显式声明:@ComponentScan(base-package={"com.aaa","com.bbb"})(常用)
  • 2.把启动类放到更高一级:com包下,这样就能扫描到了(不常用)

我当时把多租户类大部分相关代码提取到了一个独立的jar工程中,但别的工程引用后却发现这些注解的类都没注入进来,就怀疑是扫描包没扫到的问题,后来一查,果然是这个原因

三、如果遇到@Configuration执行先后问题

两种解决办法:

  • 1.DynamicDataSourceInit类上的取名为@Configuration("dynamicDataSourceInit"),其他人的@Configuration配置类加上@DependsOn("dynamicDataSourceInit"),他们就会依赖于我的这个类,那么我的这个类就会先加载
  • 2.DynamicDataSourceInit类上加上@Order(-999),并让其他人的Order不得小于这个值

四、Mybatis分页插件问题

之前参考的一篇文章中,说到动态数据源可能会造成Mybatis分页插件问题,并给出了解决方案,我没试出来,但在这里也备忘一下
原文链接
原文部分摘要如下

/**
 * @Author: guomh
 * @Date: 2019/11/06
 * @Description: mybatis配置*
 */
@EnableTransactionManagement
@Configuration
@MapperScan({"com.sino.teamwork.base.dao","com.sino.teamwork.*.*.mapper"})
public class MybatisPlusConfig {
    @Bean("master")
    @Primary
    @ConfigurationProperties(prefix = "spring.datasource")
    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());
        sessionFactory.setTypeAliasesPackage("com.sino.teamwork.*.*.entity,com.sino.teamwork.base.model");    // 扫描Model
        PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
        sessionFactory.setMapperLocations(resolver.getResources("classpath*:mapper/*/*Mapper.xml"));    // 扫描映射文件
        return sessionFactory;
    }
    @Bean
    public PlatformTransactionManager transactionManager() {
        // 配置事务管理, 使用事务时在方法头部添加@Transactional注解即可
        return new DataSourceTransactionManager(dynamicDataSource());
    }
    /**
     * 加载分页插件
     * @return
     */
    @Bean
    public PaginationInterceptor paginationInterceptor() {
        PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
 
        List<ISqlParser> sqlParserList = new ArrayList<>();
        // 攻击 SQL 阻断解析器、加入解析链
        sqlParserList.add(new BlockAttackSqlParser());
        paginationInterceptor.setSqlParserList(sqlParserList);
        return paginationInterceptor;
    }

可以看到有如下配置:

  • 配置了主数据源叫master,主数据源放在spring配置文件里
  • 配置动态数据源,并将主数据源加入动态数据源中,设为默认数据源
  • 配置sqlSessionfactoryBean,并将动态数据源注入,sessionFactory.setDataSource(dynamicDataSource());
  • 配置事务管理器,并将动态数据源注入new DataSourceTransactionManager(dynamicDataSource());
  • 注意事项:
  • 此处还有一点容易出错,就是分页问题,因为之前按spring默认配置,是不用在此配置数据源跟sqlSessionFactoryBean,配置了分页插件后,spring默认给你注入到了sqlSessionFactoryBean,但是此处因我们自己配置了sqlSessionFactoryBean,所以要自己手动注入,不然分页会无效,如下
/**
* 重点,使分页插件生效
*/
Interceptor[] plugins = new Interceptor[1];
plugins[0] = paginationInterceptor();
sessionFactory.setPlugins(plugins);

结尾语

本框架由于用到了MongoDB数据库,所以除了mysql的动态数据源外,MongoDB的数据源也要动态切换,所以代码中有MongoDB部分的切换代码,如果你没有用到,稍微修改下,去掉那MongoDB部分代码即可。

回到最初的那段话:

【最完美方案】执行数据库交互时,才动态切换数据源,是最理想的状态,因为这样的开销最小

【mysql部分】已成功实现上面这点,是因为Spring源码中以下3个【关键点】全部已找到

1.【Spring管理的数据源dataSource总库"藏"在哪里?】Spring管理的dataSource"总库"就是resolvedDataSources这个Map
2.【如何更新Spring管理"总库"中的数据源?】使用setTargetDataSources + afterPropertiesSet两个方法,可以动态更新Spring管理的resolvedDataSources
3.【Spring底层在与数据库交互时是如何确定实际数据源的?】Spring是通过一个lookupKey,从resolvedDataSources确定实际调用数据源,我们可以覆写determineCurrentLookupKey()方法,或者覆写determineTargetDataSource()方法来实现动态切换租户数据源。

【mongoDB部分】也成功实现了上面这点,只是实现方式略有不同
因为调用到MongoTemplate的时候,一定会先获取【MongoTemplate】,所以在MongoTemplateContext中只需要把switchMongoTemplate的方法写在getMongoTemplate中,即可实现【最完美方案】:与数据库有交互,才动态切换数据源;没交互,就不动态切换

此外,多租户框架开发过程中,势必涉及到租户数据库表结构升级更新,在此额外提供一套自己研发的开源工具dbtools,供使用

通用数据库升级工具dbtools
https://www.jianshu.com/p/05358def903f

记得点赞哦!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260

推荐阅读更多精彩内容