Концепции, идеи, стратегии
Концепции, идеи, стратегииСравнение аргументов полей и директив

Сравнение аргументов полей и директив

Одну и ту же функциональность для изменения вывода поля в GraphQL зачастую можно реализовать двумя разными способами:

  1. Аргументы полей: field(arg: value)
  2. Директивы типа «запрос»: field @directive

(Директивы типа «запрос» — это те, что применяются в запросе на стороне клиента, в отличие от директив типа «схема», которые применяются через SDL (Schema Definition Language) при построении схемы на сервере. Поскольку Gato GraphQL создаёт схему из PHP-кода, а не из SDL, все его директивы относятся к типу «запрос» и называются просто «директивами».)

Например, преобразование ответа поля title в верхний регистр можно выполнить, передав field arg format со значением enum UPPERCASE, вот так:

{
  posts {
    title(format: UPPERCASE)
  }
}

или применив директиву @strUpperCase к полю, вот так:

{
  posts {
    title @strUpperCase
  }
}

В обоих случаях ответ сервера GraphQL будет одинаковым:

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

Когда следует использовать аргументы полей, а когда — директивы на стороне запроса? Есть ли разница между двумя методами или ситуации, в которых один вариант предпочтительнее другого?

Для чего нужны аргументы полей и директивы

Разрешение поля в GraphQL включает две разные операции:

  1. получение запрошенных данных из запрашиваемой сущности
  2. применение функциональности (например, форматирования) к полученным данным

Эти две операции можно обозначить как «разрешение данных» и «применение функциональности», или сокращённо — «данные» и «функциональность» соответственно.

Главное различие между аргументами полей и директивами состоит в том, что аргументы полей можно использовать как для «данных», так и для «функциональности», тогда как директивы — только для «функциональности».

Рассмотрим подробнее, что это означает.

Разрешение данных с помощью аргументов полей

Аргументы полей обрабатываются при разрешении поля, поэтому они могут использоваться для получения фактических данных, например для определения того, к какому свойству объекта обращаться.

Например, следующий код резолвера показывает, как аргумент size используется для получения того или иного источника изображения из типа объекта Media:

function resolveValue(
  object $mediaObject,
  FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
  if ($fieldDataAccessor->getFieldName() === 'src') {
    $size = $fieldDataAccessor->getValue('size');
    return $this->getMediaTypeAPI()->getImageSrc($mediaObject, $size);
  }
  // ...
}

Field args также можно использовать для определения того, какую строку или столбец таблицы базы данных необходимо запросить.

В этом запросе аргумент поля id используется для запроса конкретной сущности типа Post, которую резолвер преобразует в конкретную строку таблицы wp_posts базы данных WordPress:

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

В этой же таблице дата публикации хранится в двух разных столбцах: post_modified и post_modified_gmt (из соображений обратной совместимости). В данном запросе передача аргумента поля gmt со значением true или false означает получение значения из того или иного столбца:

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

Эти примеры демонстрируют, что field args могут изменять источник данных при разрешении поля.

Директивы не могут изменять источник данных, поскольку их логика реализована в directive resolvers, которые вызываются после field resolver. Следовательно, к моменту применения директивы значение поля уже должно быть получено.

Например, следующий запрос никогда не будет работать:

{
  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 знаков

Любая из этих операций является манипуляцией с уже полученными данными. Следовательно, они могут быть реализованы как в field resolver — сразу после получения данных и перед их возвратом, — так и в directive resolver, который получает значение поля в качестве входных данных. Таким образом, любая из этих операций может быть реализована как через аргументы полей, так и через директивы.

Например, field resolver для Post.excerpt может задавать значение по умолчанию через field arg default, и тогда мы можем настроить значение аргумента default в запросе:

{
  posts {
    excerpt(default: "(No excerpt)")
  }
}

Мы также можем создать директиву @default с directive resolver вида:

/**
 * 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, который не учитывает директивы при кэшировании значений полей. Если сначала выполнить запрос:

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

...Relay запросит и закэширует значение "Hello world!" для поста с ID 1. Если затем выполнить такой запрос:

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

...ответ должен быть "HELLO WORLD!", однако Relay вернёт "Hello world!" — значение, хранящееся в кэше для поста с ID 1, — проигнорировав директиву, применённую к полю.

Вопрос о том, разрешено ли директивам изменять выходное значение поля, находится в «серой зоне»: это не разрешено явно, но и не запрещено спецификацией 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-запрос является валидным, однако неизвестно, какой ответ получит пользователь:

{
  user(by: { id: 1 }) {
    name
    name @strUpperCase
    name @strLowerCase
  }
}

В зависимости от поведения сервера GraphQL ответ для поля name может быть "Leo", "LEO" или "leo"... мы не знаем этого заранее, и это проблема.

Та же проблема не возникает с аргументами полей. Когда выполняется следующий запрос:

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

...спецификация предписывает серверу GraphQL вернуть ошибку, так что значение name будет равно null. В таком случае нам пришлось бы использовать псевдонимы для выполнения запроса:

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

Директивы лучше подходят для модульности и повторного использования кода

Многие операции, предлагаемые директивами, не зависят от сущности и поля, к которым применяются. Например, @strUpperCase будет работать с любой строкой — будь то заголовок поста, имя пользователя, адрес местоположения или что-либо ещё.

Как следствие, код этой директивы реализуется только один раз в одном месте — в directive resolver. Подобно аспектно-ориентированному программированию (которое повышает модульность за счёт разделения сквозных задач), директивы применяются к полю, не затрагивая его логику.

В отличие от этого, реализация той же функциональности через аргумент поля предполагает дублирование одного и того же кода в field resolver (и в разных field resolvers):

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);
  }
  // ...
}

Для уменьшения объёма кода в резолверах директивы предпочтительнее аргументов полей.

Директивы лучше подходят для проектирования схемы

Добавление аргументов полей добавляет лишнюю информацию в схему, что может привести к её разрастанию и несогласованности.

Например, аргумент поля 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
}

Директивы позволяют сохранить схему максимально лаконичной:

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, что приведёт к медленному ответу при разрешении запроса.

Кроме того, независимое выполнение вызовов не позволит связать их данные, поэтому качество перевода будет хуже, чем если бы все данные были отправлены вместе в одном вызове API.

Например, заголовок поста "Power" можно перевести точнее, если содержимое поста, из которого ясно, что это слово означает «электроэнергию», будет отправлено вместе с ним.

Gato GraphQL вызывает директиву только один раз, передавая в качестве входных данных все поля и объекты, к которым она применяется. Получая все данные сразу, директива @strTranslate может выполнить единственный вызов Google Translate, передав все поля title и content для всех объектов, как в следующем запросе:

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

Директивы могут обеспечить более производительный способ изменения значений полей, особенно при взаимодействии с внешними API.