GraphQL中的字段参数与指令

739 阅读6分钟

在GraphQL中修改字段输出的相同功能,通常可以通过两种不同的方法实现。

  1. 字段参数。field(arg: value)
  2. 查询类型的指令。field @directive

查询类型的指令是那些在客户端应用于查询的指令,与模式类型的指令不同,后者是在服务器端建立模式时通过SDL应用。例如,将一个title 字段的响应转换为大写字母,可以通过传递一个字段参数format 和一个枚举值UPPERCASE 来实现,像这样。

{
  posts {
    title(format: UPPERCASE)
  }
}

或者通过对字段应用一个指令@upperCase ,像这样。

{
  posts {
    title @upperCase
  }
}

在这两种情况下,GraphQL服务器的响应将是相同的。

{
  "data": {
    "posts": [
      {
        "title": "HELLO WORLD!"
      },
      {
        "title": "FIELD ARGUMENTS VS DIRECTIVES IN GRAPHQL"
      }
    ]
  }
}

我们什么时候应该使用字段参数,什么时候应该使用查询边的指令?这两种方法之间有什么区别吗,或者在什么情况下一个选项比另一个更好?在这篇文章中,我们将找出答案。

GraphQL中字段参数和指令之间的区别是什么?

在GraphQL中解决一个字段涉及两个不同的操作。

  1. 从被查询的实体中获取请求的数据
  2. 在请求的数据上应用功能(如格式化)。

我们可以将这两种操作标记为 "数据解析 "和 "应用功能",或者简称为 "数据 "和 "功能"。

字段参数和查询类型指令之间的主要区别是,字段参数可以用于 "数据 "和 "功能",但指令只能用于 "功能"。

让我们更详细地了解一下这意味着什么。

通过字段参数解析数据

字段参数在解析字段时被处理;因此,它们可以被用来检索实际数据,比如决定访问对象的什么属性。

例如,Apollo GraphQL的这个解析器显示了参数size 是如何用来从一个对应于类型Mission 的对象中获取一个或另一个属性的。

Mission: {
  // The default size is 'LARGE' if not provided
  missionPatch: (mission, { size } = { size: 'LARGE' }) => {
    return size === 'SMALL'
      ? mission.missionPatchSmall
      : mission.missionPatchLarge;
  },
},

字段参数也可以用来帮助决定必须从数据库表中查询哪一行或哪一列。

这个查询中,字段参数id 被用来查询类型为Post 的一些特定实体,解析器将把它翻译成WordPress的wp_posts 数据库表中的一些特定行。

{
  post(id: 1) {
    title
  }
}

同一张表将帖子的日期存储在两个不同的列中,post_modifiedpost_modified_gmt (为了向后兼容的原因)。在这个查询,用truefalse 传递字段参数gmt ,就会转化为从一个或另一个列中获取值。

{
  post(id: 1) {
    title
    date(gmt: true)
  }
}

这些例子表明,字段参数可以在解析字段时修改数据的来源。

然而,查询类型的指令不能用来修改数据的来源,因为它们的逻辑是通过指令解析器提供的,而解析器是在字段解析器之后调用的。因此,当指令被应用时,字段的值必须已经被检索到。

例如,一个检索单一实体的字段,如post,userproduct ,必须总是收到一个ID,否则服务器就不知道要检索哪个实体了。所以这个查询将永远不会成功。

{
  post @selectEntity(id: 1) {
    title
  }
}

在这种情况下,帖子实体的id ,只能通过字段参数提供。由于它的缺失,服务器将因此返回一个错误

{
  "errors": [
    {
      "message": "Argument 'id' cannot be empty",
      "extensions": {
        "type": "QueryRoot",
        "field": "post @selectEntity(id:1)"
      }
    }
  ]
}

总之,只有字段参数可以帮助检索到解决字段的数据。

通过字段参数或指令应用功能

一旦我们检索到字段的数据,我们可能想操作它的值。例如,我们可以。

  • 格式化一个字符串,将其转换为大写或小写字母
  • 格式化一个用字符串表示的日期,从默认的YYYY-mm-dd 格式到dd/mm/YYYY
  • 屏蔽一个字符串,将电子邮件和电话号码替换为***
  • 提供一个默认值,如果它是null 或为空的话
  • 将浮动数四舍五入到两位数

这些操作中的任何一项都是对已经检索到的数据进行操作。因此,它们既可以在字段解析器中编码,即在获取数据之后和返回数据之前,也可以在指令解析器中编码,后者将获得字段的值作为其输入。因此,这些操作中的任何一个都可以通过字段参数或查询型指令来实现。

例如,Post.title 的字段解析器可以通过字段参数fallback 提供一个默认值。

// Resolvers
module.exports = {
  Post: {
    title: (post, { fallback } = { fallback: '' }) => {
      return post.title || fallback;
    }
  }
};

然后我们可以在查询中为fallback arg定制值。

{
  posts {
    title(fallback: "(No title)")
  }
}

我们也可以创建一个@default 指令,用这样的指令解析器。

/**
 * Replace all the empty results with the default value
 */
function resolveDirective(
  array $directiveArgs,
  array $objectIDFields,
  array $objectsByID,
  array &$responseByObjectIDAndField
): void {
  foreach ($objectIDFields as $id => $fields) {
    $object = $objectsByID[$id];
    $defaultValue = $directiveArgs['value'];
    foreach ($fields as $field) {
      if (empty($responseByObjectIDAndField\[$id\][$field])) {
        $responseByObjectIDAndField\[$id\][$field] = $defaultValue;
      }
    }
  }
}

这两种策略是否同样适合?让我们根据不同的兴趣领域来探讨这个问题。

使用字段参数,与GraphQL规范、客户端和工具兼容

GraphQL规范中没有明确定义允许指令操作的范围,该规范中写道:"。

指令提供了一种方法来描述GraphQL文档中交替的运行时执行和类型验证行为。

在某些情况下,你需要提供选项来改变GraphQL的执行行为,而字段参数是不够的,如有条件地包括或跳过一个字段。指令通过向执行者描述额外的信息来提供这些。

这个定义同意使用查询类型的指令,如@include@skip --分别有条件地包括和跳过一个字段,以及@stream@defer --为从服务器检索数据提供不同的运行时间执行。

然而,这个定义对于修改字段值的查询型指令并不明确,比如@upperCase ,它将输出值"Hello world!" 转变为"HELLO WORLD!"

由于这种模糊性,不同的GraphQL服务器、客户端和工具可能会在不同程度上考虑到指令,在它们之间产生冲突。

例如,在使用Relay时,我们必须注意,它在缓存字段值时不考虑指令。因此,如果使用WordPress的GraphQL API作为我们的服务器,我们可能需要避免使用它的一些指令,例如 @upperCase, @lowerCase,以及 @titleCase.否则,当同一个字段在有和没有指令的情况下被查询时,Relay可能会使其缓存产生一个错误的值。

例如,如果首先查询。

{
  post(id: 1) {
    title
  }
}

Relay会查询并缓存ID为1 的帖子的值"Hello world!" 。如果我们再运行这个查询。

{
  post(id: 1) {
    title @upperCase
  }
}

响应应该是"HELLO WORLD!" ;然而,Relay会返回"Hello world!" ,这是存储在其缓存中的ID为1 的帖子的值,忽略了应用于该字段的指令。

是否允许指令修改字段的输出值是一个灰色地带,因为GraphQL规范中既没有明确允许也没有禁止;然而,两种相反的情况都有指标。

一方面,GraphQL规范似乎授予指令一个自由的手来改进和定制GraphQL

随着GraphQL的未来版本采用新的可配置的执行能力,它们可能会通过指令暴露出来。GraphQL服务和工具也可以提供超出这里描述的任何额外的自定义指令。

另一方面,该规范没有考虑到FieldsInSetCanMerge 验证或CollectFields 算法的指令。正如规范的贡献者Benjie Gillam所指出的,下面的GraphQL查询是有效的,但不确定用户会得到什么响应。

{
  user(id: 1) {
    name
    name @upperCase
    name @lowercase
  }
}

根据GraphQL服务器的行为,字段name 的响应可能是"Leo""LEO" ,或"leo" - 这是一个问题,我们事先不知道它将返回什么。

使用字段参数时,同样的问题不会发生。当下面的查询被执行时。

{
  user(id: 1) {
    name
    name(format: UPPERCASE)
    name(format: LOWERCASE)
  }
}

规范规定GraphQL服务器要返回一个错误,所以name 的值将是null 。然后,我们将被迫引入别名来执行查询。

可能正是由于这些原因,我们不鼓励在GraphQL生态系统中使用查询型指令。例如,GraphQL工具建议不要使用它们(尽管它仍然允许它们)。

指令语法也可以出现在从客户端发送的GraphQL查询中。[...]然而,一般来说,模式作者应该考虑尽可能使用字段参数,而不是查询指令。

总而言之,如果我们想安全一点,我们必须使用字段参数。应该避免使用查询类型的指令,除非我们能够保证在所使用的堆栈(GraphQL服务器、客户端和工具)中没有冲突,或者当API只供内部使用且我们知道自己在做什么时。在这种情况下,有很好的理由使用查询型指令,我们接下来会看到。

指令更有利于模块化和代码的可重用性

查询类型指令提供的许多操作与应用它们的实体和字段无关。例如,@upperCase 将对任何字符串起作用,无论它是应用于一个帖子的标题、一个用户的名字、一个地点的地址,还是其他什么。因此,这个指令的代码只在一个地方实现一次:指令解析器。类似于面向方面的编程,它通过允许跨领域关注点的分离来增加模块化,指令被应用于字段而不影响字段的逻辑。相比之下,通过字段参数实现相同的功能需要在不同的字段解析器中执行相同的代码。

const formatString = (string, format) => {
  if (format === "UPPERCASE") {
    return string.toUpperCase();
  }
  if (format === "LOWERCASE") {
    return string.toLowerCase();
  }
  return string;
};

// Resolvers
module.exports = {
  Post: {
    title: (post, { format }) => {
      return formatString(post.title, format);
    },
    excerpt: (post, { format }) => {
      return formatString(post.excerpt, format);
    },
    content: (post, { format }) => {
      return formatString(post.content, format);
    },
  },
  User: {
    name: (user, { format }) => {
      return formatString(user.name, format);
    },
    description: (user, { format }) => {
      return formatString(user.description, format);
    },
  },
};

中间件可以改善这种解决方案的架构。

async function middleware({ root, { format }, context, info }, next) {

  const string = await next();

  if (format === "UPPERCASE") {
    return string.toUpperCase();
  }
  if (format === "LOWERCASE") {
    return string.toLowerCase();
  }
  return string;
}

但是指令比中间件更具有模块化和可重用性,因为指令和字段之间可以完全解耦,所以它们只在运行时接触,而不是在模式构建时接触。

所以,可以说,如果目标是减少解析器的代码量,那么查询型指令比字段参数更适合。

指令可能更适合于与有意见的CMS合作

当通过字段参数提供一个新的功能时,我们需要修改模式并重新编译它。但在与某些堆栈合作时,这可能是不可能的。

例如,WordPress允许其用户通过插件注入功能,并期望他们不需要触及一行代码。

在这种情况下,通过插件提供的指令可以是即插即用的,因为指令是在运行时应用的。相比之下,从模式中为相关字段添加参数很可能需要修改代码,这可能会使插件在激活后不能立即工作。

总之,查询型指令可能更适合让GraphQL遵守它所运行的有意见的CMS的理念。

指令更适合于模式设计

添加字段参数会给模式增加额外的信息,可能会使其变得臃肿,并使其不一致。

例如,字段参数format 将需要添加到所有的String 字段中,而且,如果我们不小心的话,它在不同的字段中可能不是同质的--它可能使用不同的名称、不同的值、不同的默认值,甚至将参数分成几个输入。

type Post {
  # Input value is "uppercase" or "lowercase"
  title(format: String): String
  content(format: String): String
  excerpt(format: String): String
}

type Category {
  # Input name is "case" instead of "format"
  # Input value is an enum StringCase with values UPPERCASE and LOWERCASE
  name(case: StringCase): String
}

type Tag {
  # Using a default value
  name(format: String = "lowercase"): String
}

type User {
  # Using multiple Boolean inputs
  description(useUppercase: Boolean, useLowercase: Boolean): String
}

指令使我们能够尽可能地保持模式的精简。

directive @upperCase on FIELD
directive @lowerCase on FIELD

type Post {
  title: String
  content: String
  excerpt: String
}

type Category {
  name: String
}

type Tag {
  name: String
}

type User {
  description: String
}

总之,使用查询型指令可以产生比使用字段参数更优雅的GraphQL模式。

指令比字段参数更有效

在执行时,字段参数将在解析字段时被访问,这发生在逐个字段和逐个对象的基础上。例如,当解析一个帖子列表上的字段titlecontent 时,解析器将在每个帖子和字段上被调用一次。

// Resolvers
module.exports = {
  Post: {
    title: (post, args) => {
      return post.title;
    },
    content: (post, args) => {
      return post.content;
    },
  }
};

想象一下,我们想使用谷歌翻译API来翻译这些字符串,为此我们添加参数translateTo

const executeGoogleTranslate = (string, lang) => {
  // Execute against https://translation.googleapis.com
  return ...
};

module.exports = {
  Post: {
    title: (post, { translateTo }) => {
      return executeGoogleTranslate(post.title, translateTo);
    },
    content: (post, { translateTo }) => {
      return executeGoogleTranslate(post.content, translateTo);
    },
  }
};

我们最终可能会请求大量的连接到外部API,因为逻辑是按字段和对象的组合自然执行的。这可能会减慢解决查询的响应速度。

此外,相互独立地执行调用将不允许谷歌翻译关联他们的数据,所以翻译的质量将不如在一个API调用中所有数据一起提交。

例如,如果帖子内容(使人明显感觉到这个词指的是 "电力")与之一起提交,那么帖子标题"Power" 可以得到更好的翻译。

GraphQL服务器可以选择提供关于指令的更好的体验,其中指令可以只被调用一次,将所有字段和对象作为输入来应用。这就是关于WordPress服务器GraphQL API的指令的设计策略,它的签名是DirectiveResolver

/**
 * All fields and objects are passed together to the executed directive
 */
function resolveDirective(
  array $directiveArgs,
  array $objectIDFields,
  array $objectsByID,
  array &$responseByObjectIDAndField
): void {
  // ...
}

通过接收所有的数据,@translate 指令可以执行一次对谷歌翻译的调用,传递所有对象的所有titlecontent 字段,就像在这个查询中。

{
  posts(limit: 6) {
    title @translate(from:"en", to:"fr")
    excerpt @translate(from:"en", to:"fr")
  }
}

总之,即使不是所有的GraphQL服务器都利用这种架构设计,指令可以提供一种更有效的方式来修改字段的值,例如在与外部API交互时。

结论

如果同样的功能可以通过字段参数或查询类型指令来完成,我们为什么要对使用一种方法而不是另一种方法进行选择呢?

查询类型指令很有价值,它提供了代码的可重用性,并使我们能够简化模式。不幸的是,它们目前在GraphQL规范中处于灰色地带--不完全支持,但也没有被禁止。由于这种模糊性,GraphQL服务器、客户端和工具可能会以不同的方式处理指令,可能会在它们之间产生不相容性。

因此,为了安全起见,我们建议使用字段参数,或者使用查询类型的指令--只有当你知道你在做什么的时候。

GraphQL中的字段参数与指令一文首次出现在LogRocket博客上。