springboot 多数据源切换以及动态创建

背景

最近在做一个数据隔离的需求,相同的库表结构,根据不同的用户,数据落到不同的库。第一想到的就是多数据源的方案。
以前做过类似的多数据源的切换方案,是在项目启动的时候就知道数据源,提前加载数据源,现在是只有用户过来的时候才去创建数据源,同时后期又增加了不同类型的用户,不需要修改代码,直接使用。

ps:在网上看了文章,有许多把多数据源切换跟动态数据源加载混在一起,让很多人迷茫,在这里说下

多数据源:同一个项目,用到多个数据源,在项目启动的时候就已经创建出来,比如:库存数据库、订单数据。
动态加载:项目启动的时候不知道应该创建那个数据库,只有根据请求的用户信息,动态创建相应的数据源。

技术实现

整体实现流程

image.png

动态数据源实现

  • 动态数据源实现整体流程


    image.png

技术实现

动态数据源-AbstractRoutingDataSource

源码

/*
 * Copyright 2002-2017 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.springframework.jdbc.datasource.lookup;

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

import org.springframework.beans.factory.InitializingBean;
import org.springframework.jdbc.datasource.AbstractDataSource;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;

/**
 * Abstract {@link javax.sql.DataSource} implementation that routes {@link #getConnection()}
 * calls to one of various target DataSources based on a lookup key. The latter is usually
 * (but not necessarily) determined through some thread-bound transaction context.
 *
 * @author Juergen Hoeller
 * @since 2.0.1
 * @see #setTargetDataSources
 * @see #setDefaultTargetDataSource
 * @see #determineCurrentLookupKey()
 */
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;


    /**
     * Specify the map of target DataSources, with the lookup key as key.
     * The mapped value can either be a corresponding {@link javax.sql.DataSource}
     * instance or a data source name String (to be resolved via a
     * {@link #setDataSourceLookup DataSourceLookup}).
     * <p>The key can be of arbitrary type; this class implements the
     * generic lookup process only. The concrete key representation will
     * be handled by {@link #resolveSpecifiedLookupKey(Object)} and
     * {@link #determineCurrentLookupKey()}.
     */
    public void setTargetDataSources(Map<Object, Object> targetDataSources) {
        this.targetDataSources = targetDataSources;
    }

    /**
     * Specify the default target DataSource, if any.
     * <p>The mapped value can either be a corresponding {@link javax.sql.DataSource}
     * instance or a data source name String (to be resolved via a
     * {@link #setDataSourceLookup DataSourceLookup}).
     * <p>This DataSource will be used as target if none of the keyed
     * {@link #setTargetDataSources targetDataSources} match the
     * {@link #determineCurrentLookupKey()} current lookup key.
     */
    public void setDefaultTargetDataSource(Object defaultTargetDataSource) {
        this.defaultTargetDataSource = defaultTargetDataSource;
    }

    /**
     * Specify whether to apply a lenient fallback to the default DataSource
     * if no specific DataSource could be found for the current lookup key.
     * <p>Default is "true", accepting lookup keys without a corresponding entry
     * in the target DataSource map - simply falling back to the default DataSource
     * in that case.
     * <p>Switch this flag to "false" if you would prefer the fallback to only apply
     * if the lookup key was {@code null}. Lookup keys without a DataSource
     * entry will then lead to an IllegalStateException.
     * @see #setTargetDataSources
     * @see #setDefaultTargetDataSource
     * @see #determineCurrentLookupKey()
     */
    public void setLenientFallback(boolean lenientFallback) {
        this.lenientFallback = lenientFallback;
    }

    /**
     * Set the DataSourceLookup implementation to use for resolving data source
     * name Strings in the {@link #setTargetDataSources targetDataSources} map.
     * <p>Default is a {@link JndiDataSourceLookup}, allowing the JNDI names
     * of application server DataSources to be specified directly.
     */
    public void setDataSourceLookup(@Nullable DataSourceLookup dataSourceLookup) {
        this.dataSourceLookup = (dataSourceLookup != null ? dataSourceLookup : new JndiDataSourceLookup());
    }


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

    /**
     * Resolve the given lookup key object, as specified in the
     * {@link #setTargetDataSources targetDataSources} map, into
     * the actual lookup key to be used for matching with the
     * {@link #determineCurrentLookupKey() current lookup key}.
     * <p>The default implementation simply returns the given key as-is.
     * @param lookupKey the lookup key object as specified by the user
     * @return the lookup key as needed for matching
     */
    protected Object resolveSpecifiedLookupKey(Object lookupKey) {
        return lookupKey;
    }

    /**
     * Resolve the specified data source object into a DataSource instance.
     * <p>The default implementation handles DataSource instances and data source
     * names (to be resolved via a {@link #setDataSourceLookup DataSourceLookup}).
     * @param dataSource the data source value object as specified in the
     * {@link #setTargetDataSources targetDataSources} map
     * @return the resolved DataSource (never {@code null})
     * @throws IllegalArgumentException in case of an unsupported value type
     */
    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);
        }
    }


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

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

    @Override
    @SuppressWarnings("unchecked")
    public <T> T unwrap(Class<T> iface) throws SQLException {
        if (iface.isInstance(this)) {
            return (T) this;
        }
        return determineTargetDataSource().unwrap(iface);
    }

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

    /**
     * Retrieve the current target DataSource. Determines the
     * {@link #determineCurrentLookupKey() current lookup key}, performs
     * a lookup in the {@link #setTargetDataSources targetDataSources} map,
     * falls back to the specified
     * {@link #setDefaultTargetDataSource default target DataSource} if necessary.
     * @see #determineCurrentLookupKey()
     */
    protected DataSource determineTargetDataSource() {
        Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
        Object lookupKey = determineCurrentLookupKey();
        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 + "]");
        }
        return dataSource;
    }

      ##
    /**
     * Determine the current lookup key. This will typically be
     * implemented to check a thread-bound transaction context.
     * <p>Allows for arbitrary keys. The returned key needs
     * to match the stored lookup key type, as resolved by the
     * {@link #resolveSpecifiedLookupKey} method.
     */
    @Nullable
    protected abstract Object determineCurrentLookupKey();

}

从源码可以看出主要涉及到的变量

Map<Object, Object> targetDataSources;//外部创建的数据源都放在这个集合下
Object defaultTargetDataSource;//指定的默认数据源
Map<Object, DataSource> resolvedDataSources;//内部使用的数据源 跟目标数据源对应
DataSource resolvedDefaultDataSource;//跟默认数据源对应
他们直接的关系是:targetDataSources 是外部调用,resolvedDataSources是内部使用,当选加载数据源的时候,targetDataSources数据源集合赋值给resolvedDataSources

变量使用的地方

public void afterPropertiesSet() {
        if (this.targetDataSources == null) {
            throw new IllegalArgumentException("Property 'targetDataSources' is required");
        }
        //数据源集合赋值  默认数据源默认
        this.resolvedDataSources = new HashMap<>(this.targetDataSources.size());
        this.targetDataSources.forEach((key, value) -> {
            Object lookupKey = resolveSpecifiedLookupKey(key);
            DataSource dataSource = resolveSpecifiedDataSource(value);
            this.resolvedDataSources.put(lookupKey, dataSource);
        });
        if (this.defaultTargetDataSource != null) {
            this.resolvedDefaultDataSource = resolveSpecifiedDataSource(this.defaultTargetDataSource);
        }
    }

在多数据源切换中,抽象类AbstractRoutingDataSource里面的抽象方法determineCurrentLookupKey必须实现,切换主要是根据这个方法进行切换,代码如下

protected DataSource determineTargetDataSource() {
        Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
//调用此方法拿到,切换数据源的标记,进行切换
        Object lookupKey = determineCurrentLookupKey();
        DataSource dataSource = this.resolvedDataSources.get(lookupKey);
//这里说明下,默认数据源必须要设置,当数据源集合获取不到数据源的时候,默认使用默认数据源进行兜底resolvedDefaultDataSource
        if (dataSource == null && (this.lenientFallback || lookupKey == null)) {
            dataSource = this.resolvedDefaultDataSource;
        }
        if (dataSource == null) {
            throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
        }
        return dataSource;
    }

    /**
     * Determine the current lookup key. This will typically be
     * implemented to check a thread-bound transaction context.
     * <p>Allows for arbitrary keys. The returned key needs
     * to match the stored lookup key type, as resolved by the
     * {@link #resolveSpecifiedLookupKey} method.
     */
    @Nullable
    protected abstract Object determineCurrentLookupKey();

上面分析了下源码,到此 我们大概知道怎么去实现动态数据源的加载以及切换

具体实现

  1. 创建一个全部线程变量,控制数据源的切换 DatabaseContextHolder
public class DatabaseContextHolder {
    
    //线程变量,每一个线程一个值,相互隔离
    private static ThreadLocal<String> contextHolder = new ThreadLocal<>();

    public static void setDatabaseType(String databaseType){
        contextHolder.set(databaseType);
    }

    public static String getDataBaseType(){
        return contextHolder.get();
    }

    public static void clearDbKey(){
        contextHolder.remove();
    }
}
  1. 继承AbstractRoutingDataSource创建动态数据源,DynamicDataSource
public class DynamicDataSource extends AbstractRoutingDataSource {

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

    private static DynamicDataSource instance;
    private static byte[] lock=new byte[0];
    //数据源集合,
    private static Map<Object,Object> dataSourceMap=new HashMap<Object, Object>();

    @Override
    public void setTargetDataSources(Map<Object, Object> targetDataSources) {
        super.setTargetDataSources(targetDataSources);
        dataSourceMap.putAll(targetDataSources);
        super.afterPropertiesSet();// 必须添加该句,否则新添加数据源无法识别到
    }

    public Map<Object, Object> getDataSourceMap() {
        return dataSourceMap;
    }

    public static synchronized DynamicDataSource getInstance(){
        if(instance==null){
            synchronized (lock){
                if(instance==null){
                    instance=new DynamicDataSource();
                }
            }
        }
        return instance;
    }

    @Override
    protected Object determineCurrentLookupKey() {
        logger.info("当前使用的数据源:{}",DatabaseContextHolder.getDataBaseType());
        return DatabaseContextHolder.getDataBaseType();
    }


}

此类解释下,单例模式、dataSourceMap,主要是为了在数据源初始化以后,后续有的数据源进来,能动态加载,不至于重新应用, afterPropertiesSet()这个方法必须调用,否则新增加的数据源不会生效,具体看上面的代码
以上两步,动态数据源就创建好了,可以替代DataSource了,接下来,应该怎么使用呢?

  1. 创建数据源,DataSourceConfig
@Configuration
public class DataSourceConfig {

    @Value("${spring.datasource.master.url}")
    private String masterDBUrl;
    @Value("${spring.datasource.master.username}")
    private String masterDBUser;
    @Value("${spring.datasource.master.password}")
    private String masterDBPassword;
    @Value("${spring.datasource.master.driver-class-name}")
    private String masterDBDreiverName;

    @Bean
    public DynamicDataSource dynamicDataSource() {
        DynamicDataSource dynamicDataSource = DynamicDataSource.getInstance();



        DruidDataSource oneDataSouce = new DruidDataSource();
        oneDataSouce.setDriverClassName("com.mysql.jdbc.Driver");
        oneDataSouce.setUrl("jdbc:mysql://127.0.0.1:3306/kb_master?serverTimezone=Hongkong");
        oneDataSouce.setUsername("");
        oneDataSouce.setPassword("");

        DruidDataSource twoDataSource = new DruidDataSource();
        twoDataSource.setDriverClassName("com.mysql.jdbc.Driver");
        twoDataSource.setUrl("jdbc:mysql://127.0.0.1:3306/kb_master?serverTimezone=Hongkong");
        twoDataSource.setUsername("");
        twoDataSource.setPassword("");

        Map<Object,Object> map = new HashMap<>();
        map.put("oneDataSouce", oneDataSouce);
        map.put("twoDataSource", twoDataSource);
        //添加数据源结合
        dynamicDataSource.setTargetDataSources(map);
        //必须设置一个默认数据源兜底
        dynamicDataSource.setDefaultTargetDataSource(oneDataSouce);

        return dynamicDataSource;
    }

    @Bean
    public SqlSessionFactory sqlSessionFactory(
        @Qualifier("dynamicDataSource") DataSource dynamicDataSource)
        throws Exception {
        SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
        bean.setDataSource(dynamicDataSource);
        bean.setMapperLocations(new PathMatchingResourcePatternResolver()
            .getResources("classpath*:mappers/*.xml"));
        return bean.getObject();

    }

    @Bean(name = "sqlSessionTemplate")
    public SqlSessionTemplate sqlSessionTemplate(
        @Qualifier("sqlSessionFactory") SqlSessionFactory sqlSessionFactory)
        throws Exception {
        return new SqlSessionTemplate(sqlSessionFactory);
    }

   @Bean
    public MapperScannerConfigurer mapperScannerConfigurer() {
        MapperScannerConfigurer mapperScannerConfigurer = new MapperScannerConfigurer();
        mapperScannerConfigurer.setSqlSessionFactoryBeanName("sqlSessionFactory");
        //跟mybatis映射mapper的时候,特别注意,一定要具体到mapper的包下,不然会导致,在bean会被加载两次,例如:我就遇到service 被加载了两次具体大家可以看下MapperScannerConfigurer
        mapperScannerConfigurer.setBasePackage("com.cn.kbtest");
        return mapperScannerConfigurer;
    }
}

我用的是springboot,如果是其他这配置,写在xml里面就行,具体可以自行百度
上面动态数据源基本上就写完了,下面看怎么使用

  1. 通过AOP的切面进行拦截,可以对包路径进行拦截,也有对指定的注解进行拦截,然后进行相应的数据库操作
@Aspect
@Order
@Component
public class DataSourceViewAspect {

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

    @Pointcut("@within(secondDataSourceMapper)")
    public void pointCut(SecondDataSourceMapper secondDataSourceMapper){}

    @Before("pointCut(secondDataSourceMapper)")
    public void doBefore(JoinPoint point, SecondDataSourceMapper secondDataSourceMapper){
        DatabaseContextHolder.setDatabaseType("view");
        logger.info("数据源切换为:{}","view");
    }

    @After("pointCut(secondDataSourceMapper)")
    public void after(SecondDataSourceMapper secondDataSourceMapper){
        logger.info("清除数据源标记:{}" ,"view");
        DatabaseContextHolder.clearDbKey();
    }
}

上面是通过拦截这个注解,类下所有的方法,进行切换,也可以拦截package,具体可以查询aop,里面有个注意点,一定要@before,之前就进行切换,不然出错
这样就做到了动态数据源的切换,这是对项目启动的时候,对存在的数据源加载进行切换,如果后续新增了一个数据源,怎么办?

  1. 通过拦截器,对外部请求进行拦截,可以从header、cookie等里面获取数据源的标记,进行数据源的创建,然后加载
@Component
public class MyControllerAdvice implements HandlerInterceptor {

    @Value("${datasource.global.use-encrypted-password}")
    private boolean useEncryptedPassword;

    @Value("${datasource.dynamic.url}")
    private String url;

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
        throws Exception {
        HospitalContextHolder.remove();
    }

    @Value("${datasource.dynamic.username}")
    private String userName;

    @Value("${datasource.dynamic.password}")
    private String passWord;

    @Value("${datasource.dynamic.driver-class-name}")
    private String driverClassName;

    @Value("${hospital.db.isolation}")
    private boolean isolation;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
        throws IOException {
        if (isolation){
            String hospitalId = request.getHeader("hospitalId");
            if(StringUtils.isNotBlank(hospitalId)){
                String dataType="wit120_"+request.getHeader("hospitalId");
                DynamicDataSource dynamicDataSource = DynamicDataSource.getInstance();
                Map<Object,Object> dataSourceMap = dynamicDataSource.getDataSourceMap();
                if (dataSourceMap.get(dataType)==null){
                    DruidDataSource datasource = new DruidDataSource();
                    String dbUrl = this.url.replace("wit120",dataType);
                    datasource.setUrl(dbUrl);
                    datasource.setUsername(this.userName);
                    datasource.setPassword(this.passWord);
                    datasource.setDriverClassName(this.driverClassName);
                    datasource.setInitialSize(5);
                    datasource.setMinIdle(5);
                    datasource.setMaxActive(20);
                    datasource.setMaxWait(60000);
                    datasource.setTimeBetweenEvictionRunsMillis(60000);
                    datasource.setMinEvictableIdleTimeMillis(300000);
                    datasource.setValidationQuery("SELECT 1 FROM DUAL ");
                    datasource.setTestWhileIdle(true);
                    datasource.setTestOnBorrow(false);
                    datasource.setTestOnReturn(false);
                    datasource.setPoolPreparedStatements(true);
                    datasource.setMaxPoolPreparedStatementPerConnectionSize(20);
                    try {
                        datasource.setFilters("config,stat,wall,log4j");
                        datasource.setConnectionProperties("allowMultiQueries=true;druid.stat.mergeSql=true;druid.stat.slowSqlMillis=3000;config.decrypt="+useEncryptedPassword);
                        dynamicDataSource.setTargetDataSources(dataSourceMap);
                        datasource.getConnection();
                        //加载数据源
                        dataSourceMap.put(dataType,datasource);
                    } catch (Exception e) {
                        datasource.close();
                        refuse(response);
                        return false;
                    }
                }
                //存储医院标记
                HospitalContextHolder.setHospitalId(hospitalId);
            }
        }
        return true;
    }

    /**
     * 拒绝的响应处理
     *
     * @param response
     * @throws IOException
     */
    private void refuse(HttpServletResponse response) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.getOutputStream().write(ResultUtil.resultFailed(BaseBizError.HOSPTAIL_Id_NOT_EXIST).toJsonString().getBytes("UTF-8"));
    }

}

基本上 就实现了,动态数据源的加载,切换

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

推荐阅读更多精彩内容