Spark SQL DataFrame和DataSet

翻译自Spark官网。

一、Spark Sql 历史

大数据主要包括三类操作:
1、 长时间运行的批量数据处理。
2、 交互式运行的数据查询。
3、 实时数据流处理。

Spark Sql 的前身是shark,最初是用在查询Hive的,hive是为熟悉数据库,但是不熟悉MapReduce技术人员提供的工具,hive提供一些列工具,可以对数据进行提取转化和加载(简称ETL),通过Hive工具可以查询分析存储在Hadoop上的大规模数据机制。Hive定义了简单的查询语言HQL(类SQL),可以把SQL操作转成MapReduce任务。
但是MapReduce的计算过程消耗大量的IO,降低了运行效率,为了提高SQL-On-Hadoop的效率,出现了大量的工具,包括Impla 和shark。
Shark 是spark的生态组件之一,它复用了hive的sql解析等组件,修改了内存管理,物理计划、执行模块,使它能够运行在Spark的计算引擎上,使用SQL的查询速度有了10-100倍的提升。

随着shark的发展,shark对hive的依赖限制了其发展,包括语法解析器和查询优化器。Spark团队汲取了shark的优点重新设计了Spark Sql,使之在数据兼容、性能优化、组件扩展等方面得到极大的提升。
数据兼容:不仅兼容Hive,还可以从RDD、parquet文件、Json文件获取数据、支持从RDBMS获取数据。
性能优化:采用内存列式存储、自定义序列化器等方式提升性能。
组件扩展:SQL的语法解析器、分析器、优化器都可以重新定义和扩展。

二、Spark Sql 概述

Spark SQL 是spark中用于处理结构化数据的模块。Spark SQL相对于RDD的API来说,提供更多结构化数据信息和计算方法。Spark SQL 提供更多额外的信息进行优化。可以通过SQL或DataSet API方式同Spark SQL进行交互。无论采用哪种方法,哪种语言进行计算操作,实际上都用相同的执行引擎,因此使用者可以在不同的API中进行切换,选择一种最自然的方式去执行一个转换。

SQL
一种使用Spark SQL 的方法是进行SQL查询。Spark SQL 可以从存在的Hive中读取数据。当在编程语言中使用SQL的时候,结果将返回一个DataSet或DataFrame类型封装的对象。
你也可以通过命令行或JDBC/ODBC方式使用SQL接口。

三、DataFrame和DataSet

DataSet是分布式的数据集合。DataSet是在Spark1.6中添加的新的接口。它集中了RDD的优点(强类型 和可以用强大lambda函数)以及Spark SQL优化的执行引擎。DataSet可以通过JVM的对象进行构建,可以用函数式的转换(map/flatmap/filter)进行多种操作。
DataSet API 在Scala和Java中都是可以用的。
DataSet 通过Encoder实现了自定义的序列化格式,使得某些操作可以在无需序列化情况下进行。另外Dataset还进行了包括Tungsten优化在内的很多性能方面的优化。

DataFrame和DataSet类似,也是个分布式集合,其中数据别组织成命名的列,可以看做关系数据库中的表,底层做了很多优化,可以通过很多数据源进行构建,比如RDD、结构化文件、外部数据库、Hive表。
DataFrame的前身是SchemaRDD。Spark1.3开始SchemaRDD更改为DataFrame。区别,不继承RDD,自己实现了RDD的大部分功能。可以在DataFrame上调用RDD的方法转化成另外一个RDD。
DataFrame可以看做分布式Row对象的集合,其提供了由列组成的详细模式信息,
使其可以得到优化。
DataFrame 不仅有比RDD更多的算子,还可以进行执行计划的优化。
DataSet包含了DataFrame的功能,Spark2.0中两者统一,DataFrame表示为DataSet[Row],即DataSet的子集。
使用API尽量使用DataSet ,不行再选用DataFrame,其次选择RDD。

四、DataFrame基本说明

要使用DataFrame,在2.0中需要SparkSession这个类,创建这个类的方法如下:

import org.apache.spark.sql.SparkSession
 
val spark = SparkSession
  .builder()
  .appName("Spark SQL Example")
  .config("spark.some.config.option", "some-value")
  .getOrCreate()
// For implicit conversions like converting RDDs to DataFrames
import spark.implicits._

通过SparkSession,应用程序可以通过存在的RDD、或者Hive表中或者Spark数据源中中创建DataFrame,下面是从JSON文件中创建DataFrame:

val df = spark.read.json("examples/src/main/resources/people.json")
// Displays the content of the DataFrame to stdout
df.show()
// +----+-------+
// | age|   name|
// +----+-------+
// |null|Michael|
// |  30|   Andy|
// |  19| Justin|
// +----+-------+

DataFrame 为DataSet[Row],可以执行一些非强制类型的转换,例子如下:

// This import is needed to use the $-notation
import spark.implicits._
// Print the schema in a tree format
df.printSchema()
// root
// |-- age: long (nullable = true)
// |-- name: string (nullable = true)
 
// Select only the "name" column
df.select("name").show()
// +-------+
// |   name|
// +-------+
// |Michael|
// |   Andy|
// | Justin|
// +-------+
 
// Select everybody, but increment the age by 1
df.select($"name", $"age" + 1).show()
// +-------+---------+
// |   name|(age + 1)|
// +-------+---------+
// |Michael|     null|
// |   Andy|       31|
// | Justin|       20|
// +-------+---------+
 
// Select people older than 21
df.filter($"age" > 21).show()
// +---+----+
// |age|name|
// +---+----+
// | 30|Andy|
// +---+----+
 
// Count people by age
df.groupBy("age").count().show()
// +----+-----+
// | age|count|
// +----+-----+
// |  19|    1|
// |null|    1|
// |  30|    1|
// +----+-----+

我自己理解是,DataFrame只是知道字段,但是不知道字段的类型,所以在执行这些操作的时候是没办法在编译的时候检查是否类型失败的,比如你可以对一个String进行减法操作,在执行的时候才报错,而DataSet不仅仅知道字段,而且知道字段类型,所以有更严格的错误检查。
在程序中使用SQL查询
在SparkSession中可以用程序的方式运行SQL查询,结果作为一个DataFrame返回。

// Register the DataFrame as a SQL temporary view
df.createOrReplaceTempView("people")
 
val sqlDF = spark.sql("SELECT * FROM people")
sqlDF.show()
// +----+-------+
// | age|   name|
// +----+-------+
// |null|Michael|
// |  30|   Andy|
// |  19| Justin|
// +----+-------+

五、DataSet使用说明

如上面所述,DataSet不同于RDD,没有使用Java序列化器或者Kryo进行序列化,而是使用一个特定的编码器进行序列化,这些序列化器可以自动生成,而且在spark执行很多操作(过滤、排序、hash)的时候不用进行反序列化。

创建DataSet

// Note: Case classes in Scala 2.10 can support only up to 22 fields. To work around this limit,
// you can use custom classes that implement the Product interface
case class Person(name: String, age: Long)
 
// Encoders are created for case classes
val caseClassDS = Seq(Person("Andy", 32)).toDS()
caseClassDS.show()
// +----+---+
// |name|age|
// +----+---+
// |Andy| 32|
// +----+---+
 
// Encoders for most common types are automatically provided by importing spark.implicits._
val primitiveDS = Seq(1, 2, 3).toDS()
primitiveDS.map(_ + 1).collect() // Returns: Array(2, 3, 4)
 
// DataFrames can be converted to a Dataset by providing a class. Mapping will be done by name
val path = "examples/src/main/resources/people.json"
val peopleDS = spark.read.json(path).as[Person]
peopleDS.show()
// +----+-------+
// | age|   name|
// +----+-------+
// |null|Michael|
// |  30|   Andy|
// |  19| Justin|
// +----+-------+

正如上述,DataSet不光有各个字段名,而且有其详细的类型,使其在编译的时候就可以进行错误的检查。

六、与RDD互操作

Spark SQL 支持两种不同的方法用于将存在的RDD转成Datasets。
第一种方法使用反射去推断包含特定类型对象的RDD模式(schema),该模式使你的代码更加简练,不过你必须在写Spark的程序的时候已经知道模式信息(比如RDD中的对象是自己定义的case class类型)。

第二种方法是通过一个编程接口,此时你需要构造一个模式,将其应用到一个已经存在的RDD上将其转化为DataFrame,该方法适用于运行之前还不知道列以及列的类型。

l 用反射推断模式
Spark SQL的Scala接口支持将包含case class的RDD自动转换为DataFrame。case class定义了表的模式,case class的参数名被反射读取并成为表的列名。case class也可以嵌套或者包含复杂类型(如序列或者数组)。示例如下:

import org.apache.spark.sql.catalyst.encoders.ExpressionEncoder
import org.apache.spark.sql.Encoder
 
// For implicit conversions from RDDs to DataFrames
import spark.implicits._
 
// Create an RDD of Person objects from a text file, convert it to a Dataframe
val peopleDF = spark.sparkContext
  .textFile("examples/src/main/resources/people.txt")
  .map(_.split(","))
  .map(attributes => Person(attributes(0), attributes(1).trim.toInt))
  .toDF()
// Register the DataFrame as a temporary view
peopleDF.createOrReplaceTempView("people")
 
// SQL statements can be run by using the sql methods provided by Spark
val teenagersDF = spark.sql("SELECT name, age FROM people WHERE age BETWEEN 13 AND 19")
 
// The columns of a row in the result can be accessed by field index
teenagersDF.map(teenager => "Name: " + teenager(0)).show()
// +------------+
// |       value|
// +------------+
// |Name: Justin|
// +------------+
 
// or by field name
teenagersDF.map(teenager => "Name: " + teenager.getAs[String]("name")).show()
// +------------+
// |       value|
// +------------+
// |Name: Justin|
// +------------+
 
// No pre-defined encoders for Dataset[Map[K,V]], define explicitly
implicit val mapEncoder = org.apache.spark.sql.Encoders.kryo[Map[String, Any]]
// Primitive types and case classes can be also defined as
implicit val stringIntMapEncoder: Encoder[Map[String, Int]] = ExpressionEncoder()
 
// row.getValuesMap[T] retrieves multiple columns at once into a Map[String, T]
teenagersDF.map(teenager => teenager.getValuesMap[Any](List("name", "age"))).collect()
// Array(Map("name" -> "Justin", "age" -> 19))

l 编程指定模式
当case class不能提前定义时(比如记录的结构被编码为字符串,或者当文本数据集被解析时不同用户需要映射不同的字段),可以通过下面三步来将RDD转换为DataFrame:
1、从原始RDD创建得到一个包含Row对象的RDD。
2、创建一个与第1步中Row的结构相匹配的StructType,以表示模式信息。
3、通过createDataFrame()将模式信息应用到第1步创建的RDD上。
举例说明:

import org.apache.spark.sql.types._
 
// 1、Create an RDD
val peopleRDD = spark.sparkContext.textFile("examples/src/main/resources/people.txt")
 
// The schema is encoded in a string
val schemaString = "name age"
 
///2、Generate the schema based on the string of schema
val fields = schemaString.split(" ")
  .map(fieldName => StructField(fieldName, StringType, nullable = true))
val schema = StructType(fields)
 
// Convert records of the RDD (people) to Rows
val rowRDD = peopleRDD
  .map(_.split(","))
  .map(attributes => Row(attributes(0), attributes(1).trim))
 
//3、 Apply the schema to the RDD
val peopleDF = spark.createDataFrame(rowRDD, schema)
 
// Creates a temporary view using the DataFrame
peopleDF.createOrReplaceTempView("people")
 
// SQL can be run over a temporary view created using DataFrames
val results = spark.sql("SELECT name FROM people")
 
// The results of SQL queries are DataFrames and support all the normal RDD operations
// The columns of a row in the result can be accessed by field index or by field name
results.map(attributes => "Name: " + attributes(0)).show()
// +-------------+
// |        value|
// +-------------+
// |Name: Michael|
// |   Name: Andy|
// | Name: Justin|
// +-------------+
 

七、数据源

DataFrame 可以当做标准的RDD进行操作,也可以注册为一个临时表。将DataFrame注册为一个临时表,准许你在上执行SQL查询。
DataFrame接口可以处理多种数据源,SparkSql 也内建若干个有用的数据源格式(Json、parquet、jdbc)。此外,当你用SQL查询的数据源的时候,只使用了一部分字段,SparkSQL可以智能扫描这些字段。

标准的加载和保存函数
在最简单的方式,默认的数据源(parquet 除非在spark.sql.source.default中特殊设置)将被用来执行多种操作。

val usersDF = spark.read.load("examples/src/main/resources/users.parquet")
usersDF.select("name", "favorite_color").write.save("namesAndFavColors.parquet")

手工指定选项
你可以通过手工指定数据源和任何想要传递给数据源的选项。指定数据源通常需要使用数据源全名(如org.apache.spark.sql.parquet),但对于内建数据源,你也可以使用它们的短名(json、parquet和jdbc)。并且不同的数据源类型之间都可以相互转换。
示例如下:

val peopleDF = spark.read.format("json").load("examples/src/main/resources/people.json")
peopleDF.select("name", "age").write.format("parquet").save("namesAndAges.parquet")

在文件上直接运行SQL
除了你可以通过读文件的API将文件读入DataFrame 然后查询它,还可以通过SQL直接查询文件。
val sqlDF = spark.sql("SELECT * FROM parquet.\examples/src/main/resources/users.parquet`")`

保存模式
可以通过一个选项来进行设置保存模式 SaveMode,这个选项指定了当数据存在的时候如何处理。要认识到这些选项不是用任何锁,也不是原子性的。此外,当执行OverWrite,在写数据之前删除老数据。

Scala/Java Any Language Meaning
SaveMode.ErrorIfExists(default) "error"(default) When saving a DataFrame to a data source,if data already exists, an exception is expected to be thrown.
SaveMode.Append "append" When saving a DataFrame to a data source,if data/table already exists, contents of the DataFrame are expected to be appended to existing data.
SaveMode.Overwrite "overwrite" Overwrite mode means that when saving a DataFrame to a data source, if data/table already exists, existing data is expected to be overwritten by the contents of the DataFrame.
SaveMode.Ignore "ignore" Ignore mode means that when saving a DataFrame to a data source, if data already exists, the save operation is expected to not save the contents of the DataFrame and to not change the existing data. This is similar to a CREATE TABLE IF NOT EXISTS in SQL

** 保存到持久表**
DataFrame 可以通过saveAsTable命令将数据作为持久表保存到Hive的元数据中。使用这个功能不一定需要Hive的部署。Spark将创建一个默认的本地的Hive的元数据保存(通过用Derby(一种数据库))。不同于createOrReplaceTempView,saveAsTable将实现DataFrame内容和创建一个指向这个Hive元数据的指针。持久表在你的spark程序重启后仍然存在,只要你保存你和元数据存储的连接。可以通过SparkSession调用table这个方法,来将DataFrame保存为一个持久表。

通过默认的saveAsTable 将会创建一个“管理表”,意思是数据的位置将被元数据控制。在数据表被删除的时候管理表也会被删除。

Parquet文件
Parquet 格式是被许多其他的数据处理系统支持的列数据格式类型。Spark Sql支持在读写Parquet文件的时候自动保存原始数据的模式信息。在写Parquet文件时候,所有的列将会因为兼容原因转成nullable。

编程方式加载Parquet数据:

// Encoders for most common types are automatically provided by importing spark.implicits._
import spark.implicits._
 
val peopleDF = spark.read.json("examples/src/main/resources/people.json")
 
// DataFrames can be saved as Parquet files, maintaining the schema information
peopleDF.write.parquet("people.parquet")
 
// Read in the parquet file created above
// Parquet files are self-describing so the schema is preserved
// The result of loading a Parquet file is also a DataFrame
val parquetFileDF = spark.read.parquet("people.parquet")
 
// Parquet files can also be used to create a temporary view and then used in SQL statements
parquetFileDF.createOrReplaceTempView("parquetFile")
val namesDF = spark.sql("SELECT name FROM parquetFile WHERE age BETWEEN 13 AND 19")
namesDF.map(attributes => "Name: " + attributes(0)).show()
// +------------+
// |       value|
// +------------+
// |Name: Justin|
// +------------+

Parquet分区自动发现
在很多系统中(如Hive),表分区是一个通用的优化方法。在一个分区的表中,数据通常存储在不同的目录中,列名和列值通常被编码在分区目录名中以区分不同的分区。Parquet数据源能够自动地发现和推断分区信息。 如下是人口分区表目录结构,其中gender和country是分区列:
path
└── to
└── table
├── gender=male
│ ├── ...
│ │
│ ├── country=US
│ │ └── data.parquet
│ ├── country=CN
│ │ └── data.parquet
│ └── ...
└── gender=female
├── ...

├── country=US
│ └── data.parquet
├── country=CN
│ └── data.parquet
└── ...
传递/path/to/table 给SparkSession.read.parquet或者SparkSession.read.load,Spark SQL
将会自动从路径中提取分区信息,返回的DataFrame的分区信息如下:
root
|-- name: string (nullable = true)
|-- age: long (nullable = true)
|-- gender: string (nullable = true)
|-- country: string (nullable = true)
注意上述分区的数据类型是自动推断的。目前支持数值类型和string类型。
如果你不想自动推断分区列的数据类型。自动推断分区列是通过spark.sql.sources.partitionColumnTypeInference.enabled,选项,默认为ture。当类型推断不可用时候,自动指定分区的列为string类型。
从spark1.6开始、分区默认只发现给定路径下的分区。比如用户传递/path/to/table/gender=male 作为读取数据路径,gender将不被作为一个分区列。你可以在数据源选项中设置basePath来指定分区发现应该开始的基路径,那样像上述设置,gender将会被作为分区列。

Parquet模式合并
就像ProtocolBuffer、Avro和Thrift,Parquet也支持模式演化(schema evolution)。这就意味着你可以向一个简单的模式逐步添加列从而构建一个复杂的模式。这种方式可能导致模式信息分散在不同的Parquet文件中,Parquet数据源能够自动检测到这种情况并且合并所有这些文件中的模式信息。
但是由于模式合并是相对昂贵的操作,并且绝大多数情况下不是必须的,因此从Spark 1.5.0开始缺省关闭模式合并。开启方式:在读取Parquet文件时,
1、 设置数据源选项mergeSchema为true,
2、 或者设置全局的SQL选项spark.sql.parquet.mergeSchema为true。
示例如下:

// This is used to implicitly convert an RDD to a DataFrame.
import spark.implicits._
 
// Create a simple DataFrame, store into a partition directory
val squaresDF = spark.sparkContext.makeRDD(1 to 5).map(i => (i, i * i)).toDF("value", "square")
squaresDF.write.parquet("data/test_table/key=1")
 
// Create another DataFrame in a new partition directory,
// adding a new column and dropping an existing column
val cubesDF = spark.sparkContext.makeRDD(6 to 10).map(i => (i, i * i * i)).toDF("value", "cube")
cubesDF.write.parquet("data/test_table/key=2")
 
// Read the partitioned table
val mergedDF = spark.read.option("mergeSchema", "true").parquet("data/test_table")
mergedDF.printSchema()
 
// The final schema consists of all 3 columns in the Parquet files together
// with the partitioning column appeared in the partition directory paths
// root
// |-- value: int (nullable = true)
// |-- square: int (nullable = true)
// |-- cube: int (nullable = true)
// |-- key : int (nullable = true)

Parquet表和Hive元数据转换
暂时忽略。

JSON DataSet
Spark SQL 可以自动推断出JSON 数据集的模式,把它加载为一个DataSet[Row].在通过SparkSession.read.json()读取一个String类型的RDD或者一个JSON文件。

注意: 这里面的Json每一行必须是一个有效的JSOn对象,如果一个对象跨越多行将导致失败。

举例:

*// A JSON dataset is pointed to by path.*
*// The path can be either a single text file or a directory storing text files*
**val** path **=** "examples/src/main/resources/people.json"
**val** peopleDF **=** spark.read.json(path)
 
*// The inferred schema can be visualized using the printSchema() method*
peopleDF.printSchema()
*// root*
*//  |-- age: long (nullable = true)*
*//  |-- name: string (nullable = true)*
 
*// Creates a temporary view using the DataFrame*
peopleDF.createOrReplaceTempView("people")
 
*// SQL statements can be run by using the sql methods provided by spark*
**val** teenagerNamesDF **=** spark.sql("SELECT name FROM people WHERE age BETWEEN 13 AND 19")
teenagerNamesDF.show()
*// +------+*
*// |  name|*
*// +------+*
*// |Justin|*
*// +------+*
 
*// Alternatively, a DataFrame can be created for a JSON dataset represented by*
*// an RDD[String] storing one JSON object per string*
**val** otherPeopleRDD **=** spark.sparkContext.makeRDD(
  """{"name":"Yin","address":{"city":"Columbus","state":"Ohio"}}""" :: **Nil**)
**val** otherPeople **=** spark.read.json(otherPeopleRDD)
otherPeople.show()
*// +---------------+----+*
*// |        address|name|*
*// +---------------+----+*
*// |[Columbus,Ohio]| Yin|*
*// +---------------+----+*
otherPeople.printSchema()
root
|-- address: struct (nullable = true)
|    |-- city: string (nullable = true)
|    |-- state: string (nullable = true)
|-- name: string (nullable = true)

Hive Tables
忽略

和不同Hive 版本交互
忽略

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

推荐阅读更多精彩内容