Vergelijken van veldargumenten en directives
Dezelfde functionaliteit om de uitvoer van een veld in GraphQL te wijzigen kan vaak worden bereikt via twee verschillende methoden:
- Veldargumenten:
field(arg: value) - Query-type directives:
field @directive
(Query-type directives zijn die welke worden toegepast op de query aan de client-kant, in tegenstelling tot schema-type directives, die worden toegepast via SDL -Schema Definition Language- bij het bouwen van het schema op de server. Omdat Gato GraphQL het schema opbouwt vanuit PHP-code en niet vanuit SDL, zijn alle directives van het query-type en worden ze simpelweg aangeduid als "directives".)
Zo kan het omzetten van de respons van een title-veld naar hoofdletters worden bereikt door een field arg format door te geven met een enum-waarde UPPERCASE, zoals dit:
{
posts {
title(format: UPPERCASE)
}
}of door een directive @strUpperCase toe te passen op het veld, zoals dit:
{
posts {
title @strUpperCase
}
}In beide gevallen is de respons van de GraphQL-server hetzelfde:
{
"data": {
"posts": [
{
"title": "HELLO WORLD!"
},
{
"title": "FIELD ARGUMENTS VS DIRECTIVES IN GRAPHQL"
}
]
}
}Wanneer gebruik je veldargumenten en wanneer query-side directives? Is er een verschil tussen de twee methoden, of een situatie waarin de ene optie beter is dan de andere?
Waar veldargumenten en directives goed voor zijn
Het oplossen van een veld in GraphQL omvat twee verschillende operaties:
- de gevraagde data ophalen van de bevraagde entiteit
- functionaliteit toepassen (zoals opmaak) op de gevraagde data
We kunnen deze twee operaties aanduiden als "data-oplossing" en "functionaliteit toepassen", of kortweg als respectievelijk "data" en "functionaliteit".
Het belangrijkste verschil tussen veldargumenten en directives is dat veldargumenten kunnen worden gebruikt voor zowel "data" als "functionaliteit", maar directives kunnen alleen worden gebruikt voor "functionaliteit".
Laten we wat gedetailleerder bekijken wat dit betekent.
Data ophalen via veldargumenten
Veldargumenten worden verwerkt bij het oplossen van het veld, zodat ze kunnen worden gebruikt om de feitelijke data op te halen, zoals beslissen welke eigenschap van het object wordt benaderd.
Dit resolver-code voorbeeld laat zien hoe argument size wordt gebruikt om de ene of andere afbeeldingsbron op te halen uit het objecttype Media:
function resolveValue(
object $mediaObject,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
if ($fieldDataAccessor->getFieldName() === 'src') {
$size = $fieldDataAccessor->getValue('size');
return $this->getMediaTypeAPI()->getImageSrc($mediaObject, $size);
}
// ...
}Field args kunnen ook worden gebruikt om te helpen beslissen welke rij of kolom uit de DB-tabel moet worden bevraagd.
In deze query wordt field argument id gebruikt om een specifieke entiteit van het type Post op te vragen, die de resolver zal vertalen naar een specifieke rij uit de wp_posts DB-tabel van WordPress:
{
post(by: { id: 1 }) {
title
}
}Dezelfde tabel slaat de datum van het bericht op in twee verschillende kolommen, post_modified en post_modified_gmt (vanwege achterwaartse compatibiliteitsredenen). In deze query leidt het doorgeven van field argument gmt met true of false tot het ophalen van de waarde uit de ene of de andere kolom:
{
post(by: { id: 1 }) {
title
date(gmt: true)
}
}Deze voorbeelden laten zien dat field args de databron kunnen wijzigen bij het oplossen van het veld.
Directives kunnen niet worden gebruikt om de databron te wijzigen, omdat hun logica wordt verstrekt via directive resolvers, die worden aangeroepen na de field resolver. Dus op het moment dat de directive wordt toegepast, moet de waarde van het veld al zijn opgehaald.
Deze query zal bijvoorbeeld nooit werken:
{
post @selectEntity(id: 1) {
title
}
}In dit voorbeeld vereist het veld post dat het id van de entiteit wordt opgegeven, en omdat het niet als veldargument wordt meegegeven, zal de server een fout retourneren:
{
"errors": [
{
"message": "Argument 'id' cannot be empty",
"extensions": {
"type": "QueryRoot",
"field": "post @selectEntity(id:1)"
}
}
]
}Kortom, alleen veldargumenten kunnen helpen bij het ophalen van de data die het veld oplost.
Functionaliteit toepassen via veldargumenten of directives
Zodra we de data voor het veld hebben opgehaald, willen we de waarde ervan misschien manipuleren. We zouden bijvoorbeeld het volgende kunnen doen:
- Een string opmaken door hem naar hoofd- of kleine letters te converteren
- Een datum die als string is weergegeven opmaken, van het standaard
YYYY-mm-dd-formaat naardd/mm/YYYY - Een string maskeren door e-mailadressen en telefoonnummers te vervangen door
*** - Een standaardwaarde opgeven als hij
nullof leeg is - Kommagetallen afronden tot 2 cijfers
Al deze operaties zijn manipulaties op al-opgehaalde data. Ze kunnen dan ook zowel in de field resolver worden gecodeerd, direct na het ophalen van de data en vóór het retourneren ervan, als in de directive resolver, die de waarde van het veld als invoer ontvangt. Elke van deze operaties kan dus worden geïmplementeerd via veldargumenten of directives.
De field resolver voor Post.excerpt kan bijvoorbeeld een standaardwaarde bieden via een field arg default, en dan kunnen we de waarde voor het default-arg aanpassen in de query:
{
posts {
excerpt(default: "(No excerpt)")
}
}We kunnen ook een @default-directive maken, met een directive resolver zoals deze:
/**
* 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;
}
}
}
}Zijn deze twee strategieën even geschikt? Laten we deze vraag verkennen aan de hand van verschillende aandachtsgebieden.
Veldargumenten worden beter gedekt door de GraphQL-spec
In hoeverre directives mogen opereren is niet duidelijk gedefinieerd in de GraphQL-spec, die stelt:
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.
Deze definitie staat het gebruik toe van directives zoals @include en @skip, die respectievelijk een veld voorwaardelijk opnemen en overslaan, en @stream en @defer, die een andere runtime-uitvoering bieden voor het ophalen van data van de server.
Deze definitie is echter niet eenduidig wat betreft directives die de waarde van een veld wijzigen, zoals @strUpperCase, die de uitvoerwaarde "Hello world!" omzet naar "HELLO WORLD!".
Door deze ambiguïteit kunnen verschillende GraphQL-servers, clients en tools directives in verschillende mate in aanmerking nemen, waardoor er conflicten tussen hen ontstaan.
Een voorbeeld hiervan is Relay, dat geen rekening houdt met directives bij het cachen van veldwaarden. Als je eerst de volgende query uitvoert:
{
post(by: { id: 1 }) {
title
}
}...zal Relay de waarde "Hello world!" bevragen en cachen voor de post met ID 1. Als we dan deze query uitvoeren:
{
post(by: { id: 1 }) {
title @strUpperCase
}
}...zou de respons "HELLO WORLD!" moeten zijn, maar Relay retourneert "Hello world!", de waarde die is opgeslagen in de cache voor de post met ID 1, waarbij de directive op het veld wordt genegeerd.
Of directives de uitvoerwaarde van het veld mogen wijzigen of niet, bevindt zich in een grijs gebied, omdat dit in de GraphQL-spec noch expliciet is toegestaan noch verboden, maar er zijn aanwijzingen voor beide tegengestelde situaties.
Enerzijds lijkt de GraphQL-spec directives vrij spel te geven om GraphQL te verbeteren en aan te passen:
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.
Anderzijds houdt de spec geen rekening met directives bij de FieldsInSetCanMerge-validatie of het CollectFields-algoritme. De volgende GraphQL-query is geldig, maar het is onduidelijk welke respons de gebruiker zal ontvangen:
{
user(by: { id: 1 }) {
name
name @strUpperCase
name @strLowerCase
}
}Afhankelijk van het gedrag van de GraphQL-server kan de respons voor veld name "Leo", "LEO" of "leo" zijn... we weten dit niet van tevoren, en dat is een probleem.
Hetzelfde probleem treedt niet op bij veldargumenten. Wanneer de volgende query wordt uitgevoerd:
{
user(by: { id: 1 }) {
name
name(format: UPPERCASE)
name(format: LOWERCASE)
}
}...schrijft de spec voor dat de GraphQL-server een fout retourneert, zodat de waarde voor name null zal zijn. We zouden dan worden gedwongen aliassen in te voeren om de query uit te voeren:
{
user(by: { id: 1 }) {
name
ucName: name(format: UPPERCASE)
lcName: name(format: LOWERCASE)
}
}Directives zijn beter voor modulariteit en herbruikbaarheid van code
Veel van de operaties die directives bieden, zijn agnostisch ten opzichte van de entiteit en het veld waarop ze worden toegepast. @strUpperCase werkt bijvoorbeeld op elke string, of het nu wordt toegepast op de titel van een bericht, de naam van een gebruiker, het adres van een locatie of iets anders.
Als gevolg hiervan wordt de code voor deze directive slechts één keer en op één plaats geïmplementeerd: in de directive resolver. Vergelijkbaar met aspect-georiënteerd programmeren (dat de modulariteit vergroot door de scheiding van dwarsdoorsnedenbelangen mogelijk te maken), worden directives op het veld toegepast zonder de logica van het veld te beïnvloeden.
Het implementeren van dezelfde functionaliteit via een veldargument houdt daarentegen in dat dezelfde code in de field resolver (en in verschillende field resolvers) wordt uitgevoerd:
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);
}
// ...
}Om de hoeveelheid code in de resolvers te verminderen, zijn directives dus geschikter dan veldargumenten.
Directives zijn beter voor schemaontwerp
Het toevoegen van veldargumenten voegt extra informatie toe aan het schema, wat het mogelijk omvangrijker en inconsistent maakt.
Een field argument format moet bijvoorbeeld worden toegevoegd aan alle String-velden en, als we niet voorzichtig zijn, is het mogelijk niet homogeen over velden heen, met gebruik van verschillende namen, verschillende waarden, verschillende standaardwaarden, of zelfs het splitsen van het argument in meerdere invoervelden:
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
}Directives stellen ons in staat het schema zo compact mogelijk te houden:
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
}Directives kunnen efficiënter zijn dan veldargumenten
Tijdens uitvoeringstijd wordt een veldargument benaderd bij het oplossen van het veld, wat veld voor veld en object voor object gebeurt. Bij het oplossen van de velden title en content op een lijst van berichten wordt de resolver bijvoorbeeld één keer per bericht en veld aangeroepen:
function resolveValue(
object $post,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
if ($fieldDataAccessor->getFieldName() === 'title') {
return $post->post_title;
}
if ($fieldDataAccessor->getFieldName() === 'content') {
return $post->post_content;
}
// ...
}Stel je voor dat we deze strings willen vertalen met de Google Translate API, waarvoor we argument translateTo toevoegen:
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);
}
// ...
}Omdat de logica van nature wordt uitgevoerd per combinatie van veld en object, kunnen we uiteindelijk een groot aantal verbindingen met de externe API aanvragen, wat een trage respons oplevert bij het oplossen van de query.
Bovendien zal het onafhankelijk van elkaar uitvoeren van de aanroepen het niet mogelijk maken hun data te koppelen, waardoor de kwaliteit van de vertaling slechter zal zijn dan wanneer alle data samen in één API-aanroep worden ingediend.
Een berichttitel "Power" kan bijvoorbeeld beter worden vertaald als de berichtinhoud, die duidelijk maakt dat dit woord verwijst naar "elektrisch vermogen", er samen mee wordt ingediend.
Gato GraphQL roept een directive slechts één keer aan en geeft alle velden en objecten waarop de directive moet worden toegepast als invoer mee. Door alle data tegelijk te ontvangen, kan de @strTranslate-directive één aanroep naar Google Translate uitvoeren en alle title- en content-velden voor alle objecten meegeven, zoals in deze query:
{
posts(pagination: { limit: 6 }) {
title @strTranslate(from: "en", to: "fr")
excerpt @strTranslate(from: "en", to: "fr")
}
}Directives kunnen een meer performante manier bieden om de waarde van velden te wijzigen, bijvoorbeeld bij interactie met externe API's.