概念、想法、策略
概念、想法、策略通过元指令实现脚本功能

通过元指令实现脚本功能

假设我们有一个指令 @strTitleCase,可以应用于查询中的字段,将其值从 "hello world!" 转换为 "Hello World!",因此将其应用于 String 类型的字段才有意义。

运行以下查询时:

{
  post(by: { id: 1 }) {
    title @strTitleCase
  }
}

...将得到:

{
  "data": {
    "post": {
      "title": "Hello World!"
    }
  }
}

现在,假设字段类型为 [String](或 [String!]),如以下情况:

type Post {
  categoryNames: [String!]
}

运行以下查询时,对字段 categoryNames 应用指令 @strTitleCase 应该发生什么?

{
  post(by: { id: 1 }) {
    categoryNames @strTitleCase
  }
}

理想情况下,响应应该是数组中每个 String 值经过转换后的结果:

{
  "data": {
    "post": {
      "categoryNames": [
        "Software",
        "Web Development",
        "Mobile App"
      ]
    }
  }
}

为了实现这一点,@strTitleCase 的指令解析器需要检查输入是否为数组,并据此进行处理(此 PHP 代码仅为示例,插件中的实际方法有所不同):

function applyDirective(mixed $value, array $schemaDef): mixed
{
  // Convert each item in an array to title case
  if ($schemaDef['isArray']) {
    return array_map(ucwords(...), $value);
  }
 
  // Convert the String value to title case
  return ucwords($value);
}

这并不太难。但如果字段是 String 的数组的数组,即 [[String]],该怎么办?虽然稍微复杂一点,指令也可以处理:

function applyDirective(mixed $value, array $schemaDef): mixed
{
  // Convert each item in an array of arrays to title case
  if ($schemaDef['isArrayOfArrays']) {
    return array_map(
      fn (array $array) => array_map(ucwords(...), $array),
      $value
    );
  }
 
  // Convert each item in an array to title case
  if ($schemaDef['isArray']) {
    return array_map(ucwords(...), $value);
  }
 
  // Convert the String value to title case
  return ucwords($value);
}

那么,如果是 [[[String]]][[[[String]]]] 呢?实现起来就越来越困难了。

更糟糕的是,这种额外的逻辑样板代码需要为每一个可能应用于数组的指令都实现一遍。例如,要实现指令 @strUpperCase,也需要这段额外的逻辑:

function applyDirective(mixed $value, array $schemaDef): mixed
{
  // Convert each item in an array of arrays to uppercase
  if ($schemaDef['isArrayOfArrays']) {
    return array_map(
      fn (array $array) => array_map(strtoupper(...), $array),
      $value
    );
  }
 
  // Convert each item in an array to uppercase
  if ($schemaDef['isArray']) {
    return array_map(strtoupper(...), $value);
  }
 
  // Convert the String value to uppercase
  return strtoupper($value);
}

这看起来并不优雅,对吧?

解决方案:通过另一个指令修改指令的输入

这正是将一个指令应用于修改另一个指令的行为大显身手的地方。

无需针对字段可能的各种数组深度(即 String[String][[String]][[[String]]] 等)一一处理,@strTitleCase 只需处理基本情况 String

function applyDirective(mixed $value, array $schemaDef): mixed
{
  // The input will always be `String`
  // Convert the String value to title case
  return ucwords($value);
}

然后,另一个指令 @underEachArrayItem 通过以下方式修改其行为:

  1. [String] 类型的单个输入转换为 String 类型的输入数组
  2. 遍历该数组中的每个元素,并依次调用和应用下游指令(@strTitleCase),该指令将接收 String 类型的输入
  3. String 值的数组重新转换为单个 [String]

这样我们就可以执行以下查询:

{
  post(by: { id: 1 }) {
    categoryNames @underEachArrayItem @strTitleCase
  }
}

这张 gif 展示了 @underEachArrayItem 的实际效果:

Adding @underEachArrayItem to modify another directive

这个解决方案的优雅之处在于,它将数组的深度与指令的实现解耦。如果输入类型为 [[String]],只需再添加一个 @underEachArrayItem,让它修改那个修改目标指令的 @underEachArrayItem 即可:

{
  customerAllNames @underEachArrayItem @underEachArrayItem @strTitleCase
}

...生成:

{
  "data": {
    "customerAllNames": [
      [
        "John",
        "Edward",
        "Stevenson"
      ],
      [
        "Samantha",
        "Perkins"
      ],
      [
        "Michael",
        "Edward",
        "Higgs"
      ]
    ]
  }
}

因此,正如我们所见,一个指令修改另一个指令的情况也可以发生在指令管道中——其中一个指令影响下游的指令,而它自身也被上游的指令所修改。

我们将 @underEachArrayItem 称为「元指令」:一种修改另一个指令行为的指令。通过这种方式,它为开发者提供了「元脚本」能力,可以在 GraphQL 查询内部添加一些编程逻辑。

格式化 GraphQL 查询

由于空白字符不具有语义意义,我们可以对查询和 SDL 进行格式化,以更清晰地表达嵌套关系:

{
  customerAllNames
    @underEachArrayItem
      @underEachArrayItem
        @strTitleCase
}

定义嵌套指令的管道

@underEachArrayItem 如何知道它需要修改 @strTitleCase 的行为?在前面的示例中,是因为它被放置在 @strTitleCase 的正前面。但如果在它们之后还有另一个指令,应该怎么处理?

例如,在以下查询中:

{
  post(by: { id: 1 }) {
    categoryNames
      @underEachArrayItem
        @strTitleCase
        @strTranslate(to: "es")
  }
}

...@underEachArrayItem 也应该修改指令 @strTranslate 的行为,因为该指令也必须应用于 String,从而生成以下响应:

{
  "data": {
    "post": {
      "categoryNames": [
        "Software",
        "Desarrollo web",
        "Aplicación movil"
      ]
    }
  }
}

然而,后续放置的指令也可能需要应用于数组,而不是单个 String 值。例如,以下指令 @arrayPad 用默认值填充数组中缺少的条目,因此不应受到 @underEachArrayItem 的影响:

{
  post(by: { id: 1 }) {
    categoryNames
      @underEachArrayItem
        @strTitleCase
      @arrayPad(length: 5, value: "undefined")
  }
}

...生成以下响应:

{
  "data": {
    "post": {
      "categoryNames": [
        "Software",
        "Web Development",
        "Mobile App",
        "undefined",
        "undefined"
      ]
    }
  }
}

为了区分这两种情况,我们为 @underEachArrayItem 引入了参数 affectDirectivesUnderPos,该参数以 Int 数组的形式定义需要被影响的指令的相对位置。

在以下查询中,@underEachArrayItem 知道它需要应用于 @strTitleCase@strTranslate,因为它们分别位于自身的相对位置 12

{
  post(by: { id: 1 }) {
    categoryNames
      @underEachArrayItem(affectDirectivesUnderPos: [1, 2])
        @strTitleCase
        @strTranslate(to: "es")
  }
}

在另一个查询中,@underEachArrayItem 只应用于 @strTitleCase(相对位置 1),而不应用于 @arrayPad

{
  post(by: { id: 1 }) {
    categoryNames
      @underEachArrayItem(affectDirectivesUnderPos: [1])
        @strTitleCase
      @arrayPad(length: 5, value: "undefined")
  }
}

affectDirectivesUnderPos 的默认值设为 [1],因此如果未指定,指令将始终应用于其正后方的指令。上面的查询等同于这个:

{
  post(by: { id: 1 }) {
    categoryNames
      @underEachArrayItem
        @strTitleCase
      @arrayPad(length: 5, value: "undefined")
  }
}

我们可以定义任意组合——哪些指令受元指令影响,哪些不受影响:

{
  post(by: { id: 1 }) {
    categoryNames
      @underEachArrayItem(affectDirectivesUnderPos: [1, 2])
        @strTitleCase
        @strTranslate(to: "es")
      @arrayPad(length: 5, value: "undefined")
  }
}