Porównanie argumentów pola i dyrektyw
Tę samą funkcjonalność modyfikowania wyjścia pola w GraphQL często można osiągnąć za pomocą dwóch różnych metod:
- Argumenty pola:
field(arg: value) - Dyrektywy typu query:
field @directive
(Dyrektywy typu query to te stosowane w query po stronie klienta, w przeciwieństwie do dyrektyw typu schema, które są stosowane przez SDL -Schema Definition Language- podczas budowania schematu na serwerze. Ponieważ Gato GraphQL tworzy schemat z kodu PHP, a nie z SDL, jego dyrektywy są wszystkie typu query i są po prostu określane jako "dyrektywy".)
Na przykład, konwersja odpowiedzi pola title na wielkie litery może być osiągnięta przez przekazanie field arg format z wartością enum UPPERCASE, w ten sposób:
{
posts {
title(format: UPPERCASE)
}
}lub przez zastosowanie dyrektywy @strUpperCase na polu, w ten sposób:
{
posts {
title @strUpperCase
}
}W obu przypadkach odpowiedź z serwera GraphQL będzie taka sama:
{
"data": {
"posts": [
{
"title": "HELLO WORLD!"
},
{
"title": "FIELD ARGUMENTS VS DIRECTIVES IN GRAPHQL"
}
]
}
}Kiedy należy używać argumentów pola, a kiedy dyrektyw po stronie query? Czy istnieje jakaś różnica między tymi dwiema metodami, lub sytuacja, w której jedna opcja jest lepsza od drugiej?
Do czego służą argumenty pola i dyrektywy
Rozwiązanie pola w GraphQL obejmuje dwie różne operacje:
- pobieranie żądanych danych z zapytywanej encji
- stosowanie funkcjonalności (takich jak formatowanie) na żądanych danych
Możemy nazwać te dwie operacje "rozwiązywaniem danych" i "stosowaniem funkcjonalności", lub w skrócie odpowiednio "danymi" i "funkcjonalnościami".
Główna różnica między argumentami pola a dyrektywami polega na tym, że argumenty pola mogą być używane zarówno do "danych", jak i do "funkcjonalności", ale dyrektywy mogą być używane tylko do "funkcjonalności".
Przyjrzyjmy się nieco bardziej szczegółowo, co to oznacza.
Rozwiązywanie danych za pomocą argumentów pola
Argumenty pola są przetwarzane podczas rozwiązywania pola, dlatego mogą być używane do pobierania rzeczywistych danych, np. do decydowania, do której właściwości obiektu się odwołujemy.
Na przykład ten kod resolvera pokazuje, jak argument size jest używany do pobrania jednego lub drugiego źródła obrazu z typu obiektu Media:
function resolveValue(
object $mediaObject,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
if ($fieldDataAccessor->getFieldName() === 'src') {
$size = $fieldDataAccessor->getValue('size');
return $this->getMediaTypeAPI()->getImageSrc($mediaObject, $size);
}
// ...
}Field args mogą być również używane do pomocy w decydowaniu, który wiersz lub kolumna z tabeli bazy danych musi być zapytana.
W tej query argument pola id jest używany do zapytania o konkretną encję typu Post, którą resolver przetłumaczy na konkretny wiersz z tabeli wp_posts bazy danych WordPress:
{
post(by: { id: 1 }) {
title
}
}Ta sama tabela przechowuje datę posta w dwóch różnych kolumnach, post_modified i post_modified_gmt (ze względów zgodności wstecznej). W tej query przekazanie argumentu pola gmt z wartością true lub false przekłada się na pobranie wartości z jednej lub drugiej kolumny:
{
post(by: { id: 1 }) {
title
date(gmt: true)
}
}Te przykłady pokazują, że field args mogą modyfikować źródło danych podczas rozwiązywania pola.
Dyrektywy nie mogą być używane do modyfikowania źródła danych, ponieważ ich logika jest dostarczana przez directive resolvers, które są wywoływane po field resolverze. Dlatego w momencie, gdy dyrektywa jest stosowana, wartość pola musi już być pobrana.
Na przykład ta query nigdy nie zadziała:
{
post @selectEntity(id: 1) {
title
}
}W tym przykładzie pole post wymaga podania id encji, a ponieważ nie jest ono podane jako argument pola, serwer zwróci błąd:
{
"errors": [
{
"message": "Argument 'id' cannot be empty",
"extensions": {
"type": "QueryRoot",
"field": "post @selectEntity(id:1)"
}
}
]
}Podsumowując, tylko argumenty pola mogą pomóc w pobieraniu danych rozwiązujących pole.
Stosowanie funkcjonalności za pomocą argumentów pola lub dyrektyw
Po pobraniu danych dla pola możemy chcieć manipulować jego wartością. Na przykład moglibyśmy:
- Sformatować ciąg znaków, konwertując go na wielkie lub małe litery
- Sformatować datę reprezentowaną jako ciąg znaków, z domyślnego formatu
YYYY-mm-dddodd/mm/YYYY - Zamaskować ciąg znaków, zastępując adresy e-mail i numery telefonów znakami
*** - Podać wartość domyślną, jeśli jest
nulllub pusta - Zaokrąglić liczby zmiennoprzecinkowe do 2 cyfr
Każda z tych operacji jest manipulacją na już pobranych danych. Dlatego mogą być zakodowane zarówno w field resolverze, zaraz po pobraniu danych i przed ich zwróceniem, jak i w directive resolverze, który otrzyma wartość pola jako dane wejściowe. Tym samym każda z tych operacji może być zaimplementowana za pomocą argumentów pola lub dyrektyw.
Na przykład field resolver dla Post.excerpt mógłby dostarczyć wartość domyślną przez field arg default, a następnie możemy dostosować wartość arg default w query:
{
posts {
excerpt(default: "(No excerpt)")
}
}Możemy również stworzyć dyrektywę @default, z directive resolverem takim jak ten:
/**
* 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;
}
}
}
}Czy obie strategie są równie odpowiednie? Przeanalizujmy to pytanie na podstawie różnych obszarów zainteresowania.
Argumenty pola są lepiej objęte przez specyfikację GraphQL
Zakres, w jakim dyrektywy mogą działać, nie jest wyraźnie określony w specyfikacji GraphQL, która głosi:
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.
Ta definicja zezwala na stosowanie dyrektyw takich jak @include i @skip, które odpowiednio warunkowo włączają i pomijają pole, oraz @stream i @defer, które zapewniają inny sposób wykonania w czasie rzeczywistym dla pobierania danych z serwera.
Jednak ta definicja nie jest jednoznaczna w odniesieniu do dyrektyw modyfikujących wartość pola, takich jak @strUpperCase, która przekształca wartość wyjściową "Hello world!" w "HELLO WORLD!".
Z powodu tej niejednoznaczności różne serwery GraphQL, klienci i narzędzia mogą brać dyrektywy pod uwagę w różnym stopniu, tworząc konflikty między nimi.
Przykładem tego jest Relay, który nie bierze dyrektyw pod uwagę podczas buforowania wartości pól. Jeśli najpierw zapytamy:
{
post(by: { id: 1 }) {
title
}
}...Relay zapyta i zapisze w pamięci podręcznej wartość "Hello world!" dla posta o ID 1. Jeśli następnie uruchomimy tę query:
{
post(by: { id: 1 }) {
title @strUpperCase
}
}...odpowiedź powinna wynosić "HELLO WORLD!", jednak Relay zwróci "Hello world!", czyli wartość przechowywaną w jego pamięci podręcznej dla posta o ID 1, ignorując dyrektywę zastosowaną na polu.
To, czy dyrektywy mogą modyfikować wartość wyjściową pola, czy nie, jest kwestią sporną, ponieważ nie jest to ani wyraźnie dozwolone, ani zabronione w specyfikacji GraphQL, jednak istnieją wskazówki dla obu przeciwstawnych sytuacji.
Z jednej strony specyfikacja GraphQL wydaje się przyznawać dyrektywom pełną swobodę ulepszania i dostosowywania 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.
Z drugiej strony specyfikacja nie bierze dyrektyw pod uwagę w walidacji FieldsInSetCanMerge ani w algorytmie CollectFields. Następująca query GraphQL jest prawidłowa, jednak nie wiadomo, jaką odpowiedź otrzyma użytkownik:
{
user(by: { id: 1 }) {
name
name @strUpperCase
name @strLowerCase
}
}W zależności od zachowania serwera GraphQL, odpowiedź dla pola name może być "Leo", "LEO" lub "leo"... nie wiemy z góry, i to jest problem.
Ten sam problem nie występuje w przypadku argumentów pola. Gdy wykonywana jest następująca query:
{
user(by: { id: 1 }) {
name
name(format: UPPERCASE)
name(format: LOWERCASE)
}
}...specyfikacja nakazuje serwerowi GraphQL zwrócenie błędu, więc wartość name będzie wynosić null. Bylibyśmy wtedy zmuszeni wprowadzić aliasy, aby wykonać query:
{
user(by: { id: 1 }) {
name
ucName: name(format: UPPERCASE)
lcName: name(format: LOWERCASE)
}
}Dyrektywy są lepsze dla modularności i ponownego użycia kodu
Wiele operacji oferowanych przez dyrektywy jest niezależnych od encji i pola, na którym są stosowane. Na przykład @strUpperCase będzie działać na dowolnym ciągu znaków, niezależnie od tego, czy jest stosowany na tytule posta, imieniu użytkownika, adresie lokalizacji czy czymkolwiek innym.
W konsekwencji kod tej dyrektywy jest zaimplementowany tylko raz i w jednym miejscu — w directive resolverze. Podobnie do programowania aspektowego (które zwiększa modularność, umożliwiając separację zagadnień przekrojowych), dyrektywy są stosowane na polu bez wpływu na logikę pola.
Natomiast implementacja tej samej funkcjonalności za pomocą argumentu pola wiąże się z wykonywaniem tego samego kodu w całym field resolverze (i w różnych field resolverach):
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);
}
// ...
}Aby zmniejszyć ilość kodu w resolverach, dyrektywy są zatem bardziej odpowiednie niż argumenty pola.
Dyrektywy są lepsze dla projektowania schematu
Dodanie argumentów pola doda dodatkowe informacje do schematu, potencjalnie go przeładowując i czyniąc niespójnym.
Na przykład argument pola format będzie musiał zostać dodany do wszystkich pól String i, jeśli nie będziemy ostrożni, może nie być jednorodny między polami, używając różnych nazw, różnych wartości, różnych wartości domyślnych, lub nawet dzieląc argument na kilka wejść:
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
}Dyrektywy pozwalają nam utrzymać schemat tak zwięzłym, jak to możliwe:
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
}Dyrektywy mogą być bardziej wydajne niż argumenty pola
W czasie wykonania argument pola jest dostępny podczas rozwiązywania pola, co odbywa się pole po polu i obiekt po obiekcie. Na przykład przy rozwiązywaniu pól title i content na liście postów, resolver będzie wywoływany raz na post i pole:
function resolveValue(
object $post,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
if ($fieldDataAccessor->getFieldName() === 'title') {
return $post->post_title;
}
if ($fieldDataAccessor->getFieldName() === 'content') {
return $post->post_content;
}
// ...
}Wyobraź sobie, że chcemy przetłumaczyć te ciągi znaków za pomocą Google Translate API, dla którego dodajemy argument 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);
}
// ...
}Ponieważ logika jest naturalnie wykonywana dla każdej kombinacji pola i obiektu, możemy skończyć ze składaniem dużej liczby połączeń do zewnętrznego API, co powoduje powolną odpowiedź na rozwiązanie query.
Ponadto wykonywanie wywołań niezależnie od siebie nie pozwoli na powiązanie ich danych, więc jakość tłumaczenia będzie gorsza niż gdyby wszystkie dane zostały przesłane razem w jednym wywołaniu API.
Na przykład tytuł posta "Power" może być lepiej przetłumaczony, jeśli treść posta, która wyraźnie wskazuje, że to słowo odnosi się do "energii elektrycznej", jest przesyłana razem z nim.
Gato GraphQL wywołuje dyrektywę tylko raz, przekazując wszystkie pola i obiekty, które mają być zastosowane, jako dane wejściowe. Otrzymując wszystkie dane naraz, dyrektywa @strTranslate może wykonać jedno wywołanie do Google Translate, przesyłając wszystkie pola title i content dla wszystkich obiektów, jak w tej query:
{
posts(pagination: { limit: 6 }) {
title @strTranslate(from: "en", to: "fr")
excerpt @strTranslate(from: "en", to: "fr")
}
}Dyrektywy mogą zapewniać bardziej wydajny sposób modyfikowania wartości pól, szczególnie podczas interakcji z zewnętrznymi API.