GraphQL服务器的结构和实现(第三部分)
如果您以前编写过GraphQL服务器,很可能您已经遇到了传递给解析器的info对象。幸运的是,在大多数情况下,您实际上并不需要了解它在查询解析过程中的实际作用以及它的作用。
但是,有许多边缘情况,其中info对象是导致许多混淆和误解的原因。本文的目标是查看info对象的内容,并阐明它在GraphQL 执行过程中的作用。
本文假设您已经熟悉了如何解决GraphQL查询和突变的基础知识。如果您在这方面感到有点不稳定,那么您一定要查看本系列的前几篇文章:第一部分:GraphQL架构(中文)(必需)第二部分:网络层(英文)(可选)
info
对象的结构
回顾:GraphQL解析器的签名
快速回顾一下,建立一个GraphQL服务器时GraphQL.js,你有两个主要任务:
- 定义GraphQL架构(在SDL中或作为普通的JS对象)
- 对于模式中的每个字段,实现一个知道如何返回该字段值的解析器函数
解析器函数需要四个参数(按此顺序):
-
parent
:上一个解析器调用的结果(更多信息)。 -
args
:解析器字段的参数。 -
context
:每个解析程序可以读取/写入的自定义对象。 -
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
:跟踪遍历当前字段(即解析程序)的遍历字段。 -
schema
:GraphQLSchema
表示可执行schema
的实例。 -
fragments
:作为查询文档一部分的片段映射。 -
rootValue
:rootValue
传递给执行的参数。 -
operation
:整个查询的AST 。 -
variableValues
:与查询一起提供的任何变量的映射对应于variableValues参数。
不要担心,如果这仍然是抽象的,我们很快就会看到所有这些的例子。
具体的字段 vs Global
关于上面的键,有一个有趣的观察结果。info
对象上的键是 具体的字段
或 Global。
具体的字段 意味着该键的值取决于info
对象传递到的字段(及其后备解析程序)。例子如下(fieldName
,rootType
和parentType
):
type Query {
author: User!
feed: [Post!]!
}
在author
中 fieldName
就是author
,而returnType
就是 User!
和parentType
就是 Query
。
而相对于feed
这些价值当然会有所不同:fieldName
是feed
,returnType
是[Post!]!
,parentType
也是Query
。
因此,这三个键的值是取决于字段的。还有其他取决于字段的键是:fieldNodes
和path
。实际上,上面Flow
定义的前五个键均是取决于字段的。
另一方面,Global
意味着这些键的值不会改变 - 无论我们谈论哪个解析器。schema
,fragments
,rootValue
,operation
并且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
属性
如前所述,returnType
与 parentType
则相当简单:
{
"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
,您现在可以通过调用专用绑定函数而不是发送原始 queries
和 mutations
来发送可用的查询和突变。
例如,考虑以下原始查询,检索特定的User
:
query {
user(id: "user-100") {
id
username
}
}
使用绑定功能实现相同的功能如下(这可能是Prisma.js
里的内容):
binding.query.user({ id: 'user-100' }, null, '{ id username }')
通过在user
绑定实例上调用函数并传递相应的参数,我们传达的信息与上面的原始GraphQL查询完全相同。
绑定函数graphql-binding
有三个参数:
-
args
:包含字段的参数(例如,上面username
的createUser
变异)。 -
context
:context
传递给解析器链的对象。 -
info
:info
对象。请注意,GraphQL ResolveInfo
您还可以传递一个简单定义选择集的字符串,而不是(Info
类型)的实例。
使用Prisma(Prisma.js
一个graphql
框架)将应用程序Schema
映射到数据库Schema
注:以下内容可能比较抽象或者你可以认为没多大用这里我简单表达下我对下面内容的理解(我们想要知道客户端需要得到的字段的最佳方案就是通过解析
info
对象里的fileNodes
属性得到)
info
对象可能引起混淆的另一个常见用例是基于Prisma和prisma绑定的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
元素集,但只返回它们id
和title
值 - 没有关于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行 产生。
那么,这里发生了什么?之所以失败是因为对象中的特定字段键info
与posts
查询不匹配。
我们在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
}
}
}
现在一切都有道理!我们将上述选择集发送到posts
Prisma数据库模式的查询,当然这些模式不知道feed
和count
字段。不可否认,所产生的错误信息并非超级有用,但至少我们了解现在正在发生的事情。
那么,这个问题的解决方案是什么?解决此问题的一种方法是手动解析选择集的正确部分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数组(表示为字符串),而不是
posts
从Query.feed
解析器返回postIds
。 - 在
Feed.posts
解析器中,我们现在可以访问父解析器postIds
返回的内容。这次,我们可以使用传入的对象,并将其简单地传递给绑定函数。info posts
如果您想要使用此示例,可以查看此存储库,其中包含上述示例的运行版本。请随意尝试本文中提到的不同实现,并亲自观察行为!
摘要
在本文中,您深入了解了info
在实现基于GraphQL.js的GraphQL API时使用的对象。
该info
对象未正式记录 - 要了解有关它的更多信息,您需要深入研究代码。在本教程中,我们首先概述其内部结构并了解其在GraphQL解析器函数中的作用。然后,我们介绍了一些边缘情况和潜在的陷阱,需要更深入的了解info
。
本文中显示的所有代码都可以在相应的GitHub存储库中找到,这样您就可以自己试验和观察info对象的行为。