Confronto tra argomenti di campo e direttive
La stessa funzionalità per modificare l'output di un campo in GraphQL può spesso essere ottenuta tramite due metodi diversi:
- Argomenti di campo:
field(arg: value) - Direttive di tipo query:
field @directive
(Le direttive di tipo query sono quelle applicate alla query lato client, in contrapposizione alle direttive di tipo schema, che vengono applicate tramite SDL -Schema Definition Language- durante la costruzione dello schema lato server. Poiché Gato GraphQL crea lo schema a partire da codice PHP, e non da SDL, le sue direttive sono tutte di tipo query e vengono semplicemente indicate come «direttive».)
Ad esempio, convertire la risposta di un campo title in maiuscolo potrebbe essere ottenuto passando un field arg format con un valore enum UPPERCASE, in questo modo:
{
posts {
title(format: UPPERCASE)
}
}oppure applicando una direttiva @strUpperCase sul campo, in questo modo:
{
posts {
title @strUpperCase
}
}In entrambi i casi, la risposta dal server GraphQL sarà la stessa:
{
"data": {
"posts": [
{
"title": "HELLO WORLD!"
},
{
"title": "FIELD ARGUMENTS VS DIRECTIVES IN GRAPHQL"
}
]
}
}Quando dovremmo usare gli argomenti di campo e quando le direttive lato query? C'è qualche differenza tra i due metodi, o qualche situazione in cui un'opzione è preferibile all'altra?
A cosa servono gli argomenti di campo e le direttive
Risolvere un campo in GraphQL comporta due operazioni distinte:
- recuperare i dati richiesti dall'entità interrogata
- applicare funzionalità (come la formattazione) sui dati richiesti
Possiamo chiamare queste due operazioni «risoluzione dei dati» e «applicazione delle funzionalità», o, in breve, «dati» e «funzionalità» rispettivamente.
La principale differenza tra gli argomenti di campo e le direttive è che gli argomenti di campo possono essere usati sia per i «dati» sia per le «funzionalità», mentre le direttive possono essere usate solo per le «funzionalità».
Vediamo un po' più in dettaglio cosa significa.
Risoluzione dei dati tramite gli argomenti di campo
Gli argomenti di campo vengono elaborati durante la risoluzione del campo, quindi possono essere usati per recuperare i dati effettivi, come decidere a quale proprietà dell'oggetto si accede.
Ad esempio, questo codice del resolver mostra come l'argomento size viene usato per recuperare una sorgente di immagine o un'altra dal tipo di oggetto Media:
function resolveValue(
object $mediaObject,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
if ($fieldDataAccessor->getFieldName() === 'src') {
$size = $fieldDataAccessor->getValue('size');
return $this->getMediaTypeAPI()->getImageSrc($mediaObject, $size);
}
// ...
}I field args possono anche essere usati per aiutare a decidere quale riga o colonna della tabella del database deve essere interrogata.
In questa query, l'argomento di campo id viene usato per interrogare una specifica entità di tipo Post, che il resolver tradurrà in una riga precisa della tabella wp_posts del database di WordPress:
{
post(by: { id: 1 }) {
title
}
}La stessa tabella memorizza la data dell'articolo in due colonne diverse, post_modified e post_modified_gmt (per motivi di compatibilità con le versioni precedenti). In questa query, passare l'argomento di campo gmt con true o false si traduce nel recupero del valore dall'una o dall'altra colonna:
{
post(by: { id: 1 }) {
title
date(gmt: true)
}
}Questi esempi dimostrano che i field args possono modificare la sorgente dei dati durante la risoluzione del campo.
Le direttive non possono essere usate per modificare la sorgente dei dati, perché la loro logica è fornita tramite i directive resolver, che vengono invocati dopo il field resolver. Di conseguenza, nel momento in cui la direttiva viene applicata, il valore del campo deve essere già stato recuperato.
Ad esempio, questa query non funzionerà mai:
{
post @selectEntity(id: 1) {
title
}
}In questo esempio, il campo post richiede che venga fornito l'id dell'entità, e poiché non viene fornito come argomento di campo, il server restituirà un errore:
{
"errors": [
{
"message": "Argument 'id' cannot be empty",
"extensions": {
"type": "QueryRoot",
"field": "post @selectEntity(id:1)"
}
}
]
}In conclusione, solo gli argomenti di campo possono aiutare a recuperare i dati che risolvono il campo.
Applicazione delle funzionalità tramite gli argomenti di campo o le direttive
Una volta recuperati i dati per il campo, potremmo voler manipolare il suo valore. Ad esempio, potremmo:
- Formattare una stringa, convertendola in maiuscolo o minuscolo
- Formattare una data rappresentata da una stringa, dal formato predefinito
YYYY-mm-ddal formatodd/mm/YYYY - Mascherare una stringa, sostituendo e-mail e numeri di telefono con
*** - Fornire un valore predefinito se è
nullo vuoto - Arrotondare i numeri in virgola mobile a 2 cifre
Ciascuna di queste operazioni è una manipolazione di dati già recuperati. Di conseguenza, possono essere codificate sia nel field resolver, subito dopo il recupero dei dati e prima della loro restituzione, sia nel directive resolver, che riceverà il valore del campo come input. Pertanto, ciascuna di queste operazioni può essere implementata tramite argomenti di campo o direttive.
Ad esempio, il field resolver per Post.excerpt potrebbe fornire un valore predefinito tramite un field arg default, e possiamo quindi personalizzare il valore dell'arg default nella query:
{
posts {
excerpt(default: "(No excerpt)")
}
}Possiamo anche creare una direttiva @default, con un directive resolver come questo:
/**
* 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;
}
}
}
}Queste due strategie sono ugualmente appropriate? Esploriamo questa domanda in base a diverse aree di interesse.
Gli argomenti di campo sono meglio coperti dalla specifica GraphQL
La misura in cui le direttive sono autorizzate a operare non è chiaramente definita nella specifica GraphQL, che recita:
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.
Questa definizione consente l'uso di direttive come @include e @skip, che includono e omettono condizionalmente un campo rispettivamente, e @stream e @defer, che forniscono un'esecuzione a runtime diversa per recuperare i dati dal server.
Tuttavia, questa definizione non è priva di ambiguità riguardo alle direttive che modificano il valore di un campo, come @strUpperCase, che trasforma il valore di output "Hello world!" in "HELLO WORLD!".
A causa di questa ambiguità, diversi server, client e strumenti GraphQL possono tenere conto delle direttive in misura diversa, creando conflitti tra loro.
Un esempio di ciò è Relay, che non tiene conto delle direttive per la memorizzazione nella cache dei valori dei campi. Se si interroga prima:
{
post(by: { id: 1 }) {
title
}
}...Relay interrogherà e memorizzerà nella cache il valore "Hello world!" per l'articolo con ID 1. Se poi eseguiamo questa query:
{
post(by: { id: 1 }) {
title @strUpperCase
}
}...la risposta dovrebbe essere "HELLO WORLD!", tuttavia Relay restituirà "Hello world!", che è il valore memorizzato nella sua cache per l'articolo con ID 1, ignorando la direttiva applicata sul campo.
Se le direttive siano autorizzate o meno a modificare il valore di output del campo si trova in una zona grigia, poiché non è né esplicitamente consentito né vietato nella specifica GraphQL, eppure esistono indicatori per entrambe le situazioni opposte.
Da un lato, la specifica GraphQL sembra concedere alle direttive carta bianca per migliorare e personalizzare 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.
Dall'altro lato, la specifica non tiene conto delle direttive per la validazione FieldsInSetCanMerge né per l'algoritmo CollectFields. La seguente query GraphQL è valida, eppure è incerto quale risposta otterrà l'utente:
{
user(by: { id: 1 }) {
name
name @strUpperCase
name @strLowerCase
}
}A seconda del comportamento del server GraphQL, la risposta per il campo name può essere "Leo", "LEO" o "leo"... non lo sappiamo in anticipo, e questo è un problema.
Lo stesso problema non si verifica con gli argomenti di campo. Quando viene eseguita la seguente query:
{
user(by: { id: 1 }) {
name
name(format: UPPERCASE)
name(format: LOWERCASE)
}
}...la specifica impone al server GraphQL di restituire un errore, quindi il valore di name sarà null. Saremmo allora costretti a introdurre alias per eseguire la query:
{
user(by: { id: 1 }) {
name
ucName: name(format: UPPERCASE)
lcName: name(format: LOWERCASE)
}
}Le direttive sono preferibili per la modularità e la riusabilità del codice
Molte delle operazioni offerte dalle direttive sono indipendenti dall'entità e dal campo a cui vengono applicate. Ad esempio, @strUpperCase funzionerà su qualsiasi stringa, che sia applicata sul titolo di un articolo, sul nome di un utente, sull'indirizzo di un luogo o su qualsiasi altra cosa.
Di conseguenza, il codice di questa direttiva viene implementato una sola volta e in un unico posto, il directive resolver. In modo simile alla programmazione orientata agli aspetti (che aumenta la modularità consentendo la separazione delle preoccupazioni trasversali), le direttive vengono applicate sul campo senza influenzare la logica del campo.
Al contrario, implementare la stessa funzionalità tramite un argomento di campo comporta l'esecuzione dello stesso codice attraverso il field resolver (e attraverso diversi field resolver):
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);
}
// ...
}Per ridurre la quantità di codice nei resolver, le direttive sono quindi più appropriate degli argomenti di campo.
Le direttive sono preferibili per la progettazione dello schema
L'aggiunta di argomenti di campo aggiungerà informazioni supplementari allo schema, rendendolo potenzialmente sovraccarico e incoerente.
Ad esempio, un argomento di campo format dovrà essere aggiunto a tutti i campi String e, se non facciamo attenzione, potrebbe non essere omogeneo tra i campi, usando nomi diversi, valori diversi, valori predefiniti diversi, o persino suddividendo l'argomento in più input:
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
}Le direttive ci permettono di mantenere lo schema il più snello possibile:
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
}Le direttive possono essere più efficienti degli argomenti di campo
Al momento dell'esecuzione, un argomento di campo viene letto durante la risoluzione del campo, cosa che avviene campo per campo e oggetto per oggetto. Ad esempio, durante la risoluzione dei campi title e content su una lista di articoli, il resolver verrà invocato una volta per articolo e per campo:
function resolveValue(
object $post,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
if ($fieldDataAccessor->getFieldName() === 'title') {
return $post->post_title;
}
if ($fieldDataAccessor->getFieldName() === 'content') {
return $post->post_content;
}
// ...
}Immaginiamo di voler tradurre queste stringhe usando l'API di Google Translate, per la quale aggiungiamo l'argomento 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);
}
// ...
}Poiché la logica viene eseguita naturalmente per combinazione di campo e oggetto, potremmo finire per effettuare un gran numero di connessioni all'API esterna, producendo una risposta lenta nel risolvere la query.
Inoltre, eseguire le chiamate indipendentemente l'una dall'altra non permetterà di associare i loro dati, quindi la qualità della traduzione sarà inferiore rispetto a quella ottenuta se tutti i dati venissero inviati insieme in un'unica chiamata all'API.
Ad esempio, un titolo di articolo "Power" può essere tradotto meglio se il contenuto dell'articolo, che rende evidente che questa parola si riferisce all'«energia elettrica», viene inviato insieme ad esso.
Gato GraphQL invoca una direttiva una sola volta, passando come input tutti i campi e gli oggetti a cui deve essere applicata. Ricevendo tutti i dati insieme, la direttiva @strTranslate può eseguire una singola chiamata a Google Translate trasmettendo tutti i campi title e content per tutti gli oggetti, come in questa query:
{
posts(pagination: { limit: 6 }) {
title @strTranslate(from: "en", to: "fr")
excerpt @strTranslate(from: "en", to: "fr")
}
}Le direttive possono fornire un modo più performante per modificare il valore dei campi, in particolare durante le interazioni con API esterne.