字段参数与指令的比较
在 GraphQL 中,修改字段输出的相同功能通常可以通过两种不同的方法实现:
- 字段参数:
field(arg: value) - 查询类型指令:
field @directive
(查询类型指令是指应用于客户端查询的指令,与通过 SDL(Schema Definition Language)在服务器上构建 schema 时应用的 schema 类型指令不同。由于 Gato GraphQL 从 PHP 代码而非 SDL 创建 schema,其所有指令都属于查询类型,简称为"指令"。)
例如,将 title 字段的响应转换为大写,可以通过传入值为枚举 UPPERCASE 的字段参数 format 来实现,如下所示:
{
posts {
title(format: UPPERCASE)
}
}或者,通过在字段上应用 @strUpperCase 指令来实现:
{
posts {
title @strUpperCase
}
}两种情况下,GraphQL 服务器的响应是相同的:
{
"data": {
"posts": [
{
"title": "HELLO WORLD!"
},
{
"title": "FIELD ARGUMENTS VS DIRECTIVES IN GRAPHQL"
}
]
}
}我们应该在什么时候使用字段参数,什么时候使用查询端指令?这两种方法之间有什么区别,或者其中一种比另一种更优的情况吗?
字段参数与指令各自擅长的领域
在 GraphQL 中解析字段涉及两种不同的操作:
- 从被查询的实体中获取所请求的数据
- 对所获取的数据应用功能(如格式化)
我们可以将这两种操作分别标记为"数据解析"和"功能应用",或简称为"数据"和"功能"。
字段参数与指令的主要区别在于:字段参数可用于"数据"和"功能"两者,而指令只能用于"功能"。
下面我们来更详细地了解这意味着什么。
通过字段参数解析数据
字段参数在解析字段时被处理,因此可用于获取实际数据,例如决定访问对象的哪个属性。
例如,以下解析器代码展示了如何使用参数 size 从 Media 对象类型中获取不同的图片来源:
function resolveValue(
object $mediaObject,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
if ($fieldDataAccessor->getFieldName() === 'src') {
$size = $fieldDataAccessor->getValue('size');
return $this->getMediaTypeAPI()->getImageSrc($mediaObject, $size);
}
// ...
}字段参数还可以用于帮助决定需要查询数据库表的哪一行或哪一列。
在此 Query 中,字段参数 id 用于查询 Post 类型的特定实体,解析器会将其转换为 WordPress wp_posts 数据库表中的特定行:
{
post(by: { id: 1 }) {
title
}
}同一张表以两个不同的列 post_modified 和 post_modified_gmt 存储文章日期(出于向后兼容性考虑)。在此 Query 中,将字段参数 gmt 传入 true 或 false,即可决定从哪一列获取值:
{
post(by: { id: 1 }) {
title
date(gmt: true)
}
}这些示例说明字段参数可以在解析字段时修改数据来源。
指令不能用于修改数据来源,因为指令的逻辑由指令解析器提供,而指令解析器在字段解析器之后被调用。因此,在指令被应用时,字段的值必须已经被获取。
例如,以下 Query 永远不会生效:
{
post @selectEntity(id: 1) {
title
}
}在这个示例中,字段 post 需要提供实体的 id,但由于没有作为字段参数提供,服务器将返回错误:
{
"errors": [
{
"message": "Argument 'id' cannot be empty",
"extensions": {
"type": "QueryRoot",
"field": "post @selectEntity(id:1)"
}
}
]
}总之,只有字段参数才能帮助获取解析字段所需的数据。
通过字段参数或指令应用功能
获取字段的数据后,我们可能希望对其值进行处理。例如,我们可以:
- 格式化字符串,将其转换为大写或小写
- 格式化以字符串表示的日期,从默认的
YYYY-mm-dd格式转换为dd/mm/YYYY - 屏蔽字符串,将电子邮件和电话号码替换为
*** - 若值为
null或为空,则提供默认值 - 将浮点数四舍五入到小数点后 2 位
以上任何操作都是对已获取数据的处理。因此,它们既可以在字段解析器中编码(在获取数据之后、返回之前),也可以在接收字段值作为输入的指令解析器中编码。如此一来,这些操作均可通过字段参数或指令来实现。
例如,Post.excerpt 的字段解析器可以通过字段参数 default 提供默认值,然后在 Query 中自定义 default 参数的值:
{
posts {
excerpt(default: "(No excerpt)")
}
}我们也可以创建一个 @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 规范中并未明确定义,规范如此写道:
Directives provide a way to describe alternate runtime execution and type validation behavior in a GraphQL document.
In some cases, you need to provide options to alter GraphQL's execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor.
此定义允许使用诸如 @include 和 @skip(分别用于条件性地包含或跳过字段)以及 @stream 和 @defer(为从服务器获取数据提供不同的运行时执行)等指令。
然而,对于像 @strUpperCase 这样修改字段值的指令(将输出值 "Hello world!" 转换为 "HELLO WORLD!"),此定义并不明确。
由于这种模糊性,不同的 GraphQL 服务器、客户端和工具可能对指令的支持程度不同,从而产生冲突。
Relay 就是一个例子,它在缓存字段值时不考虑指令。如果首先执行以下 Query:
{
post(by: { id: 1 }) {
title
}
}……Relay 会对 ID 为 1 的文章查询并缓存值 "Hello world!"。如果再执行以下 Query:
{
post(by: { id: 1 }) {
title @strUpperCase
}
}……响应应该是 "HELLO WORLD!",但 Relay 会忽略字段上应用的指令,返回其缓存中为 ID 1 文章存储的值 "Hello world!"。
指令是否被允许修改字段的输出值处于灰色地带,因为 GraphQL 规范既未明确允许,也未明确禁止,但两种对立情况都有相应的依据。
一方面,GraphQL 规范似乎赋予指令自由改善和定制 GraphQL 的手段:
As future versions of GraphQL adopt new configurable execution capabilities, they may be exposed via directives. GraphQL services and tools may also provide any additional custom directive beyond those described here.
另一方面,规范在 FieldsInSetCanMerge 验证和 CollectFields 算法中并未考虑指令。以下 GraphQL Query 是有效的,但用户将获得什么响应是不确定的:
{
user(by: { id: 1 }) {
name
name @strUpperCase
name @strLowerCase
}
}根据 GraphQL 服务器的行为,字段 name 的响应可能是 "Leo"、"LEO" 或 "leo"……我们无法提前知晓,这是一个问题。
字段参数不会出现同样的问题。当执行以下 Query 时:
{
user(by: { id: 1 }) {
name
name(format: UPPERCASE)
name(format: LOWERCASE)
}
}……规范要求 GraphQL 服务器返回错误,因此 name 的值将为 null。我们需要引入别名来执行该 Query:
{
user(by: { id: 1 }) {
name
ucName: name(format: UPPERCASE)
lcName: name(format: LOWERCASE)
}
}指令在模块化和代码复用性方面更具优势
指令提供的许多操作与其应用的实体和字段无关。例如,@strUpperCase 可以应用于任何字符串,无论是文章的标题、用户的姓名、地点的地址还是其他任何内容。
因此,该指令的代码只需在一个地方实现,即指令解析器。类似于面向切面编程(通过分离横切关注点来提高模块化程度),指令在不影响字段逻辑的情况下被应用于字段。
相比之下,通过字段参数实现相同功能,需要在各个字段解析器(以及不同的字段解析器)中执行相同的代码:
function formatString(string $string, string $format): string
{
if ($format === "UPPERCASE") {
return strtoupper($string);
}
if ($format === "LOWERCASE") {
return strtolower($string);;
}
return $string;
};
function resolveValue(
object $post,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
$format = $fieldDataAccessor->getValue('format');
if ($fieldDataAccessor->getFieldName() === 'title') {
return formatString($post->post_title, $format);
}
if ($fieldDataAccessor->getFieldName() === 'excerpt') {
return formatString($post->post_excerpt, $format);
}
if ($fieldDataAccessor->getFieldName() === 'content') {
return formatString($post->post_content, $format);
}
// ...
}为了减少解析器中的代码量,指令比字段参数更为合适。
指令在 schema 设计方面更具优势
添加字段参数会向 schema 中增加额外信息,可能导致 schema 膨胀并失去一致性。
例如,字段参数 format 需要添加到所有 String 字段中,如果不加注意,可能会导致字段间不一致,例如使用不同的名称、不同的值、不同的默认值,甚至将参数拆分为多个输入:
type Post {
# Input value is "uppercase" or "strLowerCase"
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 = "strLowerCase"): String
}
type User {
# Using multiple Boolean inputs
description(useUppercase: Boolean, useLowercase: Boolean): String
}使用指令可以让 schema 尽量保持精简:
directive @strUpperCase on FIELD
directive @strLowerCase on FIELD
type Post {
title: String
content: String
excerpt: String
}
type Category {
name: String
}
type Tag {
name: String
}
type User {
description: String
}指令有时比字段参数更高效
在运行时,字段参数在解析字段时被访问,这是逐字段、逐对象进行的。例如,在解析文章列表中的 title 和 content 字段时,解析器会对每篇文章和每个字段各调用一次:
function resolveValue(
object $post,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
if ($fieldDataAccessor->getFieldName() === 'title') {
return $post->post_title;
}
if ($fieldDataAccessor->getFieldName() === 'content') {
return $post->post_content;
}
// ...
}假设我们想使用 Google Translate API 翻译这些字符串,为此添加参数 translateTo:
function executeGoogleTranslate(string $string, string $lang): string
{
// Execute against https://translation.googleapis.com
// ...
};
function resolveValue(
object $post,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
$lang = $fieldDataAccessor->getValue('lang');
if ($fieldDataAccessor->getFieldName() === 'title') {
return executeGoogleTranslate($post->post_title, $lang);
}
if ($fieldDataAccessor->getFieldName() === 'content') {
return executeGoogleTranslate($post->post_content, $lang);
}
// ...
}由于逻辑自然地按字段和对象的每种组合执行,我们最终可能向外部 API 发起大量请求,导致 Query 解析响应缓慢。
此外,各次调用独立执行无法关联它们的数据,因此翻译质量会低于将所有数据一次性提交到单个 API 调用的情况。
例如,文章标题 "Power" 如果与文章内容一起提交——内容能清楚表明这个词指的是"电力"——则可以获得更准确的翻译。
Gato GraphQL 只调用指令一次,将所有需要应用的字段和对象作为输入传入。通过一次性接收所有数据,@strTranslate 指令可以对所有对象的所有 title 和 content 字段执行单次 Google Translate 调用,如以下 Query 所示:
{
posts(pagination: { limit: 6 }) {
title @strTranslate(from: "en", to: "fr")
excerpt @strTranslate(from: "en", to: "fr")
}
}指令可以提供一种更高效的方式来修改字段的值,例如在与外部 API 交互时。