如果你以前编写过 GraphQL 服务器,那么你很可能已经遇到过传递给解析器的 info 对象。幸运的是,在大多数情况下,你并不需要真正理解它到底做了什么以及它在查询解析过程中的作用。
然而,在一些特殊情况下,info 对象会引起很多困惑和误解。本文的目标是深入了解 info 对象的内部结构,并阐明它在 GraphQL 执行过程中的作用。
本文假设你已经熟悉 GraphQL 查询和变更如何解析的基本知识。如果你对此有些不确定,你一定要查看本系列的前几篇文章:第一部分:GraphQL Schema(必需)第二部分:网络层(可选)
`info` 对象的结构
回顾:GraphQL 解析器的签名
快速回顾一下,在使用 GraphQL.js 构建 GraphQL 服务器时,你有两项主要任务:
- 定义你的 GraphQL schema(以 SDL 或纯 JS 对象的形式)
- 为 schema 中的每个字段,实现一个知道如何返回该字段值的*解析器*函数
解析器函数按顺序接受四个参数:
parent:上一次解析器调用的结果(更多信息)。args:解析器字段的参数。context:每个解析器都可以读取/写入的自定义对象。info:这正是我们将在本文中讨论的内容。
以下是简单 GraphQL 查询的执行过程以及所属解析器调用的概述。由于*第二层解析器*的解析是微不足道的,因此无需实际实现这些解析器——它们的返回值由 GraphQL.js 自动推断。
GraphQL 解析器链中 `parent` 和 `args` 参数的概述
`info` 包含查询 AST 和更多执行信息
那些对 info 对象的结构和作用感到好奇的人仍然一无所知。官方的 规范和 文档都没有提及它。曾有一个 GitHub 问题要求更好地记录它,但该问题在没有 notable action 的情况下被关闭。所以,除了深入代码之外别无他法。
从非常高的层面来看,可以说 info 对象包含传入 GraphQL 查询的 AST。因此,解析器知道它们需要返回哪些字段。
要了解更多关于查询 AST 的外观,请务必查看 Christian Joudrey 的精彩文章 GraphQL 查询的生命周期——词法分析/解析 以及 Eric Baer 的精彩演讲 GraphQL 内幕。
要理解 info 的结构,让我们看看 它的 Flow 类型定义
以下是这些键的概述和快速解释
fieldName:如前所述,你的 GraphQL schema 中的每个字段都需要由一个解析器支持。fieldName包含属于当前解析器的字段名称。fieldNodes:一个数组,其中每个对象代表剩余*选择集*中的一个字段。returnType:相应字段的 GraphQL 类型。parentType:此字段所属的 GraphQL 类型。path:跟踪遍历过的字段,直到当前字段(即解析器)被访问。schema:表示你的*可执行* schema 的GraphQLSchema实例。fragments:查询文档中包含的 片段的映射。rootValue:传递给执行的rootValue参数。operation:*整个*查询的 AST。variableValues:随查询一起提供的任何变量的映射,对应于 variableValues 参数。
如果这仍然显得抽象,请不要担心,我们很快就会看到所有这些的示例。
字段特定与全局
关于上面的键,有一个有趣的观察。`info` 对象上的键要么是*字段特定的*,要么是*全局的*。
字段特定仅仅意味着该键的值取决于将 info 对象传递到的字段(及其支持的解析器)。显而易见的例子是 fieldName、rootType 和 parentType。考虑以下 GraphQL 类型中的 author 字段
该字段的 fieldName 就是 author,returnType 是 User!,parentType 也是 Query。
现在,对于 feed,这些值当然会不同:fieldName 是 feed,returnType 是 [Post!]!,parentType 也是 Query。
因此,这三个键的值是字段特定的。更多的字段特定键是:fieldNodes 和 path。实际上,Flow 定义中的前五个键都是字段特定的。
另一方面,*全局*意味着这些键的值不会改变——无论我们讨论的是哪个解析器。schema、fragments、rootValue、operation 和 variableValues 将始终为所有解析器携带相同的值。
一个简单的例子
现在让我们看看 `info` 对象内容的示例。为了铺垫,这里是我们将用于此示例的schema 定义
假设该 schema 的解析器实现如下:
请注意,实际上并不需要
Post.title解析器,我们仍将其包含在此处,以查看当解析器被调用时info对象是什么样子。
现在考虑以下查询
为简洁起见,我们只讨论 Query.author 字段的解析器,而不讨论 Post.title 的解析器(在执行上述查询时仍会调用)。
如果你想尝试这个例子,我们准备了一个仓库,其中包含上述 schema 的运行版本,你可以进行实验!
接下来,让我们看看 info 对象中的每个键,看看当调用 Query.author 解析器时它们是什么样子(你可以在这里找到 info 对象的完整日志输出)。
fieldName
这个 fieldName 仅仅是 author。
fieldNodes
请记住,fieldNodes 是字段特定的。它实际上包含查询 AST 的一个*摘录*。这个摘录从当前字段(即 author)开始,而不是从查询的*根*开始。(完整的查询 AST 从根开始,存储在 operation 中,见下文)。
returnType & parentType
如前所示,returnType 和 parentType 都相当简单。
path
path 跟踪已遍历的字段,直到当前字段。对于 Query.author,它看起来很简单:"path": { "key": "author" }。
相比之下,在 Post.title 解析器中,path 如下所示:
剩下的五个字段属于“全局”类别,因此对于
Post.title解析器来说是相同的。
schema
schema 是对可执行 schema 的引用。
fragments
fragments 包含片段定义,由于查询文档中没有任何此类定义,因此它只是一个空映射:{}。
rootValue
如前所述,rootValue 键的值对应于最初传递给 graphql 执行函数的 rootValue 参数。在本例中,它只是 null。
operation
operation 包含传入查询的完整 查询 AST。回想一下,除其他信息外,这包含了我们上面在 fieldNodes 中看到的相同值
variableValues
此键表示为查询传递的任何变量。由于我们的示例中没有变量,因此此键的值再次只是一个空映射:{}。
如果查询是用变量编写的
此 variableValues 键将仅具有以下值
使用 GraphQL 绑定时 `info` 的作用
如本文开头所述,在大多数情况下,你根本不需要担心 info 对象。它只是你的解析器签名的一部分,但你实际上并没有用它做任何事情。那么,它什么时候变得相关呢?
将 `info` 传递给绑定函数
如果你以前使用过 GraphQL 绑定,你已经见过 info 对象作为生成的绑定函数的一部分。考虑以下 schema:
使用 graphql-binding,你现在可以通过调用专门的*绑定函数*来发送可用的查询和变更,而不是发送原始查询和变更。
例如,考虑以下原始查询,用于检索特定的 User:
使用绑定函数实现相同的功能如下:
通过调用绑定实例上的 user 函数并传递相应的参数,我们传达了与上面原始 GraphQL 查询完全相同的信息。
graphql-binding 中的绑定函数接受三个参数:
args:包含字段的参数(例如,上面createUser变更的username)。context:沿着解析器链传递的context对象。info:info对象。请注意,除了GraphQLResolveInfo实例(即 info 的类型)之外,你还可以传递一个简单定义选择集的字符串。
使用 Prisma 将应用程序 Schema 映射到数据库 Schema
info 对象可能导致混淆的另一个常见用例是基于 Prisma 和 prisma-binding 的 GraphQL 服务器实现。
在这种情况下,我们的想法是有两个 GraphQL 层:
- 这个 数据库层由 Prisma 自动生成,并提供通用且强大的 CRUD API
- 这个 *应用程序层*定义了暴露给客户端应用程序并根据你的应用程序需求定制的 GraphQL API
作为后端开发人员,你负责为应用程序层定义*应用程序 schema*并实现其解析器。得益于 prisma-binding,解析器的实现仅仅是将传入查询委托给底层数据库 API 的过程,没有太大的开销。
让我们考虑一个简单的例子——假设你从以下 Prisma 数据库服务的数据模型开始:
Prisma 根据此数据模型生成的数据库 schema 类似于这样:
现在,假设你想构建一个类似于这样的应用程序 schema:
feed 查询不仅返回 Post 元素的列表,而且还能返回列表的 count。请注意,它可选地接受一个 authorId,该 ID 将过滤 feed,使其仅返回由特定 User 编写的 Post 元素。
实现此应用程序 schema 的第一个直觉可能如下所示。
实现 1:这个实现看起来正确,但有一个细微的缺陷
这个实现看起来足够合理。在 feed 解析器中,我们根据可能传入的 authorId 构建 authorFilter。authorFilter 然后用于执行 posts 查询以检索 Post 元素,以及 postsConnection 查询,该查询可以访问列表的 count。
还可以仅使用 postsConnection 查询来检索实际的 Post 元素。为了简单起见,我们仍然使用 posts 查询来完成,并将另一种方法留给细心的读者作为练习。
事实上,当用这个实现启动你的 GraphQL 服务器时,乍一看会觉得一切正常。你会注意到简单的查询得到了正确的服务,例如以下查询将成功执行:
直到你尝试检索 Post 元素的 author 时,你才会遇到问题。
好的!那么,由于某种原因,实现没有返回 author,这触发了一个错误:“无法为非空 Post.author 返回 null。”因为 Post.author 字段在*应用程序 schema*中被标记为必需。
让我们再次看看实现的相关部分
我们在这里检索 Post 元素。但是,我们没有将*选择集*传递给 posts 绑定函数。如果未向 Prisma 绑定函数传递第二个参数,则默认行为是查询该类型的所有*标量*字段。
这确实解释了这种行为。调用 ctx.db.query.posts 返回正确的 Post 元素集合,但只返回它们的 id 和 title 值——没有关于 author 的关系数据。
那么,我们该如何解决这个问题呢?显然,我们需要一种方法来告诉 posts 绑定函数它需要返回哪些字段。但是,在 feed 解析器的上下文中,这些信息在哪里呢?你能猜到吗?
正确:在 info 对象内部!因为 Prisma 绑定函数的第二个参数可以是字符串*或* info 对象,所以我们只需将传递到 feed 解析器中的 info 对象传递给 posts 绑定函数。
此查询在实现 2 中失败:“类型‘Post’的字段‘posts’必须有一个子选择。”
然而,使用此实现,任何请求都不会得到正确服务。例如,考虑以下查询
错误消息 *“类型‘Post’的字段‘posts’必须有一个子选择。”* 是由上述实现的*第 8 行*产生的。
那么,这里发生了什么?这之所以失败,是因为 info 对象中的*字段特定*键与 posts 查询不匹配。
在 feed 解析器中打印 info 对象可以更清楚地说明情况。我们只考虑 fieldNodes 中的字段特定信息:
此 JSON 对象也可以表示为字符串选择集
现在一切都说得通了!我们将上述选择集发送到 Prisma 数据库 schema 的 posts 查询,该查询当然不了解 feed 和 count 字段。诚然,产生的错误消息不是很有帮助,但至少我们现在明白了发生了什么。
那么,这个问题的解决方案是什么?解决这个问题的一种方法是*手动*解析出 fieldNodes 选择集的正确部分,并将其传递给 posts 绑定函数(例如,作为字符串)。
然而,有一个更优雅的解决方案,那就是为应用程序 schema 中的 Feed 类型实现专门的解析器。以下是正确的实现方式。
实现 3:此实现修复了上述问题
此实现解决了上面讨论的所有问题。有几点需要注意:
- 在第 8 行,我们现在将一个字符串选择集(
{ id })作为第二个参数传递。这只是为了效率,因为否则将获取所有标量值(在我们的示例中不会有太大区别),而我们只需要 ID。 - 我们不再从
Query.feed解析器返回posts,而是返回postIds,它只是一个 ID 数组(表示为字符串)。 - 在
Feed.posts解析器中,我们现在可以访问由*父*解析器返回的postIds。这次,我们可以利用传入的info对象,并将其直接传递给posts绑定函数。
如果你想尝试这个例子,你可以查看这个仓库,其中包含上述例子的运行版本。请随意尝试本文中提到的不同实现,并亲自观察其行为!
概要
在本文中,你深入了解了在基于 GraphQL.js 实现 GraphQL API 时使用的 info 对象。
info 对象没有官方文档——要了解更多信息,你需要深入研究代码。在本教程中,我们首先概述了其内部结构并理解了它在 GraphQL 解析器函数中的作用。然后,我们介绍了一些边缘情况和潜在陷阱,在这些情况下需要更深入地理解 info。
本文中显示的所有代码都可以在相应的 GitHub 仓库中找到,因此你可以亲自实验并观察 info 对象的行为。
不要错过下一篇文章!
订阅 Prisma 新闻通讯
