【译】GraphQL Server基础(三):ResolveInfo

GraphQL服务器的结构和实现(第三部分)

原文地址

image.png

如果您以前编写过GraphQL服务器,很可能您已经遇到了传递给解析器的info对象。幸运的是,在大多数情况下,您实际上并不需要了解它在查询解析过程中的实际作用以及它的作用。

但是,有许多边缘情况,其中info对象是导致许多混淆和误解的原因。本文的目标是查看info对象的内容,并阐明它在GraphQL 执行过程中的作用。

本文假设您已经熟悉了如何解决GraphQL查询和突变的基础知识。如果您在这方面感到有点不稳定,那么您一定要查看本系列的前几篇文章:第一部分:GraphQL架构(中文)(必需)第二部分:网络层(英文)(可选)

info对象的结构

回顾:GraphQL解析器的签名

快速回顾一下,建立一个GraphQL服务器时GraphQL.js,你有两个主要任务:

  • 定义GraphQL架构(在SDL中或作为普通的JS对象)
  • 对于模式中的每个字段,实现一个知道如何返回该字段值的解析器函数

解析器函数需要四个参数(按此顺序):

  1. parent:上一个解析器调用的结果(更多信息)。
  2. args:解析器字段的参数。
  3. context:每个解析程序可以读取/写入的自定义对象。
  4. info这就是我们将在本文中讨论的内容。

info 包含查询AST和更多执行信息

关于info对象的结构和作用。官方规范文档都没有提到它。曾经有一个GitHub 问题需要更好的文档,但是没有明显的行动就关闭了。因此,除了深入研究代码之外别无他法。

在非常高的层次上,可以说info对象包含传入的是GraphQL查询的AST。由于这一点,解析器知道他们需要返回哪些字段。

要了解有关ASTs查询的更多信息,请查看Christian Joudrey的精彩文章LifeQL of GraphQL Query - Lexing / Parsing以及Eric Baer的精彩演讲GraphQL Under the Hood

要了解其结构info,我们来看看它的Flow类型定义

/* @flow */

export type GraphQLResolveInfo = {
  fieldName: string;
  fieldNodes: Array<FieldNode>;
  returnType: GraphQLOutputType;
  parentType: GraphQLCompositeType;
  path: ResponsePath;
  schema: GraphQLSchema;
  fragments: { [fragmentName: string]: FragmentDefinitionNode };
  rootValue: mixed;
  operation: OperationDefinitionNode;
  variableValues: { [variableName: string]: mixed };
};

以下是每个键的概述和快速说明:

  • fieldName:如前所述,GraphQL架构中的每个字段都需要由解析程序支持。该fieldName包含属于当前的解决该域的名称。
  • fieldNodes:一个数组,其中每个对象表示剩余选择集中的字段。
  • returnType:响应字段的GraphQL类型。
  • parentType:此字段所属的GraphQL类型。
  • path:跟踪遍历当前字段(即解析程序)的遍历字段。
  • schemaGraphQLSchema表示可执行schema的实例。
  • fragments:作为查询文档一部分的片段映射。
  • rootValuerootValue传递给执行的参数。
  • operation整个查询的AST 。
  • variableValues:与查询一起提供的任何变量的映射对应于variableValues参数。

不要担心,如果这仍然是抽象的,我们很快就会看到所有这些的例子。

具体的字段 vs Global

关于上面的键,有一个有趣的观察结果。info对象上的键是 具体的字段 或 Global。

具体的字段 意味着该键的值取决于info对象传递到的字段(及其后备解析程序)。例子如下(fieldNamerootTypeparentType):

type Query {
  author: User!
  feed: [Post!]!
}

authorfieldName就是author,而returnType就是 User!parentType就是 Query

而相对于feed这些价值当然会有所不同:fieldNamefeedreturnType[Post!]!parentType也是Query

因此,这三个键的值是取决于字段的。还有其他取决于字段的键是:fieldNodespath。实际上,上面Flow定义的前五个键均是取决于字段的。

另一方面,Global意味着这些键的值不会改变 - 无论我们谈论哪个解析器。schemafragmentsrootValueoperation并且variableValues将始终贯彻相同的值于所有解析器中。

一个简单的例子

现在让我们继续看一下info对象内容的示例。要设置阶段,这里是我们将用于此示例的schema definition
(模式定义)

type Query {
  author(id: ID!): User!
  feed: [Post!]!
}

type User {
  id: ID!
  username: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  author: User!
}

假设该schema的解析器实现如下:

const resolvers = {
  Query: {
    author: (root, { id }, context, info) => {
      console.log(`Query.author - info: `, JSON.stringify(info))
      return users.find(u => u.id === id)
    },
    feed: (root, args, context, info) => {
      console.log(`Query.feed - info: `, JSON.stringify(info))
      return posts
    }
  },
  Post: {
    title: (root, args, context, info) => {
      console.log(`Post.title - info: `, JSON.stringify(info))
      return root.title
    },
  },
}

请注意,Post.title实际上并不需要解析器,我们仍然在此处包含它以查看info调用解析器时对象的结构。

现在考虑以下查询:

query AuthorWithPosts {
  author(id: "user-1") {
    username
    posts {
      id
      title
    }
  }
}

出于简洁的目的,我们将仅讨论该Query.author字段的解析器,而不是用于Post.title(在执行上述查询时仍然调用)的解析器。

如果您想要使用此示例,我们准备了一个存储库,其中包含上述架构的运行版本,因此您可以尝试一些内容!

接下来,让我们看看info对象内部的每个键,看看Query.author调用解析器时它们的样子(你可以在这里找到info对象的整个日志输出)。

fieldName 属性

fieldName其实就是是author

fieldNodes 属性

请记住,这fieldNodes是取决于字段的。它实际上包含查询AST 的摘录。此摘录从当前字段(即author)开始,而不是从查询的root开始。(从root开始的整个查询AST存储在operation,见下文)。

{
    "fieldNodes": [
    {
      "kind": "Field",
      "name": {
        "kind": "Name",
        "value": "author",
        "loc": { "start": 27, "end": 33 }
      },
      "arguments": [
        {
          "kind": "Argument",
          "name": {
            "kind": "Name",
            "value": "id",
            "loc": { "start": 34, "end": 36 }
          },
          "value": {
            "kind": "StringValue",
            "value": "user-1",
            "block": false,
            "loc": { "start": 38, "end": 46 }
          },
          "loc": { "start": 34, "end": 46 }
        }
      ],
      "directives": [],
      "selectionSet": {
        "kind": "SelectionSet",
        "selections": [
          {
            "kind": "Field",
            "name": {
              "kind": "Name",
              "value": "username",
              "loc": { "start": 54, "end": 62 }
            },
            "arguments": [],
            "directives": [],
            "loc": { "start": 54, "end": 62 }
          },
          {
            "kind": "Field",
            "name": {
              "kind": "Name",
              "value": "posts",
              "loc": { "start": 67, "end": 72 }
            },
            "arguments": [],
            "directives": [],
            "selectionSet": {
              "kind": "SelectionSet",
              "selections": [
                {
                  "kind": "Field",
                  "name": {
                    "kind": "Name",
                    "value": "id",
                    "loc": { "start": 81, "end": 83 }
                  },
                  "arguments": [],
                  "directives": [],
                  "loc": { "start": 81, "end": 83 }
                },
                {
                  "kind": "Field",
                  "name": {
                    "kind": "Name",
                    "value": "title",
                    "loc": { "start": 90, "end": 95 }
                  },
                  "arguments": [],
                  "directives": [],
                  "loc": { "start": 90, "end": 95 }
                }
              ],
              "loc": { "start": 73, "end": 101 }
            },
            "loc": { "start": 67, "end": 101 }
          }
        ],
        "loc": { "start": 48, "end": 105 }
      },
      "loc": { "start": 27, "end": 105 }
    }
  ]
}

returnType 属性 & parentType 属性

如前所述,returnTypeparentType则相当简单:

{
  "returnType": "User!",
  "parentType": "Query",
}

path 属性

path即包含已经走过,直到当前的一个域的路径图。如Query.author,它看起来像"path": { "key": "author" }

{
  "path": { "key": "author" }
}

为了比较,在Post.title解析器中,path结构如下:

{
  "path": {
    "prev": {
      "prev": { "prev": { "key": "author" }, "key": "posts" },
      "key": 0
    },
    "key": "title"
  },
}

其余五个字段属于“global”类别,因此对于Post.title解析器而言将是相同的。

schema 属性(可以理解为一种架构、一种规范、一种结构、一个表)

schema是对可执行模式的引用。

fragments 属性(查询语句的片段)

fragments包含片段定义,因为查询文档没有任何这些,它只是一个空映射:{}

rootValue 属性 (可以自定义一个属性值作为首次解析的参数)

如前所述,rootValue键的值对应于首先rootValue传递给graphql执行函数的参数。在示例的情况下,它只是null

operation 属性

operation包含传入查询的完整查询AST。回想一下,在其他信息中,它包含我们在fieldNodes上面看到的相同值:

{
  "operation": {
    "kind": "OperationDefinition",
    "operation": "query",
    "name": {
      "kind": "Name",
      "value": "AuthorWithPosts"
    },
    "selectionSet": {
      "kind": "SelectionSet",
      "selections": [
        {
          "kind": "Field",
          "name": {
            "kind": "Name",
            "value": "author"
          },
          "arguments": [
            {
              "kind": "Argument",
              "name": {
                "kind": "Name",
                "value": "id"
              },
              "value": {
                "kind": "StringValue",
                "value": "user-1"
              }
            }
          ],
          "selectionSet": {
            "kind": "SelectionSet",
            "selections": [
              {
                "kind": "Field",
                "name": {
                  "kind": "Name",
                  "value": "username"
                }
              },
              {
                "kind": "Field",
                "name": {
                  "kind": "Name",
                  "value": "posts"
                },
                "selectionSet": {
                  "kind": "SelectionSet",
                  "selections": [
                    {
                      "kind": "Field",
                      "name": {
                        "kind": "Name",
                        "value": "id"
                      }
                    },
                    {
                      "kind": "Field",
                      "name": {
                        "kind": "Name",
                        "value": "title"
                      }
                    }
                  ]
                }
              }
            ]
          }
        }
      ]
    }
  }
}

variableValues 属性(查询语句附带的变量组)

此键表示已为查询传递的所有变量。由于我们的示例中没有变量,因此该值的值只是一个空映射:{}

如果查询是用变量编写的:

query AuthorWithPosts($userId: ID!) {
  author(id:$userId) {
    username
    posts {
      id
      title
    }
  }
}

variableValues键会有以下值:

{
  "variableValues": { "userId": "user-1" }
}

info使用GraphQL绑定时的作用

正如本文开头所提到的,在大多数情况下,您根本不需要关心info对象。它只是你的解析器签名的一部分,但你实际上并没有将它用于干任何事情。那么,什么时候会变得相关?

传递info给绑定函数

如果您之前使用过GraphQL bindings,那么您已将该info对象视为生成的绑定函数的一部分。请考虑以下架构:

type Query {
  users(): [User]!
  user(id: ID!): User
}

type Mutation {
  createUser(username: String!): User!
  deleteUser(id: ID!!): User
}

type User {
  id: ID!
  username: String!
}

使用graphql-binding,您现在可以通过调用专用绑定函数而不是发送原始 queriesmutations 来发送可用的查询和突变。

例如,考虑以下原始查询,检索特定的User

query {
  user(id: "user-100") {
    id
    username
  }
}

使用绑定功能实现相同的功能如下(这可能是Prisma.js里的内容):

binding.query.user({ id: 'user-100' }, null, '{ id username }')

通过在user绑定实例上调用函数并传递相应的参数,我们传达的信息与上面的原始GraphQL查询完全相同。

绑定函数graphql-binding有三个参数:

  1. args:包含字段的参数(例如,上面usernamecreateUser变异)。
  2. contextcontext传递给解析器链的对象。
  3. infoinfo对象。请注意,GraphQL ResolveInfo您还可以传递一个简单定义选择集的字符串,而不是(Info类型)的实例。

使用Prisma(Prisma.js一个graphql框架)将应用程序Schema映射到数据库Schema

注:以下内容可能比较抽象或者你可以认为没多大用这里我简单表达下我对下面内容的理解(我们想要知道客户端需要得到的字段的最佳方案就是通过解析info对象里的fileNodes属性得到)

info对象可能引起混淆的另一个常见用例是基于Prismaprisma绑定的GraphQL服务器的实现。

在这种情况下,我们的想法是有两个GraphQL层:

  • Database层 是由Prisma自动生成,并提供了一个通用和强大CRUD API
  • Application层 定义了暴露给客户端应用程序并根据您的应用程序需求量身定制的GraphQL API

作为后端开发人员,您负责定义应用 程序层的应用程序架构并实现其解析器。由于prisma-binding,解析器的实现仅仅是将传入的查询委托 给底层数据库API而没有大的开销的过程。

让我们考虑一个简单的例子 - 假设你开始使用以下Prisma数据库服务的数据模型:

type Post {
  id: ID! @unique
  title: String!
  author: User!
}

type User {
  id: ID! @uniqe
  name: String!
  posts: [Post!]!
}

Prisma基于此数据模型生成的数据库模式类似于:

type Query {
  posts(where: PostWhereInput, orderBy: PostOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Post]!
  postsConnection(where: PostWhereInput, orderBy: PostOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): PostConnection!
  post(where: PostWhereUniqueInput!): Post
  users(where: UserWhereInput, orderBy: UserOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [User]!
  usersConnection(where: UserWhereInput, orderBy: UserOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): UserConnection!
  user(where: UserWhereUniqueInput!): User
}

type Mutation {
  createPost(data: PostCreateInput!): Post!
  updatePost(data: PostUpdateInput!, where: PostWhereUniqueInput!): Post
  deletePost(where: PostWhereUniqueInput!): Post
  createUser(data: UserCreateInput!): User!
  updateUser(data: UserUpdateInput!, where: UserWhereUniqueInput!): User
  deleteUser(where: UserWhereUniqueInput!): User
}

现在,假设您要构建一个类似于此的应用程序模式:

type Query {
  feed(authorId: ID): Feed!
}

type Feed {
  posts: [Post!]!
  count: Int!
}

feed查询不仅返回一个列表Post元素,而且能够返回count列表。请注意,它可以选择authorId过滤Feed,以仅返回Post由特定内容写入的元素User

实现此应用程序模式的第一个直觉可能如下所示。

实施1:这种实现看起来正确但有一个微妙的缺陷:

const resolvers = {
  Query: {
    async feed(parent, { authorId }, ctx, info) {
      // build filter
      const authorFilter = authorId ? { author: { id: authorId } } : {}

      // retrieve (potentially filtered) posts
      const posts = await ctx.db.query.posts({ where: authorFilter })

      // retrieve (potentially filtered) element count
      const postsConnection = await ctx.db.query.postsConnection(
        { where: authorFilter },
        `{ aggregate { count } }`,
      )
      return {
        count: postsConnection.aggregate.count,
        posts: posts,
      }
    },
  },
}

这种实现似乎足够合理。在feed解析器内部,我们正在构建authorFilter基于潜在的传入authorId。该authorFilter则用来执行posts查询和检索Post元素,还有postsConnection它可以访问查询count列表。

也可以仅使用postsConnection查询来检索实际的Post元素。为了简单起见,我们仍然使用Post查询,并将另一种方法作为练习给细心的读者。

实际上,在使用此实现启动GraphQL服务器时,事情看起来似乎很好。您会注意到正确提供了简单查询,例如以下查询将成功:

query {
  feed(authorId: "cjdbbsepg0wp70144svbwqmtt") {
    count
    posts {
      id
      title
    }
  }
}

它不是直到你想获取author的的Post,当你运行到一个问题内容:

query {
  feed(authorId: "cjdbbsepg0wp70144svbwqmtt") {
    count
    posts {
      id
      title
      author {
        id 
        name
      }
    }
  }
}

行!因此,由于某种原因,实现不会返回,author并且会触发错误“无法为不可为空的Post.author返回null”。因为该Post.author字段在应用程序架构中标记为必需。

让我们再看看实现的相关部分:

// retrieve (potentially filtered) posts
const posts = await ctx.db.query.posts({ where: authorFilter })

这是我们检索Post元素的地方。但是,我们没有将选择集传递给Post绑定功能。如果没有第二个参数传递给Prisma绑定函数,则默认行为是查询该类型的所有标量字段。

这确实解释了这种行为。调用ctx.db.query.posts返回正确的Post元素集,但只返回它们idtitle值 - 没有关于author的关系数据。

那么,我们该如何解决这个问题呢?显然需要一种方法来告诉posts绑定函数它需要返回哪些字段。但是这些信息在feed解析器的上下文中存在于何处?你能猜到吗?

没错:info对象内!因为对于一个Prisma的绑定功能的第二个参数可以是一个字符串一个info对象,我们只是通过info它获取传递到目标feed分解到posts绑定功能。

此查询失败,执行2:sub selection类型的Post 字段必须有子集。

const resolvers = {
  Query: {
    async feed(parent, { authorId }, ctx, info) {
      // build filter
      const authorFilter = authorId ? { author: { id: authorId } } : {}

      // retrieve (potentially filtered) posts
      const posts = await ctx.db.query.posts({ where: authorFilter }, info) // pass `info`

      // retrieve (potentially filtered) element count
      const postsConnection = await ctx.db.query.postsConnection(
        { where: authorFilter },
        `{ aggregate { count } }`,
      )
      return {
        count: postsConnection.aggregate.count,
        posts: posts,
      }
    },
  },
}

然而,这并不能完全正确实现。例如,请考虑以下查询:

query {
  feed {
    count
    posts {
      title
    }
  }
}

sub selection类型的错误消息Post 字段必须有子选择。” 由上述实现的第8行 产生。

那么,这里发生了什么?之所以失败是因为对象中的特定字段infoposts查询不匹配。

我们在feed解析器内打印info对象可以更好地了解情况。我们只考虑以下领域的具体信息fieldNodes

{
  "fieldNodes": [
    {
      "kind": "Field",
      "name": {
        "kind": "Name",
        "value": "feed"
      },
      "arguments": [],
      "directives": [],
      "selectionSet": {
        "kind": "SelectionSet",
        "selections": [
          {
            "kind": "Field",
            "name": {
              "kind": "Name",
              "value": "count"
            },
            "arguments": [],
            "directives": []
          },
          {
            "kind": "Field",
            "name": {
              "kind": "Name",
              "value": "posts"
            },
            "arguments": [],
            "directives": [],
            "selectionSet": {
              "kind": "SelectionSet",
              "selections": [
                {
                  "kind": "Field",
                  "name": {
                    "kind": "Name",
                    "value": "title"
                  },
                  "arguments": [],
                  "directives": []
                }
              ]
            }
          }
        ]
      }
    }
  ]
}

此JSON对象也可以表示为字符串选择集:

{
  feed {
    count
    posts {
      title
    }
  }
}

现在一切都有道理!我们将上述选择集发送到postsPrisma数据库模式的查询,当然这些模式不知道feedcount字段。不可否认,所产生的错误信息并非超级有用,但至少我们了解现在正在发生的事情。

那么,这个问题的解决方案是什么?解决此问题的一种方法是手动解析选择集的正确部分fieldNodes并将其传递给posts绑定函数(例如,作为字符串)。

但是,对于这个问题我们有一个更优雅的解决方案,那就是为应用程序模式中的feed类型实现专用的解析器。下面是正确实现的例子

实施3:该实现解决了上述问题

const resolvers = {
  Query: {
    async feed(parent, { authorId }, ctx, info) {
      // build filter
      const authorFilter = authorId ? { author: { id: authorId } } : {}

      // retrieve (potentially filtered) posts
      const posts = await ctx.db.query.posts({ where: authorFilter }, `{ id }`) // second argument can also be omitted

      // retrieve (potentially filtered) element count
      const postsConnection = await ctx.db.query.postsConnection(
        { where: authorFilter },
        `{ aggregate { count } }`,
      )
      return {
        count: postsConnection.aggregate.count,
        postIds: posts.map(post => post.id), // only pass the `postIds` down to the `Feed.posts` resolver
      }
    },
  },
  Feed: {
    posts({ postIds }, args, ctx, info) {
      const postIdsFilter = { id_in: postIds }
      return ctx.db.query.posts({ where: postIdsFilter }, info)
    },
  },
}

此实现修复了上面讨论的所有问题。有几点需要注意:

  • 第8行中,我们现在传递一个字符串选择set({ id })作为第二个参数。这只是为了提高效率,否则所有的标量值都会被提取(这在我们的例子中不会产生很大的不同),我们只需要ID。
  • 我们返回的只是一个ID数组(表示为字符串),而不是postsQuery.feed解析器返回postIds
  • Feed.posts解析器中,我们现在可以访问解析器postIds返回的内容。这次,我们可以使用传入的对象,并将其简单地传递给绑定函数。info posts

如果您想要使用此示例,可以查看存储库,其中包含上述示例的运行版本。请随意尝试本文中提到的不同实现,并亲自观察行为!

摘要

在本文中,您深入了解了info在实现基于GraphQL.js的GraphQL API时使用的对象。

info对象未正式记录 - 要了解有关它的更多信息,您需要深入研究代码。在本教程中,我们首先概述其内部结构并了解其在GraphQL解析器函数中的作用。然后,我们介绍了一些边缘情况和潜在的陷阱,需要更深入的了解info

本文中显示的所有代码都可以在相应的GitHub存储库中找到,这样您就可以自己试验和观察info对象的行为。

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