MongoDB第六讲聚合

下面将会讨论MongoDB中的聚合查询,聚合查询是以管道的方式来完成的,这和Java8中的Stream非常类似,首先聚合查询好比下图流程所示

聚合查询流程

)

管道概念

具体的思路是把Collection加载到聚合框架中,之后经过第一个条件限制(如:math)之后会把筛选出来的数据传给第二个条件,这其实就和管道的概念一样。常用的聚合管道有如下一些部分:

聚合管理 说明
$project 输出文档的内容,其实就等于sql中的投影操作,也类似find查询中的第二个条件
$match 查询条件
$limit 取多少条数据传递给下一个管道
$skip 跳过一定数量的文档
$unwind 扩展数组
$group 分组,和sql中的group类似
$sort 文档排序
$out 把管道的结果写到一个集合中

聚合管道的写法如下所示

db.collection.aggregate([{$project},{$match},....{$sort}])

注意写法,首先aggregate是聚合函数,和find一样使用()来调用,第一个参数就是管道,以数组的方式来定义多个管道所以要使用中括号[ ]来定义,数组中的每一个{}就表示一个管道,下面我们会根据上一讲的例子来详细分析管道的各个操作。由于原来的数据库被不小心删除了,下图是最新版本的数据对象模型图,数据结构没有任何的改变,无非一些名称有些改变而已,org变成了dep之类

聚合查询数据模型

)

先来查询一个可以使用find就完成的操作,我们查询年龄大于23的所有User并且仅仅显示name和age,此时需要通过两个管道来完成,match和project,match用来进行条件匹配,project用来确定查询出来的文档

db.user.aggregate([
        {$match:{age:{$gt:23}}},
        {$project:{_id:0,name:1,age:1}}
])

第一个管道完成了条件的查询,第二个管道确定了输出内容。很明显这个操作使用find更为直观一些,接下来将会介绍一些常用了聚合操作,通过聚合操作大家会感受到MongoDB的强大之处。

project和match管道

match用来进行条件筛选,但是注意,该管道放置的位置不一样,筛选所面对的集合是不一样的,我们先来看下面一种查询需求,在设计时,在dep中加入了用户的信息,如果我们希望查询所有的用户数量,使用find就有些力不从心了,需要使用到javascript的shell

var d = db.dep.findOne()
d.users.length

这个还没有办法把所有的信息列表出来,但是使用聚合查询可以很容易实现这个需求

db.dep.aggregate([{$project:{name:1,usize:{$size:"$users"}}}])
{
    "_id" : ObjectId("5a2947c367732445e8adfff0"),
    "name" : "教务处",
    "usize" : 3
}
{
    "_id" : ObjectId("5a2947c367732445e8adfff1"),
    "name" : "财务处",
    "usize" : 3
}
{
    "_id" : ObjectId("5a2947c367732445e8adfff2"),
    "name" : "计算机学院",
    "usize" : 5
}

改查询使用了一个管道,$project,用来投影具体需要的值,这里仅仅显示了name,usize这个可以自己定义,通过 $size 来统计,统计的地方,如果是文档中的key,需要使用$ 符号来引用。

下面加入match,首先通过第一个管道来筛选部门类型为行政部门的信息,看看查询代码

db.dep.aggregate([
    {$match:{type:"行政部门"}},
    {$project:{name:1,unum:{$size:"$users"}}}
])
{
    "_id" : ObjectId("5a2947c367732445e8adfff0"),
    "name" : "教务处",
    "unum" : 3
}
{
    "_id" : ObjectId("5a2947c367732445e8adfff1"),
    "name" : "财务处",
    "unum" : 3
}

第一个管道是基于原来的dep文档来进行筛选的,筛选了type为行政部门的所有信息,接下来我们在project管道之后再使用match来进行筛选,此时就是针对以上文档的,我们需要根据上面文档的值进行查询

db.dep.aggregate([
    {$match:{type:"行政部门"}},
    {$project:{name:1,unum:{$size:"$users"}}},
    {$match:{unum:{$gt:3}}}
])##查询人数大于3的用户
Fetched 0 record(s) in 0ms ##由于没有这个数据,所以显示没有查询到元素

这种查询方式对于关系数据库而言,还是不太容易实现的,需要用到子查询,对于project而言,还可以对文档进行重塑,先来看一个基本的重塑。

db.dep.aggregate([
    {$project:{_id:0,dep:{name:"$name",type:"$type"},unum:{$size:"$users"}}}
])
{
    "dep" : {
        "name" : "教务处",
        "type" : "行政部门"
    },
    "unum" : 3
}

我们把dep中的id去掉,并且把name和type加入了一个子文档dep中,在这个例子中,这种方法似乎比较多余,但是随着我们应用的深入,你会喜欢上这样的操作的,另外MongoDB提供了一组函数来帮助我们做各种重塑,会在后面专门讲解。

group管道

group对于关系型数据库而言,是非常重要的,是统计查询中的基础,MongoDB和关系数据库类似,都是通过group来进行分组查询,group的写法是固定的,{$group:{_id:'分组的对象',多个自定义投影}} 。_id这个是必须填写的,就等于sql中group by 后的这个值,而要统计哪些值,在后面通过写出来,这其实就等于select的投影

下面我们来使用一下group管道,虽然我们设计了在dep下添加users的array,但是我们也在user中添加了dep的映射,我们通过dep来分组统计一下部门下面来看使用group进行统计,这个需要在user这个document中查询

db.user.aggregate([{$group:{_id:'$dep.name',count:{$sum:1}}}])
{  "_id" : "教务处","count" : 3.0}
{"_id" : "财务处","count" : 3.0}
{"_id" : "计算机学院","count" : 5.0}

查询出来的结果是id和数量,使用$group,必须第一个值是_id,说明根据dep的 name进行分组,第二个参数的投影名称是count然后使用$sum ,sum表示求和,等于sql的count(*)。

下面我们希望查询人数大于3的学院,此时在sql中需要使用having来查询,但是对于MongoDB而言,只要在后面增加一个match的管道即可。

db.user.aggregate([
    {$group:{_id:'$dep.name',count:{$sum:1}}},
    {$match:{count:{$gt:3}}}
])
{
    "_id" : "计算机学院",
    "count" : 5.0
}

同样如果希望筛选group前的数据,只用在group管道前增加match即可

db.user.aggregate([
    {$match:{"dep.name":"财务处"}},
    {$group:{_id:'$dep.name',count:{$sum:1}}}
])
{
    "_id" : "财务处",
    "count" : 3.0
}

对于group而言,处理$sum 函数外还有如下几个函数:

group函数 说明
$addToSet 添加组里的一个元素到数组,数组里的元素唯一
$first 组里的第一个值。
$last 组里的最后一个值。
$max 组里某个字段的最大值
$min 组里某个字段的最小值
$avg 组里某个字段平均值
$push 添加组里的一个元素到数组,数组里的元素不唯一
$sum 组里某个字段的和

以上这些操作很多从字面意思就能知道怎么使用,但是first,last,push和addToSet稍微有些不好理解,我们先通过一个实例把几个统计函数学会,为了方便统计,首先对user加入salary这个数据,并且随机生成薪水的数量,薪水的数量随机4000-10000

Random.setRandomSeed()
db.user.find({}).forEach(function(u){
    db.user.updateOne({_id:u._id},{$set:{salary:(Random.randInt(6000)+4000)}})
})

首先统计每个部门的薪水之和平均薪水,最高薪水和最低薪水

db.user.aggregate([
    {$group:{_id:"$dep.id",
        salays:{$sum:"$salary"},
        avgs:{$avg:"$salary"},
        max:{$max:"$salary"},
        min:{$min:"$salary"}}}
])
{
    "_id" : ObjectId("5a2947c367732445e8adfff0"),
    "salays" : 19394.0,
    "avgs" : 6464.66666666667,
    "max" : 8926.0,
    "min" : 4927.0
}
{
    "_id" : ObjectId("5a2947c367732445e8adfff1"),
    "salays" : 27166.0,
    "avgs" : 9055.33333333333,
    "max" : 9664.0,
    "min" : 8591.0
}

上面这个例子中,根据部门统计了薪水的信息,此时我们由于使用了dep.id进行统计,所以仅仅显示了dep的id,但是我们已经冗余了dep.name,所以可以通过$first 或者$last 函数,由于dep的name是唯一的,所以使用first或者last都一样

db.user.aggregate([
    {$group:{_id:"$dep.id",
        depname:{$first:"$dep.name"},
        salays:{$sum:"$salary"},
        avgs:{$avg:"$salary"},
        max:{$max:"$salary"},
        min:{$min:"$salary"}}}
])
{
    "_id" : ObjectId("5a2947c367732445e8adfff0"),
    "depname" : "教务处",
    "salays" : 19394.0,
    "avgs" : 6464.66666666667,
    "max" : 8926.0,
    "min" : 4927.0
}

最后就是addToSet和push了,这个可以把用户的基本信息添加到一个数组中,使用addToSet添加的信息不会有重复,而使用push的信息会存在重复,看如下的例子

db.user.aggregate([
    {$group:{_id:"$dep.name",
        count:{$sum:1},
        usernames:{$push:'$name'}, #把用户名添加进行
        users:{$addToSet:{phone:'$phone',age:'$age'}}##使用重塑设置两个值
    }}
])

以上这个查询由于没有重复的用户,所以push和addToSet基本一致,但是addToSet中进行了重塑,看看下面的结果

{
    "_id" : "教务处",
    "count" : 3.0,
    "usernames" : [ 
        "b1", 
        "b2", 
        "b3"
    ],
    "users" : [ 
        {
            "phone" : "119",
            "age" : 26
        }, 
        {
            "phone" : "119",
            "age" : 24
        }, 
        {
            "phone" : "119",
            "age" : 23
        }
    ]
}

有了两个数组,一个是usernames,所有用户的名称,users里面是一个对象数组集合。这个数据是非常有必要的,因为可以通过这个信息再次进行二次查询,到此为止group的知识就算讲完了,group是聚合中最为重要的一部分内容,务必掌握。

unwind和out管道

第一个查询需求是获取用户对私人信息的统计,统计每个用户该查询的私人信息数量,看过的数量和没有看过的数量,这使用project就可以解决,先看看结果

db.user.aggregate({$project:
    {
        username:1,
        msgs:{$size:'$msgs.all'},
        noVisited:{$size:'$msgs.noVisited'},
        visited:{$size:'$msgs.visited'}
     }})

但是执行之后会发现报错了,错误提示The argument to $size must be an array, but was of type: missing 提示$size 应该是针对所有的数组,我们的noVisited或者visited可能会存在不存在的情况,这个时候需要通过$ifNull来解决

db.user.aggregate({$project:
   {
       username:1,
       msgs:{$size:'$msgs.all'},
       noVisited:{$size:{$ifNull:['$msgs.noVisited',[]]}},
       visited:{$size:{$ifNull:['$msgs.visited',[]]}}
    }})

注意$ifNull,有两个值,使用的[]而不是{},第一个是条件,第二个是如果不存在的替换值。

看看结果

/* 1 */
{
    "_id" : ObjectId("5a29467b67732445e8adffe5"),
    "username" : "foo1",
    "msgs" : 2,
    "noVisited" : 1,
    "visited" : 1
}

/* 2 */
{
    "_id" : ObjectId("5a2946a567732445e8adffe6"),
    "username" : "bar1",
    "msgs" : 2,
    "noVisited" : 2,
    "visited" : 0
}

下面我们要更进一步,我们希望统计每条私人信息中没有访问的数量,由于访问的信息是存储在user中,并且是以数组的方式来存储,这样很难进行统计,但是MongoDB提供了unwind来解决此类数组查询的问题,使用unwind会把数组中的每个文档都提出来作为一个单独的文档,注意unwind一般和project配合使用,否则会比较占用内存。先看unwind的结果

db.user.aggregate([
    {$project:{name:1,"msgs.noVisited":1}},
    {$unwind:"$msgs.noVisited"}
])
{
    "_id" : ObjectId("5a29467b67732445e8adffe5"),
    "name" : "foo",
    "msgs" : {
        "noVisited" : ObjectId("5a294ee567732445e8adfff6")
    }
}
{
    "_id" : ObjectId("5a2946a567732445e8adffe6"),
    "name" : "bar",
    "msgs" : {
        "noVisited" : ObjectId("5a294ee567732445e8adfff3")
    }
}

大家发现没有,通过unwind,数组中的每一个元素都变成了一个独立的文档,之后通过_id进行一个group即可获取数量

db.user.aggregate([
    {$project:{name:1,"msgs.noVisited":1}},
    {$unwind:"$msgs.noVisited"},
    {$group:{_id:"$msgs.noVisited",count:{$sum:1}}}
])
{
    "_id" : ObjectId("5a29470667732445e8adffef"),
    "count" : 4.0
}
{
    "_id" : ObjectId("5a2946fc67732445e8adffed"),
    "count" : 4.0
}

此时如果希望把标题查询出来,对于MongoDB而言,由于没有join,所以需要对这个数组进行二次查询,使用out可以将查询出来的结果添加到一个文档中

db.user.aggregate([
    {$project:{name:1,"msgs.noVisited":1}},
    {$unwind:"$msgs.noVisited"},
    {$group:{_id:"$msgs.noVisited",count:{$sum:1}}},
    {$out:"msgsNoVisited"}
])

此时可以通过msgsNoVisited来获取message的title,由于MongoDB没有join,所以第一种方法使用游标,利用forEach处理

db.noVisitedMsgs.remove({})
db.msgsNoVisited.find().forEach(function(e) {
    var m = db.message.findOne({_id:e._id})
    if(m!=null) {
        e.title = m.title
    } else {
        e.title = "not found"
    }
    db.noVisitedMsgs.insertOne(e)
})
db.noVisitedMsgs.find()

通过以上shell将查询的结果存储到一个noVisitedMsgs的集合中,第一条remove是先把这个临时数据删除,看一下结果

{
    "_id" : ObjectId("5a294ee567732445e8adfff5"),
    "count" : 9.0,
    "title" : "msg3"
}
{
    "_id" : ObjectId("5a294ee567732445e8adfff4"),
    "count" : 10.0,
    "title" : "msg2"
}

这种方案是基于伪查询的方式,由于每个游标都是通过findOne来进行二次查询的,所以如果数据量非常大的话,效率会很低,在MongoDB3.2版本之后提供了新的函数lookup来完成两个集合的连接

db.message.aggregate([
    {$lookup:{
         from:"msgsNoVisited",
         localField:"_id",
         foreignField:"_id",
         as:"noVisited"
    }}
])

此时会内嵌一个文档noVisited到message中,如下所示

{
    "_id" : ObjectId("5a294ee567732445e8adfff3"),
    "title" : "msg1",
    "content" : "msg ...",
    "createDate" : ISODate("2017-12-15T05:22:20.103Z"),
    "user" : {
        "id" : ObjectId("5a29467b67732445e8adffe5"),
        "name" : "foo"
    },
    "attach" : {
        "name" : "world.txt",
        "type" : "txt",
        "createDate" : "Thu Dec 07 2017 23:34:54 GMT+0800",
        "size" : 23
    },
    "noVisited" : [ 
        {
            "_id" : ObjectId("5a294ee567732445e8adfff3"),
            "count" : 10.0
        }
    ]
}

如果感觉里面的数据太多了,可以首先通过project来进行投影,最后把生成的信息拷贝到一个文档中

db.message.aggregate([
    {$project:{
        title:1
     }},
    {$lookup:{
         from:"msgsNoVisited",
         localField:"_id",
         foreignField:"_id",
         as:"noVisited"
    }},
    {$out:"msgsNoVisited"}
])
db.msgsNoVisited.find()
---------------------------------------
{
    "_id" : ObjectId("5a294ee567732445e8adfff3"),
    "title" : "msg1",
    "noVisited" : [ 
        {
            "_id" : ObjectId("5a294ee567732445e8adfff3"),
            "count" : 10.0
        }
    ]
}

以上就是unwind和out的各种用法,希望对大家有所帮助

sort、skip和limit管道

这三个管道,sort用来排序,skip和limit用来进行分页,这个操作和查询操作非常类似,其实是互通的,看一个实例即可学会。

var pageNum = 1
var pageSize = 10
db.user.aggregate([
    {$project:{name:1,age:1}},
    {$skip:(pageNum-1)*pageSize},
    {$limit:pageSize},
    {$sort:{age:-1}}
])

以上实例通过分页的方式来查询,并且以age倒序排序。

重塑文档

在第一小部分已经看了如何重塑文档,MongoDB还提供了很多好用的函数来重塑文档,重塑文档都是基于project的,这种函数分成很多类,这里只能简单演示一些,首先字符串相关的函数有

字符串相关函数
$concat   连接两个字符串
$strcasecmp 区分大小写比较数字
$substr 取子串
$toLower 转化为小写
$toUpper 转换为大写

看看如下实例,获取用户姓名的信息

db.user.aggregate([
    {$project:{
        fullname:{$concat:['$username','-','$name']},
        fname:{$substr:['$username',0,1]}
     }}
])

{
    "_id" : ObjectId("5a29467b67732445e8adffe5"),
    "fullname" : "foo1-foo",
    "fname" : "f"
}

接下来看看算术运算函数和日期函数

算术运算函数
    $add   求和
    $divide 除法
    $mod 求余数
    $multiply 乘法
    $subtract 减法
日期函数
    $year 取年份
    $month 取月份
    $week 取一年中的某一周(0-53)
    $hour 取小时
    $minute 取分钟
    $second 取秒钟
    $millisecond 取毫秒
    $dayOfYear 一年中的某一天
    $dayOfMonth 一月中的某一天
    $dayOfWeek 一周中的某一天,1表示周日

算数运算符比较简单,我们使用以下日期函数

db.message.aggregate([
    {$match:{createDate:{$gte:new Date("2017-01-01"),$lte:new Date("2017-12-31")}}},
    {$project:{title:1,year:{$year:"$createDate"},month:{$month:"$createDate"}}},
    {$group:{_id:{year:'$year',month:'$month'},count:{$sum:1}}}
])

以上操作完成了根据year和month进行分组查询,最后可以得到2017年每个月发送的message的数量,这是非常好用的一种聚合查询。

集合操作函数也非常实用

集合操作符
    $setEquals  查看两个集合时候相同,相同返回true
    $setInterseciont 返回两个集合的公共元素
    $setDifference 返回两个集合中的不同的元素
    $setUnion 合并集合
    $setIsSubset 判断第二个集合是否是第一个的子集,如果是返回true
    $anyElementTrue 如果某个集合元素为true,就为true
    $allElementTrue 集合中所有元素为true就为true

看一下下一个实例

db.user.aggregate([
    {$project:{nov:{$setDifference:['$msgs.all',{$ifNull:['$msgs.visited',[]]}]}}}
])

会得到没有参观过的message信息,下面看一下其他的重塑函数

逻辑函数
    $and true   与操作
    $cmp 比两个值是否相等
    $cond  if ... then ... else 条件逻辑,类似三元运算符
    $eq 等于
    $gt,$gte,$lt,$lte  大于和小于
    $ifNull 是否为空
    $ne 不等于
    $not. 取反
    $ir 或运算
其他函数
    $meta  文本搜索
    $size 取数组大小
    $map 对数组的每个成员应用表达式
    $let 定义表达式内的变量
    $literal 返回表达式的值

以上函数就不举例了,只是让大家对这个有所了解,以后具体使用到的时候再去查询

Map-Reduce

对于一些复杂的查询可以转换为javascript的处理方式,这就是map reduce,但是map reduce的效率非常低,所以能够使用聚合框架实现的就不要使用Map-Reduce,下面看看Map-Reduce如何实现一个应用。

首先要定义map函数,map指的是遍历文档中的所有键值对,来做处理,我们现在对user这个collection来做map

map = function() {
    var did = this.dep.id+"-"+this.dep.name;
    var ageGt = 0;
    if(this.age>40) ageGt=1;
    emit(did,{ageGt:ageGt});
}

以上操作定义了一个map,该操作会去遍历整个collection的文档,首先确定了key是部门id+部门名称,接着完成下面一种存储如果年龄大于40存储ageGt为1否则为0,emit表示返回值,此时的map文档中有一组数据,存储了部门id+部门名称为key并且存储了年龄的文档(年龄的文档中一些是0,一些是1),之后把这个值交给reduce来返回一个结果集

reduce=function(key,values) {
    var result = {nums:0};
    values.forEach(function(v){
        result.nums+=v.ageGt;
    })
    return result;
}

reduce中就会根据key的值把返回的数据定义成为一个数组,这个数组就是reduce的第二个参数values,此时定义了一个结果为nums,并且遍历整个数组,将nums的值增加,这意味着只要发现ageGt是1表示有一个40岁以上的人,这样累加之后,就可以根据情况统计出每个部门大于40岁的人员。

定义好map和reduce之后,下一步就需要通过collection来调用

db.user.mapReduce(map,reduce,{query:{},out:"test"})

此时使用user调用刚才定义的map和reduce,第三个参数中的query是查询条件,可以过滤一组数据,第二个参数out表示要输出的文档,输出之后我们可以通过test文档查询

/* 1 */
{
    "_id" : "5a2947c367732445e8adfff0-教务处",
    "value" : {
        "nums" : 0.0
    }
}

/* 2 */
{
    "_id" : "5a2947c367732445e8adfff1-财务处",
    "value" : {
        "nums" : 1.0
    }
}

/* 3 */
{
    "_id" : "5a2947c367732445e8adfff2-计算机学院",
    "value" : {
        "nums" : 0.0
    }
}

这个map-reduce完成了部门大于40岁人的统计操作,Map-Reduce基本可以实现你想要的所有功能,但是由于效率低下,依然建议大家如果能使用聚合框架就不要用MapReduce。

总结

这部分讲解了聚合框架,这是MongoDB的核心,它可以提供非常出色的查询操作,这是必须要熟悉的一块内容,当然聚合框架的效率问题将会在索引之后详细探讨。

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

推荐阅读更多精彩内容