GraphQL 请求剖析
GraphQL 服务器非常容易设置。只需传递一个 GraphQL 模式并启动服务器即可完成这项工作。 但是,对于许多用例,这样的设置可能还不够。 让我们深入了解 GraphQL 请求的每个阶段,并了解增强每个阶段如何帮助设置生产就绪的 GraphQL 服务器。 注意:虽然 GraphQL 几乎可以在任何协议上完成,但本文重点介绍最常用的协议 GraphQL over HTTP。但是,大多数知识可以转移到其他协议,例如 WebSockets 上的 GraphQL 或其他更奇特的协议。
GraphQL 服务器非常容易设置。只需传递一个 GraphQL 模式并启动服务器即可完成这项工作。
但是,对于许多用例,这样的设置可能还不够。
让我们深入了解 GraphQL 请求的每个阶段,并了解增强每个阶段如何帮助设置生产就绪的 GraphQL 服务器。
注意:虽然 GraphQL 几乎可以在任何协议上完成,但本文重点介绍最常用的协议 GraphQL over HTTP。但是,大多数知识可以转移到其他协议,例如 WebSockets 上的 GraphQL 或其他更奇特的协议。
HTTP 解析和规范化
客户端向服务器发送 HTTP 请求,其负载包含操作文档字符串(查询、变异或订阅)、可选的一些变量以及应执行的文档中的操作名称。
当请求通过 POST HTTP 方法执行时,主体将是一个 JSON 对象:
示例 JSON POST 正文
{
"query": "query UserById($id: ID!) { user(id: $id) { id name } }",
"variables": { "id": "10" },
"operationName": "UserById"
}
但是,当使用 GET HTTP 方法(例如用于查询操作)时,这些参数也可以作为查询搜索字符串提供。然后对这些值进行 URL 编码。
获取 URL 示例
http://localhost:8080/graphql?query=query%20UserById%28%24id%3A%20ID%21%29%20%7B%20user%28id%3A%20%24id%29%20%7B%20id%20name%20%7D%20%7D&variables=%7B%20%22id%22%3A%20%2210%22%20%7DoperationName=UserById
GraphQL HTTP 服务器的第一个任务是解析和规范化正文或查询字符串,并进一步确定用于发送响应的协议。协议可以是:
- application/graphql+json(或旧客户端的 application/json)用于从执行阶段产生的单个结果multipart/mixed 用于增量交付(使用 @defer 和 @stream 指令时)。
- 规范中没有正式使用,但也用于事件流的文本/事件流(例如,在执行订阅或实时查询操作时)。
GraphQL 解析
在解析和规范化请求后,服务器会将 GraphQL 参数传递给 GraphQL 引擎,该引擎将解析 GraphQL 操作文档(可以包含任意数量的查询、突变或订阅操作和片段定义)。
如果有任何拼写错误或语法错误,此阶段将为每个问题生成 GraphQLErrors 并将它们传递回服务器层以将它们发送回客户端。
对于以下无效的 GraphQL 操作 () 在第 2 行的 $id 之后丢失):
query UserById($id: ID!) {
user(id: $id {
id
name
}
}
该错误将类似于以下内容:
{
"message": "Syntax Error: Expected Name, found {",
"locations": [
{
"line": 2,
"column": 16
}
]
}
如您所见,不幸的是,错误消息并不总是直截了当且有用。但是,该位置可以帮助您追踪语法错误!
如果没有发生错误,解析阶段会生成一个 AST(抽象语法树)。
AST 是一种方便的格式,用于后续阶段的验证和执行。
对于我们的操作,它将与以下 JSON 相同:
{
"kind": "Document",
"definitions": [
{
"kind": "OperationDefinition",
"operation": "query",
"name": {
"kind": "Name",
"value": "UserById"
},
"variableDefinitions": [
{
"kind": "VariableDefinition",
"variable": {
"kind": "Variable",
"name": {
"kind": "Name",
"value": "id"
}
},
"type": {
"kind": "NonNullType",
"type": {
"kind": "NamedType",
"name": {
"kind": "Name",
"value": "ID"
}
}
}
}
],
"selectionSet": {
"kind": "SelectionSet",
"selections": [
{
"kind": "Field",
"name": {
"kind": "Name",
"value": "user"
},
"arguments": [
{
"kind": "Argument",
"name": {
"kind": "Name",
"value": "id"
},
"value": {
"kind": "Variable",
"name": {
"kind": "Name",
"value": "id"
}
}
}
],
"selectionSet": {
"kind": "SelectionSet",
"selections": [
{
"kind": "Field",
"name": {
"kind": "Name",
"value": "id"
}
},
{
"kind": "Field",
"name": {
"kind": "Name",
"value": "name"
}
}
]
}
}
]
}
}
]
}
此过程由从 graphql-js 导出的 parse 函数执行。其他以 graphql-js 参考实现为导向的语言也有相应的对应物。
GraphQL 验证
在验证阶段,解析后的文档会根据我们的 GraphQL 模式进行验证,以确保操作中的所有选定字段都可用且有效。之前解析的 AST 使得验证规则更容易拥有一个遍历文档的通用接口。此外,检查确保文档遵循 GraphQL 规范的其他验证规则,例如文档中引用的变量是否在操作定义中声明。
例如,以下操作缺少 $id 变量的定义:
query UserById {
user(id: $id) {
id
name
}
}
如果验证该操作的 AST,则会引发以下错误。
[
{
"message": "Variable \"$id\" is not defined by operation \"UserById\".",
"locations": [
{
"line": 2,
"column": 12
},
{
"line": 1,
"column": 1
}
]
}
]
如果出现任何错误,错误将被转发到 HTTP 层,该层负责通过确定的协议将它们发送回客户端。否则,如果没有引发错误,则接下来将执行执行阶段。
此过程由从 graphql-js 导出的 validate 函数执行。其他以 graphql-js 参考实现为导向的语言也有相应的对应物。
GraphQL 执行
在执行阶段,我们实际上是使用解析和验证的 GraphQL 操作文档 AST 和我们的 GraphQL 模式来解析客户端请求的数据,其中包含指定从何处检索客户端请求的数据的解析器。
以前,HTTP 请求已经过解析和规范化,这会产生以下附加(但可选)值:变量和 operationName。
如果用于执行的 GraphQL 文档包含多个可执行的突变、查询或订阅操作,则确定 operationName 以标识应使用的文档。如果确定的可执行操作具有任何变量定义,则根据从 HTTP 请求解析的变量值断言这些定义。
如果出现任何问题或不正确,则会引发错误。例如。提供的变量无效或由于 operationName 无效或缺失而无法确定应执行的操作。
匿名文档和命名文档的示例错误
query UserById($id: ID!) {
user(id: $id) {
id
name
}
}
query {
__typename
}
{
"errors": [
{
"message": "This anonymous operation must be the only defined operation.",
"locations": [
{
"line": 7,
"column": 1
}
]
}
]
}
这样的错误将再次由 HTTP 层转发给客户端。
否则,如果没有发生错误,则将使用所有已解析和提供的参数解析字段值。该阶段产生单个或流的 GraphQL 执行结果。
{
"data": {
"user": {
"id": "10",
"name": "Laurin"
}
}
}
HTTP 层将这些转发到发起请求的客户端。
此过程由从 graphql-js 导出的执行和订阅函数执行。其他以 graphql-js 参考实现为导向的语言也有相应的对应物。
为什么我们要覆盖不同的阶段?
覆盖解析
添加缓存
解析 GraphQL 文档字符串会产生开销。我们可以缓存频繁发送的文档字符串并从缓存中提供文档,而不是每次都解析它。
测试新功能
GraphQL 类型系统定义了 GraphQL 服务的功能。此阶段可用于向类型系统添加可能尚未被 GraphQL 规范支持的新功能。
覆盖验证
添加缓存
与解析类似,验证 GraphQL 文档 AST 会产生开销。我们可以缓存重复出现的文档 AST 并从缓存中提供验证结果,而不是每次都验证它。
添加自定义规则
您可能想限制允许执行的操作类型。例如。如果我们只想允许查询操作,我们可以提供一个自定义验证规则,一旦遇到突变或订阅操作就会产生错误。
覆盖执行或订阅
添加缓存
我们可以从缓存中提供频繁执行的 GraphQL 操作结果,而不是每次都调用我们所有的解析器并从远程数据库/服务器获取。
添加追踪信息
我们可以通过测量解决文档选择集中的每个字段并缩小瓶颈所需的时间来收集统计数据。
屏蔽并报告错误
我们要确保在执行过程中发生的错误不包含敏感信息并被正确报告,因此它们不会被忽视并被正确报告。
添加新功能
我们可以自定义 graphql-js 使用的算法,以添加新功能或提高性能,例如通过使用graphql-executor。
使用envelop扩展
在准备好我们的 GraphQL 服务器生产并与我们的客户合作时,我们发现我们一遍又一遍地编写相同的自定义代码。
Envelop 为我们提供了一种用户友好的方式来连接解析、验证、执行和订阅的前后阶段。
import { Plugin, envelop } from '@envelop/core';
import { NoSchemaIntrospectionCustomRule } from 'graphql';
const myPlugin: Plugin = {
onParse() {
console.log("before parsing")
return function afterParse({ result }) {
if (result instanceof Error) {
console.log("Error occured during parsing: ", result)
}
}
},
onValidate({ addValidationRule }) {
// add a custom validation rule
addValidationRule(NoSchemaIntrospectionCustomRule);
return function afterValidate({ result }) {
if (result.length) {
console.log("Errors occured duting validation: ", result)
}
}
},
onExecute({ args }) => {
const myVar = args.contextValue.myVar;
},
};
const getEnveloped = envelop({ plugins: [myPlugin] })
const { parse, validate, execute, subscribe } = getEnveloped();