DBLE 2.17.08.1与MyCat 1.6.5的启动过程(5)——加载配置文件schema.xml

schema.xml的加载过程概况


com.actiontech.dble.config.loader.xml.XMLSchemaLoaderload()方法是schema.xml的加载入口。这个加载入口仅仅把schema.xml,以带DTD校验的方式加载成到内存中,形成w3c的DOM。然后依次调用loadDataHosts()loadDataNodes()loadSchemas()这三个方法,处理schema.xml中的三种一级标签,加载成com.actiontech.dble.config.model命名空间中对应的类的对象。

方法名 处理的标签 目标类
loadDataHosts() <dataHost> DataHostConfig
loadDataNodes() <dataNode> DataNodeConfig
loadSchemas() <schema> SchemaConfig

处理<dataHost>标签——loadDataHosts()


loadDataHosts()的使命是将<dataHost>标签加载成DataHostConfig对象。在逻辑结构上,<dataHost>标签和DataHostConfig对象完全相同。

<dataHost>属性 DataHostConfig属性
name String name
maxCon int maxCon
minCon int minCon
balance int balance
switchType int switchType
slaveThreshold int slaveThreshold
tempReadHostAvailable boolean tempReadHostAvailable
<heartbeat> String heartbeatSQL
<writeHost> DBHostConfig[] writeHosts
<readHost> Map<Integer, DBHostConfig[]> readHosts

可以看到,相对于name这些属性,<writeHost><readHost>这两个property(XML概念)的处理是重点,其中需要着重解决下面两个问题:

  1. <writeHost><readHost>应该加载成什么?

除了<writeHost>可以包含一个或多个<readHost>外,在属性层面,两者基本没有差异。因此统一用com.actiontech.dble.config.model.DBHostConfig这个类来标识它们。一个DBHostConfig对象表示一个<writeHost><readHost>标签。

  1. <writeHost><readHost>的一对多包含关系应该如何保存下来?

每个<writeHost>标签在DataHostConfig.writeHosts这个数组中存储,也因此会有它在数组中的序号,所以DataHostConfig.readHosts这个Map集合利用了这一点,设计成了“<<writeHost>在writeHosts中的序号, 这个<writeHost>拥有的<readHost>数组>”这么一种形式来存储(虽然我觉得用存储密度更高的二维数组可能会更好)。

了解了以上概况之后,它的工作流程就很明晰了,大致是:

  1. 从schema.xml中逐个找出<dataHost>标签

  2. 加载<dataHost>的简单属性到临时变量中:name, maxCon, minCon, balance, switchType, slaveThreshold, tempReadHostAvailable和<heartbeat>

  3. 加载<dataHost>的复杂属性<writeHost><readHost>到临时变量中

  4. 根据临时变量生成DataHostConfig对象,并登记到XMLSchemaLoader中(加入到内部Map集合中)

private void loadDataHosts(Element root) {

    // 提取schema.xml中所有的<dataHost>标签
    NodeList list = root.getElementsByTagName("dataHost");

    // 逐个<dataHost>标签进行处理
    for (int i = 0, n = list.getLength(); i < n; ++i) {
        Element element = (Element) list.item(i);

        // 加载简单属性到临时变量中:name, maxCon, minCon, balance, switchType, slaveThreshold, tempReadHostAvailable和<heartbeat>
        String name = element.getAttribute("name");
        if (dataHosts.containsKey(name)) {
            throw new ConfigException("dataHost name " + name + "duplicated!");
        }
        int maxCon = Integer.parseInt(element.getAttribute("maxCon"));
        int minCon = Integer.parseInt(element.getAttribute("minCon"));
        final int balance = Integer.parseInt(element.getAttribute("balance"));
        String switchTypeStr = element.getAttribute("switchType");
        int switchType = switchTypeStr.equals("") ? -1 : Integer.parseInt(switchTypeStr);
        String slaveThresholdStr = element.getAttribute("slaveThreshold");
        int slaveThreshold = slaveThresholdStr.equals("") ? -1 : Integer.parseInt(slaveThresholdStr);
        String tempReadHostAvailableStr = element.getAttribute("tempReadHostAvailable");
        boolean tempReadHostAvailable = !tempReadHostAvailableStr.equals("") && Integer.parseInt(tempReadHostAvailableStr) > 0;

        final String heartbeatSQL = element.getElementsByTagName("heartbeat").item(0).getTextContent();

        // 获取当前<dataHost>下所有的<writeHost>
        NodeList writeNodes = element.getElementsByTagName("writeHost");

        // 初始化当前<dataHost>中,<writeHost>和<readHost>的临时变量(数组及集合)
        DBHostConfig[] writeDbConfs = new DBHostConfig[writeNodes.getLength()];
        Map<Integer, DBHostConfig[]> readHostsMap = new HashMap<>(2);

        // 逐个<writeHost>进行处理
        for (int w = 0; w < writeDbConfs.length; w++) {
            Element writeNode = (Element) writeNodes.item(w);

            // 创建<writeHost>对应的DBHostConfig对象,并加入到临时变量的数组中
            writeDbConfs[w] = createDBHostConf(name, writeNode, maxCon, minCon);

            // 获取当前<writeHost>下所有的<readHost>
            NodeList readNodes = writeNode.getElementsByTagName("readHost");

            if (readNodes.getLength() != 0) {

              // 创建存放当前<writeHost>下所有<readHost>的临时数组
                DBHostConfig[] readDbConfs = new DBHostConfig[readNodes.getLength()];

                // 逐个<readHost>进行处理
                for (int r = 0; r < readDbConfs.length; r++) {
                    Element readNode = (Element) readNodes.item(r);

                    // 创建<writeHost>对应的DBHostConfig对象,并加入到临时变量的数组中
                    readDbConfs[r] = createDBHostConf(name, readNode, maxCon, minCon);
                }

                // 将准备好的临时数组注册到<readHost>临时变量(Map集合)中
                readHostsMap.put(w, readDbConfs);
            }
        }

        // 根据<dataHost>各属性的临时变量创建DataHostConfig
        DataHostConfig hostConf = new DataHostConfig(name,
                writeDbConfs, readHostsMap, switchType, slaveThreshold, tempReadHostAvailable);
        hostConf.setMaxCon(maxCon);
        hostConf.setMinCon(minCon);
        hostConf.setBalance(balance);
        hostConf.setHearbeatSQL(heartbeatSQL);

        // 将当前<dataHost>对应的DataHostConfig注册到XMLSchemaLoader的内部清单中
        dataHosts.put(hostConf.getName(), hostConf);
    }

}

当中,createDBHostConf()这个函数在DBLE和MyCat中,基本功能是一致的:

  • 获取<writeHost><readHost>的属性:host、url、user、password、usingDecrypt和weight
  • 检查host、url和user都不为空
  • 从host属性中分离出ip和port
  • 对password属性的内容进行RSA的解密(usingDecrypt属性为1时)

但是,由于设计思路的分歧,DBLE裁剪了MyCat中的以下功能:

  • 非MySQL数据库的支持(通过<dataHost>的dbType属性提供)
  • 其他JDBC Driver的支持(通过<dataHost>的dbDriver属性提供)
  • 无用属性filters和logTime
private DBHostConfig createDBHostConf(String dataHost, Element node, int maxCon, int minCon) {

    // 加载必须属性host、url和user
    String nodeHost = node.getAttribute("host");
    String nodeUrl = node.getAttribute("url");
    String user = node.getAttribute("user");

    String ip = null;
    int port = 0;

    // 检查必须属性是否都不为空
    if (empty(nodeHost) || empty(nodeUrl) || empty(user)) {
        throw new ConfigException(
                "dataHost " + dataHost +
                        " define error,some attributes of this element is empty: " +
                        nodeHost);
    }

    // 从host属性中分离出ip和port
    int colonIndex = nodeUrl.indexOf(':');
    ip = nodeUrl.substring(0, colonIndex).trim();
    port = Integer.parseInt(nodeUrl.substring(colonIndex + 1).trim());

    // 加载password和usingDecrypt属性
    String password = node.getAttribute("password");
    String usingDecrypt = node.getAttribute("usingDecrypt");

    // 对password属性的内容进行RSA的解密
    String passwordEncryty = DecryptUtil.dbHostDecrypt(usingDecrypt, nodeHost, user, password);

    // 创建目标DBHostConfig对象并根据各个属性赋值
    DBHostConfig conf = new DBHostConfig(nodeHost, ip, port, nodeUrl, user, passwordEncryty);
    conf.setMaxCon(maxCon);
    conf.setMinCon(minCon);

    // 读取weight属性并对DBHostConfig.weight赋值
    String weightStr = node.getAttribute("weight");
    int weight = "".equals(weightStr) ? PhysicalDBPool.WEIGHT : Integer.parseInt(weightStr);
    conf.setWeight(weight);

    // 返回准备好的DBHostConfig对象
    return conf;
}

处理<dataNode>标签——loadDataNodes()


loadDataHosts()相似,loadDataNodes()的使命是将<dataNode>标签加载成与之逻辑结构相同的Java对象。在这里,加载目标就是DataNodeConfig对象。

<dataNode>属性 DataNodeConfig属性
name String name
database String database
dataHost String dataHost

可以看出,相对于<dataHost>DataHostConfig<dataNode>DataNodeConfig要简单得多。但是,MyCat和DBLE为了让<dataNode>支持一种自制语法,loadDataNodes()的代码变得复杂了许多。

从现有代码来看,这个语法是为了减少schema.xml的编写量(并不会减少DataNodeConfig对象的个数),将多个拥有同样dataHost属性或database属性<dataNode>缩写成一个<dataNode>

<!-- 例子1:缩写dataHost属性相同的标签 -->

<!-- 缩写前 -->
<dataNode name="dn01" dataHost="dh01" database="customer" />
<dataNode name="dn03" dataHost="dh01" database="stock" />

<!-- 缩写后 -->
<dataNode name="dn01,dn03" 
          dataHost="dh01"
                    database="customer,stock“ />

<!-- 例子2:缩写database属性相同的标签 -->

<!-- 缩写前 -->
<dataNode name="dn01" dataHost="dh01" database="customer" />
<dataNode name="dn02" dataHost="dh02" database="customer" />

<!-- 缩写后 -->
<dataNode name="dn01,dn02" 
          dataHost="dh01,dh02"
                    database="customer“ />

<!-- 例子3:缩写dataHost和database属性局部相同的标签 -->

<!-- 缩写前 -->
<dataNode name="dn01" dataHost="dh01" database="customer" />
<dataNode name="dn02" dataHost="dh02" database="customer" />
<dataNode name="dn03" dataHost="dh01" database="stock" />
<dataNode name="dn04" dataHost="dh02" database="stock" />

<!-- 缩写后 -->
<dataNode name="dn01,dn02,dn03,dn04" 
          dataHost="dh01,dh02"
                    database="customer,stock“ />

但是这个缩写语法看起来未完全实现它的设计目标。在代码和注释中可以看到,除了“,”可以作为分隔符外,设计上还支持“$”和“-”,并且应该有不同的语义。但现在这些分隔符的作用没有差别,还没有完全实现设计目标。此外,缩写会损失一部分易读性,所以我不太建议使用该缩写语法。

loadDataNodes的工作流程大致是:

  1. 从schema.xml中逐个找出<dataNode>标签

  2. 加载<dataNode>的属性到临时变量中:name, dataHost和database

  3. 根据临时变量判断用户是否使用了缩写语法,从而决定是直接生成一个DataNodeConfig对象,还是用(dataHost, database)的两层循环了生成多个DataNodeConfig对象

  4. 将生成的一个或多个DataHostConfig对象登记到XMLSchemaLoader中(加入到内部Map集合中)

private void loadDataNodes(Element root) {

    // 提取schema.xml中所有的<dataNode>标签
    NodeList list = root.getElementsByTagName("dataNode");

    // 逐个<dataNode>标签进行处理
    for (int i = 0, n = list.getLength(); i < n; i++) {
        Element element = (Element) list.item(i);

        // 加载所有属性到临时变量中:name, dataHost和database
        String dnNamePre = element.getAttribute("name");
        String databaseStr = element.getAttribute("database");
        if (lowerCaseNames) {
            databaseStr = databaseStr.toLowerCase();
        }
        String host = element.getAttribute("dataHost");
        if (empty(dnNamePre) || empty(databaseStr) || empty(host)) {
            throw new ConfigException("dataNode " + dnNamePre + " define error ,attribute can't be empty");
        }

        // 根据用户属性中的输入,判断用户是否使用了缩写语法
        String[] dnNames = SplitUtil.split(dnNamePre, ',', '$', '-');
        String[] databases = SplitUtil.split(databaseStr, ',', '$', '-');
        String[] hostStrings = SplitUtil.split(host, ',', '$', '-');

        if (dnNames.length > 1 && dnNames.length != databases.length * hostStrings.length) {
            throw new ConfigException("dataNode " + dnNamePre +
                    " define error ,dnNames.length must be=databases.length*hostStrings.length");
        }

        if (dnNames.length > 1) {
            // 如果用户使用了缩写语法,
            // 就使用“外层dataHost,内层database”的两层循环去生生成多个DataNodeConfig,
            // 并注册到XMLSchemaLoader的内部清单中
            List<String[]> mhdList = mergerHostDatabase(hostStrings, databases);
            for (int k = 0; k < dnNames.length; k++) {
                String[] hd = mhdList.get(k);
                String dnName = dnNames[k];
                String databaseName = hd[1];
                String hostName = hd[0];
                createDataNode(dnName, databaseName, hostName);
            }

        } else {
            // 如果用户没有使用缩写语法,
            // 就直接生成一个DataNodeConfig,并注册到XMLSchemaLoader的内部清单中
            createDataNode(dnNamePre, databaseStr, host);
        }

    }
}

loadDataNodes()里用到了两个辅助方法,在这里简单说明一下:

  • mergerHostDatabase()是在确定用户使用了缩写语法之后,求两个字符串属性dataHost × database(dataHost与database的叉乘)
private List<String[]> mergerHostDatabase(String[] hostStrings, String[] databases) {
    List<String[]> mhdList = new ArrayList<>();
    for (String hostString : hostStrings) {
        for (String database : databases) {
            String[] hd = new String[2];
            hd[0] = hostString;
            hd[1] = database;
            mhdList.add(hd);
        }
    }
    return mhdList;
}
  • createDataNode()除了创建一个DataNodeConfig对象外,还必须说明它会自动把这个新对象注册到XMLSchemaLoader的内部清单里
private void createDataNode(String dnName, String database, String host) {

    // 创建新的DataNodeConfig对象
    DataNodeConfig conf = new DataNodeConfig(dnName, database, host);

    // 注册到XMLSchemaLoader之前的检查1:名称不能与已有的重复
    if (dataNodes.containsKey(conf.getName())) {
        throw new ConfigException("dataNode " + conf.getName() + " duplicated!");
    }

    // 注册到XMLSchemaLoader之前的检查2:dataHost属性指定的DataHost必须已注册
    if (!dataHosts.containsKey(host)) {
        throw new ConfigException("dataNode " + dnName + " reference dataHost:" + host + " not exists!");
    }

    // 将新的DataNodeConfig对象注册到XMLSchemaLoader的内部清单中
    dataNodes.put(conf.getName(), conf);
}

处理<schema>标签——loadSchemas()


loadSchemas()的使命是将<schema>标签加载成SchemaConfig对象。在逻辑结构上,<schema>标签和SchemaConfig对象有很多共同的地方。

<schema>属性 SchemaConfig属性
name String name
dataNode String dataNode
sqlMaxLimit int defaultMaxLimit
<table> Map<Integer, TableConfig> tables

此外,<schema>标签的子标签<table>由于涉及到了E-R关系这种比较特殊的分片策略,导致这个子标签的处理要分成三部分:

  1. 直接加载一般属性,例如name、primaryKey之类。

  2. 读取用户指定的分片算法(rule属性)和逻辑分片(dataNode属性)的字面值,进行是否存在之类的检查后,与之前的rule.xml和loadDataNodes()的成果关联起来。

  3. 如果含有<childTable>子标签,那这个<table>和它的<childTable>构成了E-R关系,会使用processChildTables()方法来递处理可能存在的多层<childTable>,并为每个<childTable>创建一个比较赋值特殊的TableConfig对象——它会被赋予parentTC、joinKey和parentKey属性。

tips:loadTables()执行完后,返回来的是每个<table><childTable>都有自己的TableConfig对象的一个Map<String, TableConfig>哈希表。如果存在E-R关系的表,还需要回到loadSchemas,由它调用XMLSchemaLoader类的其他方法来处理、整合E-R关系引入而产生的关系处理。SchemaConfig使用独立的数据结构ERTable来描述E-R关系。

private void loadSchemas(Element root) {

  // 读取所有的<schema>标签
  NodeList list = root.getElementsByTagName("schema");

    // 逐个<schema>标签进行处理
  for (int i = 0, n = list.getLength(); i < n; i++) {
    Element schemaElement = (Element) list.item(i);

        // 加载所有属性到临时变量中:name、dataNode和sqlMaxLimit
    String name = schemaElement.getAttribute("name");
    if (lowerCaseNames) {
      name = name.toLowerCase();
    }
    String dataNode = schemaElement.getAttribute("dataNode");
    String sqlMaxLimitStr = schemaElement.getAttribute("sqlMaxLimit");

        // 处理sqlMaxLimit属性:如果用户有配置sqlMaxLimit的话,就使用用户的配置值;如果没有,则设置为-1
    int sqlMaxLimit = -1;
    if (sqlMaxLimitStr != null && !sqlMaxLimitStr.isEmpty()) {
      sqlMaxLimit = Integer.parseInt(sqlMaxLimitStr);
    }

    // 读取dataNode属性,并直接加入到一个List<String>
    if (dataNode != null && !dataNode.isEmpty()) {
      List<String> dataNodeLst = new ArrayList<>(1);
      dataNodeLst.add(dataNode);
            // 调用checkDataNodeExists()来检查用户给这个<schema>指定的dataNode是不是都已经已经加载过的
      checkDataNodeExists(dataNodeLst);
    } else {
      dataNode = null;
    }

    // 调用loadTables()方法来加载当前<schema>里的所有<table>标签,每个标签加载成一个TableConfig对象,放到Map<String, TableConfig>中
    Map<String, TableConfig> tables = loadTables(schemaElement, lowerCaseNames);
    if (schemas.containsKey(name)) {
      throw new ConfigException("schema " + name + " duplicated!");
    }

    // if schema has no default dataNode,it must contains at least one table
    if (dataNode == null && tables.size() == 0) {
      throw new ConfigException(
              "schema " + name + " didn't config tables,so you must set dataNode property!");
    }

        // 生成SchemaConfig对象(tips:当中会涉及buildERMap()方法的调用,用于创建依据父表的分布来分布子表,不关注这种用法,跳过)
    SchemaConfig schemaConfig = new SchemaConfig(name, dataNode,
          tables, sqlMaxLimit);

    // 用mergeFuncNodeERMap()和mergeFkERMap()对新生成的SchemaConfig对象进行优化,(tips:用于ER表,不关注这种用法,跳过)
    mergeFuncNodeERMap(schemaConfig);
    mergeFkERMap(schemaConfig);

    // 将优化后的`SchemaConfig`注册到`XMLSchemaLoader`中
    schemas.put(name, schemaConfig);
  }

  // 处理完所有`<schema>`标签后,调用`makeAllErRelations()`(tips:用于ER表,不关注这种用法,跳过)
  makeAllErRelations();
}

loadTables()会将<table>标签加载成TableConfig对象。由于当中涉及众多E-R表的处理逻辑,而笔者并不关注,所以先暂时略过,只分析其处理普通<table>的过程:

  1. 创建TableConfig对象

  2. 读取简单属性name、primaryKey、autoIncrement、needAddLimit、type、rule和ruleRequired

  3. 读取属性dataNode,这个属性可以一次指定多个dataNode,使用“,”、“$”或“-”来分隔

  4. 调用checkDataNodeExists()来检查用户给这个<schema>指定的dataNode是不是都已经已经加载过的

  5. 如果当前处理的<table>有分片函数(非全局表),通过checkRuleSuitTable()来间接调用所有分片算法的基类AbstractPartitionAlgorithm的suitableFor(),检查用户配置的dataNode属性里dataNode个数是否与这个表配置的算法的分片数量一致(tips:这个检查依赖AbstractPartitionAlgorithm.getPartitionNum()提供当前算法需要的分片数量,默认上该函数返回的-1会让suitableFor()跳过检查,所以如果要实现自己的定制分片函数的话,需要自行覆盖该类;suitableFor()函数是final,无法被覆盖)

  6. 如果用户在配置<table>的dataNodes时使用了distribute语法(distribute(xxx,xxx,xxx))的话,调用distributeDataNodes()方法进行排序,保证dataNode编号过程中,尽可能地跨位于不同的物理分片(dataHost)上——首先,一个dataHost创建一个桶(数据结构是列表),把用户的dataNode过一遍,按照它们的dataHost放进对应的桶里;然后,按顺序从每个桶里取出一个dataNode,放到最终的返回值里,直到所有桶都取空——这样排序后,返回值里相邻的两个dataNode必然位于不同的dataHost上(tips:dataHost和dataNode的最终顺序仅与用户的输入顺序有关)

  7. 将初始化完成的TableConfig对象加入Map<String, TableConfig>

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

推荐阅读更多精彩内容