架构
架构指令管道

指令管道

指令被放置在管道中,按顺序执行。其初始设计很简单,如下所示:

指令管道

在此架构中:

  • 管道的输入是字段解析器提供的字段值
  • 每个指令执行其逻辑,并将结果传递给管道中的下一个指令
  • 管道的输出将是经过所有指令处理后的已解析字段值

然而,这个架构并没有充分发挥 GraphQL 的能力。以下是对实际指令管道所有阶段的描述,直至 Gato GraphQL 中实际实现的设计。

作为 Query 解析构建块的指令

最初,我们可以考虑让 GraphQL 服务器通过某种机制解析字段,然后将该值作为输入传递给指令管道。

然而,使用单一机制处理所有事情要简单得多:调用字段解析器(用于验证字段和解析字段)已经可以通过指令管道完成。在这种情况下,指令管道是解析 Query 的唯一机制。

因此,Gato GraphQL 服务器提供了两个特殊指令:

  • @validate 调用字段解析器以验证字段是否可以被解析(例如:语法正确、字段存在等)
  • 如果验证成功,@resolveValueAndMerge 则调用字段解析器解析字段,并将值合并到响应对象中

这两个是特殊类型的「系统」指令:它们仅供 GraphQL 引擎使用,并在每个字段上隐式存在。(相比之下,标准指令是显式的:由用户添加到 Query 中。)

通过使用这两个指令,以下 Query:

query {
  field1
  field2 @directiveA
}

...将被解析为:

query {
  field1 @validate @resolveValueAndMerge
  field2 @validate @resolveValueAndMerge @directiveA
}

管道现在如下所示(请注意,管道接收字段作为输入,而不是其初始解析值):

@validate 和 @resolveValueAndMerge 的指令管道

管道插槽

指令通常在 @resolveValueAndMerge 之后执行,因为它们大多涉及更新已解析字段的值。然而,也有一些指令必须在 @validate 之前执行,或在 @validate@resolveValueAndMerge 之间执行。

例如:

  • 为了测量解析字段所需的时间,指令 @traceExecutionTime 可以通过在管道开头放置子指令 @startTracingExecutionTime、在末尾放置 @endTracingExecutionTime,来获取字段解析前后的当前时间
  • 指令 @cache 必须在执行 @resolveValueAndMerge 之前检查所请求的字段是否已被缓存,并在已缓存时直接返回该响应

管道将通过类 PipelinePositions 提供五个不同的插槽,指令将指定在哪个插槽中执行:

  • "beginning" 插槽:最开始的位置
  • "before-validate" 插槽:在验证发生之前
  • "middle" 插槽:在验证之后、字段解析之前
  • "after-resolve" 插槽:在字段解析之后
  • "end" 插槽:最末尾的位置

指令管道现在如下所示(为简化起见,仅考虑 3 个阶段):

带插槽的指令管道

请注意,在此架构中,指令 @skip@include 是如何轻松实现的:放置在 "middle" 插槽中,它们可以通过将标志 skipExecution 设置为 true,通知指令 @resolveValueAndMerge(以及管道中后续阶段的所有指令)不执行。

管道中的 @skip 指令

在单次调用中对多个字段执行指令

到目前为止,我们考虑的是将单个字段作为输入传递给指令管道。然而,在典型的 GraphQL Query 中,我们会接收多个字段来执行指令。

例如,在以下 Query 中,指令 @upperCase 在字段 "field1""field2" 上执行:

query {
  field1 @upperCase
  field2 @upperCase
  field3
}

此外,由于 GraphQL 引擎会将系统指令 @validate@resolveValueAndMerge 添加到 Query 中的每个字段,因此以下 Query:

query {
  field1
  field2
  field3
}

...将被解析为以下 Query:

query {
  field1 @validate @resolveValueAndMerge
  field2 @validate @resolveValueAndMerge
  field3 @validate @resolveValueAndMerge
}

因此,系统指令将始终接收所有字段作为输入。

由此,指令管道被设计为接收多个字段作为输入,而不是每次只接收一个:

指令管道中接收多个字段作为输入

这种架构更高效,因为对所有字段只执行一次指令比每个字段执行一次更快,并且会产生相同的结果。

例如,当验证用户是否已登录以授予对 schema 的访问权限时,该操作只需执行一次。运行以下代码:

if (isUserLoggedIn()) {
  resolveFields([$field1, $field2, $field3]);
}

比运行以下代码更高效:

if (isUserLoggedIn()) {
  resolveField($field1);
}
if (isUserLoggedIn()) {
  resolveField($field2);
}
if (isUserLoggedIn()) {
  resolveField($field3);
}

调用像 isUserLoggedIn 这样的本地函数时,差异可能不大,但在与外部服务交互时(例如通过 GraphQL 解析 REST 端点),差异会非常显著。在这些情况下,执行一次函数而不是多次,可能决定了是否能够提供某种功能。

让我们看一个例子。通过 @translate 指令与 Google Translate 交互时,GraphQL API 必须通过网络建立连接。执行以下代码将尽可能快:

googleTranslateFields([$field1, $field2, $field3]);

相比之下,单独多次执行函数会产生更高的延迟,导致响应时间更长,降低 API 的性能。翻译 3 个字符串时(字段是要翻译的字符串)可能差别不大,但对于 100 个或更多字符串,肯定会产生影响:

googleTranslateField($field1);
googleTranslateField($field2);
googleTranslateField($field3);

此外,将所有输入一次性传递给函数,可能比分别对每个字段执行函数产生更好的响应。再次以 Google Translate 为例,提供给服务的数据越多,翻译就越精确。

例如,执行以下代码时:

googleTranslate("fork");
googleTranslate("road");
googleTranslate("sign");

在第一次独立执行时,Google 不知道 "fork" 的上下文,因此可能将其理解为餐具叉子、道路分叉或其他含义。然而,如果改为执行:

googleTranslate(["fork", "road", "sign"]);

从这更广泛的信息量中,Google 可以推断出 "fork" 指的是道路的分叉,并返回精确的翻译。

正是由于这些原因,管道中的指令会一起接收所有输入字段,然后每个指令可以决定对这些输入执行逻辑的最佳方式(对每个输入单独执行一次、对所有输入合并执行一次,或介于两者之间的任何方式)。

管道现在如下所示:

指令管道中接收多个字段作为输入

为整个 Query 执行单一指令管道

刚才我们了解到,每个指令处理多个字段是有意义的,但这只在所有字段都应用了相同指令时才能很好地工作。当指令不同时,会导致更大的复杂性,使其实现变得困难,并会减少部分已获得的优势。

让我们看看这是如何发生的。考虑以下 Query:

query {
  field1 @directiveA
  field2
  field3
}

此指令等同于:

query {
  field1 @validate @resolveValueAndMerge @directiveA
  field2 @validate @resolveValueAndMerge
  field3 @validate @resolveValueAndMerge
}

在此场景中,字段 field2field3 拥有相同的指令集,而 field1 拥有不同的指令集,因此我们需要生成 2 个不同的管道来解析该 Query:

Query 需要 2 个指令管道来解析

当所有字段都拥有唯一的指令集时,效果更为明显。考虑以下 Query:

query {
  field1 @directiveA
  field2 @directiveB @directiveC
  field3 @directiveC
}

等同于:

query {
  field1 @validate @resolveValueAndMerge @directiveA
  field2 @validate @resolveValueAndMerge @directiveB @directiveC
  field3 @validate @resolveValueAndMerge @directiveC
}

在这种情况下,需要 3 个管道来处理 3 个字段:

Query 需要 3 个指令管道来解析

在这种情况下,尽管指令 @validate@resolveValueAndMerge 应用于所有 3 个字段,但由于它们通过 3 个不同的指令管道执行,所以会相互独立地执行,这又回到了指令一次只对单个项目执行的状态。

解决此问题的方案是避免生成多个管道,而是用单一管道处理所有字段。因此,引擎不再将字段作为输入传递给管道,因为单一管道中并非所有指令都会与同一组字段交互;相反,每个指令必须接收其自己的字段列表作为其输入。

对于以下 Query:

query {
  field1 @directiveA
  field2
  field3
}

...指令 @validate@resolveValueAndMerge 将接收所有 3 个字段作为输入,而 directiveA 只接收 "field1"

用于解析所有字段的单一指令管道

对于以下 Query:

query {
  field1 @directiveA
  field2 @directiveB @directiveC
  field3 @directiveC
}

...指令 @validate@resolveValueAndMerge 将接收所有 3 个字段作为输入,directiveA 只接收 "field1"directiveB 只接收 "field2"directiveC 接收 "field2""field3"

用于解析所有字段的单一指令管道

逐 ID 控制指令执行

到目前为止,某个阶段的指令可以通过标志 skipExecution 影响后续阶段指令的执行。然而,该标志对于所有情况的粒度不够细。

例如,考虑一个 @cache 指令,它被放置在 "end" 插槽中以存储字段值,这样下次查询该字段时,可以通过放置在 "middle" 插槽中的指令 @getCache 从缓存中检索其值:

Pipeline with @getCache and @cache directives

执行此 Query 时:

{
  posts(pagination: { limit: 2 }) {
    title @translate @cache
  }
}

服务器将检索并缓存 2 条记录。然后,我们执行相同的 Query,但应用于 4 条记录:

{
  posts(pagination: { limit: 4 }) {
    title @translate @cache
  }
}

执行第 2 次 Query 时,第 1 次 Query 的 2 条记录已被缓存,但其他 2 条记录尚未缓存。然而,要使用标志 skipExecution,需要所有 4 条记录都已被缓存。如果能从缓存中检索前 2 条记录,只解析另外 2 条记录,效果会更好。

因此,我们再次更新管道设计。废弃标志 skipExecution,改为通过输入对象 fieldIDs 向每个指令传递应用该指令的字段对应的对象 ID 列表:

{
  field1: [ID11, ID12, ...],
  field2: [ID21, ID22, ...],
  ...
  fieldN: [IDN1, IDN2, ...],
}

变量 fieldIDs 对每个指令是唯一的,每个指令都可以修改后续阶段所有指令的 fieldIDs 实例。这样,skipExecution 可以在逐 ID 的粒度上进行,只需从堆栈中所有后续指令的 fieldIDs 中删除该 ID 即可。

管道现在如下所示:

向每个指令传递每个字段的 ID

应用于前面的示例,执行翻译 2 条记录的第一个 Query 时,管道如下所示:

第 1 次 Query 中向每个指令传递每个字段的 ID

执行翻译 4 条记录的第二个 Query 时,指令 @getCache 获取所有 4 条记录的 ID,但 @resolveValueAndMerge@cache 只接收最后 2 条记录(未被缓存)的 ID:

第 2 次 Query 中向每个指令传递每个字段的 ID

整合所有内容

这是指令管道的最终设计:

指令管道的最终设计

总结来说,其特点如下:

  • 字段解析器通过指令 @validate@resolveValueAndMerge 从指令管道内部调用
  • 指令可以放置在 5 个插槽的任意一个:"beginning""before-validate""middle""after-validate""end"
  • 指令在单次调用中解析多个字段
  • 单一管道包含 Query 中涉及的所有指令
  • 每个指令通过变量 fieldIDs 接收每个字段需要解析的自有 ID 集合
  • 指令可以为管道中后续阶段的所有指令修改变量 fieldIDs