Comparer les arguments de champ et les directives
La même fonctionnalité pour modifier la sortie d'un champ dans GraphQL peut souvent être réalisée via deux méthodes différentes :
- Arguments de champ :
field(arg: value) - Directives de type requête :
field @directive
(Les directives de type requête sont celles appliquées à la requête côté client, par opposition aux directives de type schéma, qui sont appliquées via SDL -Schema Definition Language- lors de la construction du schéma côté serveur. Comme Gato GraphQL crée le schéma à partir de code PHP, et non de SDL, ses directives sont toutes de type requête et sont simplement désignées comme « directives ».)
Par exemple, convertir la réponse d'un champ title en majuscules pourrait être réalisé en passant un field arg format avec une valeur enum UPPERCASE, comme ceci :
{
posts {
title(format: UPPERCASE)
}
}ou en appliquant une directive @strUpperCase sur le champ, comme ceci :
{
posts {
title @strUpperCase
}
}Dans les deux cas, la réponse du serveur GraphQL sera la même :
{
"data": {
"posts": [
{
"title": "HELLO WORLD!"
},
{
"title": "FIELD ARGUMENTS VS DIRECTIVES IN GRAPHQL"
}
]
}
}Quand faut-il utiliser les arguments de champ et quand les directives côté requête ? Y a-t-il une différence entre les deux méthodes, ou une situation où l'une est préférable à l'autre ?
À quoi servent les arguments de champ et les directives
Résoudre un champ dans GraphQL implique deux opérations distinctes :
- récupérer les données demandées depuis l'entité interrogée
- appliquer des fonctionnalités (comme le formatage) sur les données demandées
Nous pouvons nommer ces deux opérations « résolution des données » et « application des fonctionnalités », ou, en abrégé, « données » et « fonctionnalités » respectivement.
La principale différence entre les arguments de champ et les directives est que les arguments de champ peuvent être utilisés à la fois pour les « données » et les « fonctionnalités », mais les directives ne peuvent être utilisées que pour les « fonctionnalités ».
Voyons plus en détail ce que cela signifie.
Résolution des données via les arguments de champ
Les arguments de champ sont traités lors de la résolution du champ, ils peuvent donc être utilisés pour récupérer les données réelles, comme décider quelle propriété de l'objet est accédée.
Par exemple, ce code de résolveur montre comment l'argument size est utilisé pour récupérer une source d'image ou une autre depuis le type d'objet Media :
function resolveValue(
object $mediaObject,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
if ($fieldDataAccessor->getFieldName() === 'src') {
$size = $fieldDataAccessor->getValue('size');
return $this->getMediaTypeAPI()->getImageSrc($mediaObject, $size);
}
// ...
}Les field args peuvent également être utilisés pour aider à décider quelle ligne ou colonne de la table de la base de données doit être interrogée.
Dans cette requête, l'argument de champ id est utilisé pour interroger une entité spécifique de type Post, que le résolveur traduira en une ligne précise de la table wp_posts de la base de données WordPress :
{
post(by: { id: 1 }) {
title
}
}La même table stocke la date de l'article dans deux colonnes différentes, post_modified et post_modified_gmt (pour des raisons de compatibilité ascendante). Dans cette requête, passer l'argument de champ gmt avec true ou false se traduit par la récupération de la valeur dans l'une ou l'autre colonne :
{
post(by: { id: 1 }) {
title
date(gmt: true)
}
}Ces exemples démontrent que les field args peuvent modifier la source des données lors de la résolution du champ.
Les directives ne peuvent pas être utilisées pour modifier la source des données, car leur logique est fournie via des directive resolvers, qui sont invoqués après le field resolver. Par conséquent, au moment où la directive est appliquée, la valeur du champ doit déjà avoir été récupérée.
Par exemple, cette requête ne fonctionnera jamais :
{
post @selectEntity(id: 1) {
title
}
}Dans cet exemple, le champ post requiert que l'id de l'entité soit fourni, et comme il n'est pas fourni en tant qu'argument de champ, le serveur retournera une erreur :
{
"errors": [
{
"message": "Argument 'id' cannot be empty",
"extensions": {
"type": "QueryRoot",
"field": "post @selectEntity(id:1)"
}
}
]
}En conclusion, seuls les arguments de champ peuvent aider à récupérer les données qui résolvent le champ.
Application de fonctionnalités via les arguments de champ ou les directives
Une fois que nous récupérons les données pour le champ, nous pouvons vouloir manipuler sa valeur. Par exemple, nous pourrions :
- Formater une chaîne, la convertir en majuscules ou minuscules
- Formater une date représentée par une chaîne, du format par défaut
YYYY-mm-ddversdd/mm/YYYY - Masquer une chaîne, en remplaçant les e-mails et numéros de téléphone par
*** - Fournir une valeur par défaut si elle est
nullou vide - Arrondir les nombres flottants à 2 chiffres
Chacune de ces opérations est une manipulation de données déjà récupérées. Par conséquent, elles peuvent être codées à la fois dans le field resolver, juste après la récupération des données et avant leur retour, ou dans le directive resolver, qui recevra la valeur du champ en entrée. Ainsi, chacune de ces opérations peut être implémentée via des arguments de champ ou des directives.
Par exemple, le field resolver pour Post.excerpt pourrait fournir une valeur par défaut via un field arg default, et nous pouvons alors personnaliser la valeur de l'arg default dans la requête :
{
posts {
excerpt(default: "(No excerpt)")
}
}Nous pouvons également créer une directive @default, avec un directive resolver comme celui-ci :
/**
* 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;
}
}
}
}Ces deux stratégies sont-elles également appropriées ? Explorons cette question selon différents domaines d'intérêt.
Les arguments de champ sont mieux couverts par la spécification GraphQL
L'étendue dans laquelle les directives sont autorisées à opérer n'est pas clairement définie dans la spécification GraphQL, qui stipule :
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.
Cette définition consente l'utilisation de directives telles que @include et @skip, qui incluent et omettent conditionnellement un champ respectivement, et @stream et @defer, qui fournissent une exécution à l'exécution différente pour récupérer des données depuis le serveur.
Cependant, cette définition n'est pas sans ambiguïté concernant les directives qui modifient la valeur d'un champ, comme @strUpperCase, qui transforme la valeur de sortie "Hello world!" en "HELLO WORLD!".
En raison de cette ambiguïté, différents serveurs, clients et outils GraphQL peuvent prendre les directives en compte à des degrés différents, créant des conflits entre eux.
Un exemple de cela est Relay, qui ne prend pas les directives en compte pour la mise en cache des valeurs de champ. Si l'on interroge d'abord :
{
post(by: { id: 1 }) {
title
}
}...Relay interrogera et mettra en cache la valeur "Hello world!" pour l'article avec l'ID 1. Si l'on exécute ensuite cette requête :
{
post(by: { id: 1 }) {
title @strUpperCase
}
}...la réponse devrait être "HELLO WORLD!", cependant Relay retournera "Hello world!", qui est la valeur stockée dans son cache pour l'article avec l'ID 1, ignorant la directive appliquée sur le champ.
Le fait que les directives soient autorisées ou non à modifier la valeur de sortie du champ se trouve dans une zone grise, puisque cela n'est ni explicitement autorisé ni interdit dans la spécification GraphQL, mais il existe des indicateurs pour les deux situations opposées.
D'un côté, la spécification GraphQL semble accorder aux directives carte blanche pour améliorer et personnaliser 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.
D'un autre côté, la spécification ne prend pas les directives en compte pour la validation FieldsInSetCanMerge ni pour l'algorithme CollectFields. La requête GraphQL suivante est valide, pourtant il est incertain quelle réponse l'utilisateur obtiendra :
{
user(by: { id: 1 }) {
name
name @strUpperCase
name @strLowerCase
}
}Selon le comportement du serveur GraphQL, la réponse pour le champ name peut être "Leo", "LEO" ou "leo"... nous ne le savons pas à l'avance, et c'est un problème.
Le même problème ne se produit pas avec les arguments de champ. Lorsque la requête suivante est exécutée :
{
user(by: { id: 1 }) {
name
name(format: UPPERCASE)
name(format: LOWERCASE)
}
}...la spécification demande au serveur GraphQL de retourner une erreur, donc la valeur de name sera null. Nous serions alors contraints d'introduire des alias pour exécuter la requête :
{
user(by: { id: 1 }) {
name
ucName: name(format: UPPERCASE)
lcName: name(format: LOWERCASE)
}
}Les directives sont préférables pour la modularité et la réutilisabilité du code
Beaucoup des opérations offertes par les directives sont indépendantes de l'entité et du champ sur lequel elles sont appliquées. Par exemple, @strUpperCase fonctionnera sur n'importe quelle chaîne, qu'elle soit appliquée sur le titre d'un article, le nom d'un utilisateur, l'adresse d'un lieu ou autre chose.
En conséquence, le code de cette directive est implémenté une seule fois et en un seul endroit, le directive resolver. Similaire à la programmation orientée aspect (qui augmente la modularité en permettant la séparation des préoccupations transversales), les directives sont appliquées sur le champ sans affecter la logique du champ.
En revanche, implémenter la même fonctionnalité via un argument de champ implique d'exécuter le même code à travers le field resolver (et à travers différents 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);
}
// ...
}Pour réduire la quantité de code dans les résolveurs, les directives sont donc plus appropriées que les arguments de champ.
Les directives sont préférables pour la conception du schéma
L'ajout d'arguments de champ ajoutera des informations supplémentaires au schéma, le rendant potentiellement surchargé et incohérent.
Par exemple, un argument de champ format devra être ajouté à tous les champs String et, si nous ne faisons pas attention, il peut ne pas être homogène entre les champs, en utilisant des noms différents, des valeurs différentes, des valeurs par défaut différentes, ou même en divisant l'argument en plusieurs entrées :
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
}Les directives nous permettent de garder le schéma aussi épuré que possible :
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
}Les directives peuvent être plus efficaces que les arguments de champ
Au moment de l'exécution, un argument de champ sera accédé lors de la résolution du champ, ce qui se produit champ par champ et objet par objet. Par exemple, lors de la résolution des champs title et content sur une liste d'articles, le résolveur sera invoqué une fois par article et par champ :
function resolveValue(
object $post,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
if ($fieldDataAccessor->getFieldName() === 'title') {
return $post->post_title;
}
if ($fieldDataAccessor->getFieldName() === 'content') {
return $post->post_content;
}
// ...
}Imaginez que nous voulions traduire ces chaînes en utilisant l'API Google Translate, pour laquelle nous ajoutons l'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);
}
// ...
}Parce que la logique est naturellement exécutée par combinaison de champ et d'objet, nous pouvons finir par effectuer un grand nombre de connexions à l'API externe, produisant une réponse lente pour résoudre la requête.
De plus, exécuter les appels indépendamment les uns des autres ne permettra pas d'associer leurs données, de sorte que la qualité de la traduction sera inférieure à celle obtenue si toutes les données étaient soumises ensemble en un seul appel à l'API.
Par exemple, un titre d'article "Power" peut être mieux traduit si le contenu de l'article, qui rend évident que ce mot fait référence à « l'énergie électrique », est soumis avec lui.
Gato GraphQL invoque une directive une seule fois, en passant tous les champs et objets auxquels elle doit être appliquée en entrée. En recevant toutes les données ensemble, la directive @strTranslate peut exécuter un seul appel à Google Translate en transmettant tous les champs title et content pour tous les objets, comme dans cette requête :
{
posts(pagination: { limit: 6 }) {
title @strTranslate(from: "en", to: "fr")
excerpt @strTranslate(from: "en", to: "fr")
}
}Les directives peuvent fournir un moyen plus performant de modifier la valeur des champs, notamment lors d'interactions avec des API externes.