Konzepte, Ideen, Strategien
Konzepte, Ideen, StrategienVergleich von Feldargumenten und Direktiven

Vergleich von Feldargumenten und Direktiven

Die gleiche Funktionalität zum Ändern der Ausgabe eines Feldes in GraphQL lässt sich oft über zwei verschiedene Methoden erreichen:

  1. Feldargumente: field(arg: value)
  2. Query-Typ-Direktiven: field @directive

(Query-Typ-Direktiven sind solche, die clientseitig auf die Query angewendet werden, im Gegensatz zu Schema-Typ-Direktiven, die beim Aufbau des Schemas auf dem Server über SDL -Schema Definition Language- angewendet werden. Da Gato GraphQL das Schema aus PHP-Code und nicht aus SDL erstellt, sind alle seine Direktiven vom Query-Typ und werden einfach als „Direktiven" bezeichnet.)

Beispielsweise könnte die Antwort eines title-Feldes in Großbuchstaben umgewandelt werden, indem ein Field-Arg format mit dem Enum-Wert UPPERCASE übergeben wird, etwa so:

{
  posts {
    title(format: UPPERCASE)
  }
}

oder indem eine Direktive @strUpperCase auf das Feld angewendet wird, etwa so:

{
  posts {
    title @strUpperCase
  }
}

In beiden Fällen ist die Antwort vom GraphQL-Server dieselbe:

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

Wann sollten wir Feldargumente und wann query-seitige Direktiven verwenden? Gibt es einen Unterschied zwischen den beiden Methoden oder eine Situation, in der eine Option besser geeignet ist als die andere?

Wofür Feldargumente und Direktiven geeignet sind

Ein Feld in GraphQL aufzulösen beinhaltet zwei unterschiedliche Operationen:

  1. die angeforderten Daten aus der abgefragten Entität abrufen
  2. Funktionalität (wie Formatierung) auf die angeforderten Daten anwenden

Wir können diese beiden Operationen als „Datenauflösung" und „Funktionalitätsanwendung" bezeichnen, oder kurz als „Daten" bzw. „Funktionalität".

Der wesentliche Unterschied zwischen Feldargumenten und Direktiven besteht darin, dass Feldargumente sowohl für „Daten" als auch für „Funktionalität" verwendet werden können, während Direktiven nur für „Funktionalität" eingesetzt werden können.

Schauen wir uns etwas genauer an, was das bedeutet.

Daten über Feldargumente auflösen

Feldargumente werden beim Auflösen des Feldes verarbeitet, daher können sie verwendet werden, um die eigentlichen Daten abzurufen, beispielsweise um zu entscheiden, auf welche Eigenschaft des Objekts zugegriffen wird.

Dieses Resolver-Code-Beispiel zeigt zum Beispiel, wie das Argument size verwendet wird, um eine oder eine andere Bildquelle aus dem Media-Objekttyp abzurufen:

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

Field-Args können auch dabei helfen zu entscheiden, welche Zeile oder Spalte der Datenbanktabelle abgefragt werden soll.

In dieser Query wird das Feldargument id verwendet, um eine bestimmte Entität vom Typ Post abzufragen, die der Resolver in eine bestimmte Zeile der WordPress-Datenbanktabelle wp_posts übersetzt:

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

Dieselbe Tabelle speichert das Datum des Beitrags in zwei verschiedenen Spalten, post_modified und post_modified_gmt (aus Gründen der Abwärtskompatibilität). In dieser Query führt das Übergeben des Feldarguments gmt mit true oder false dazu, dass der Wert aus der einen oder anderen Spalte abgerufen wird:

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

Diese Beispiele zeigen, dass Field-Args die Datenquelle beim Auflösen des Feldes ändern können.

Direktiven können nicht zur Änderung der Datenquelle verwendet werden, da ihre Logik über Directive-Resolver bereitgestellt wird, die nach dem Field-Resolver aufgerufen werden. Daher muss der Wert des Feldes bereits abgerufen worden sein, wenn die Direktive angewendet wird.

Diese Query wird zum Beispiel nie funktionieren:

{
  post @selectEntity(id: 1) {
    title
  }
}

In diesem Beispiel erfordert das Feld post, dass die id der Entität angegeben wird. Da sie nicht als Feldargument übergeben wird, gibt der Server einen Fehler zurück:

{
  "errors": [
    {
      "message": "Argument 'id' cannot be empty",
      "extensions": {
        "type": "QueryRoot",
        "field": "post @selectEntity(id:1)"
      }
    }
  ]
}

Zusammenfassend lässt sich sagen: Nur Feldargumente können dabei helfen, die Daten abzurufen, die ein Feld auflösen.

Funktionalität über Feldargumente oder Direktiven anwenden

Sobald wir die Daten für ein Feld abgerufen haben, möchten wir möglicherweise dessen Wert manipulieren. Wir könnten zum Beispiel:

  • Einen String formatieren und ihn in Groß- oder Kleinbuchstaben umwandeln
  • Ein als String dargestelltes Datum vom Standardformat YYYY-mm-dd in dd/mm/YYYY umformatieren
  • Einen String maskieren und E-Mails und Telefonnummern durch *** ersetzen
  • Einen Standardwert angeben, wenn er null oder leer ist
  • Fließkommazahlen auf 2 Stellen runden

Jede dieser Operationen ist eine Manipulation der bereits abgerufenen Daten. Daher können sie sowohl im Field-Resolver, direkt nach dem Abrufen der Daten und vor deren Rückgabe, als auch im Directive-Resolver kodiert werden, der den Wert des Feldes als Eingabe erhält. Somit kann jede dieser Operationen entweder über Feldargumente oder über Direktiven implementiert werden.

Der Field-Resolver für Post.excerpt könnte beispielsweise einen Standardwert über ein Field-Arg default bereitstellen, und wir können den Wert des Args default in der Query anpassen:

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

Wir können auch eine @default-Direktive erstellen, mit einem Directive-Resolver wie diesem:

/**
 * 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;
      }
    }
  }
}

Sind diese beiden Strategien gleichermaßen geeignet? Untersuchen wir diese Frage anhand verschiedener Interessensbereiche.

Feldargumente sind besser durch die GraphQL-Spezifikation abgedeckt

In welchem Umfang Direktiven operieren dürfen, ist in der GraphQL-Spezifikation nicht klar definiert, die besagt:

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.

Diese Definition erlaubt die Verwendung von Direktiven wie @include und @skip, die ein Feld bedingt einschließen bzw. überspringen, sowie @stream und @defer, die eine andere Runtime-Ausführung zum Abrufen von Daten vom Server bieten.

Diese Definition ist jedoch nicht eindeutig bezüglich Direktiven, die den Wert eines Feldes ändern, wie @strUpperCase, das den Ausgabewert "Hello world!" in "HELLO WORLD!" transformiert.

Aufgrund dieser Mehrdeutigkeit können verschiedene GraphQL-Server, -Clients und -Tools Direktiven in unterschiedlichem Ausmaß berücksichtigen, was zu Konflikten zwischen ihnen führt.

Ein Beispiel dafür ist Relay, das Direktiven beim Caching von Feldwerten nicht berücksichtigt. Wenn zuerst folgendes abgefragt wird:

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

...wird Relay den Wert "Hello world!" für den Beitrag mit ID 1 abfragen und cachen. Wenn wir dann diese Query ausführen:

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

...sollte die Antwort "HELLO WORLD!" sein, jedoch gibt Relay "Hello world!" zurück, also den in seinem Cache gespeicherten Wert für den Beitrag mit ID 1, wobei die auf das Feld angewendete Direktive ignoriert wird.

Ob Direktiven den Ausgabewert eines Feldes ändern dürfen oder nicht, befindet sich in einer Grauzone, da es in der GraphQL-Spezifikation weder ausdrücklich erlaubt noch verboten ist, aber es gibt Hinweise für beide entgegengesetzten Situationen.

Einerseits scheint die GraphQL-Spezifikation Direktiven freie Hand zu lassen, GraphQL zu verbessern und anzupassen:

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.

Andererseits berücksichtigt die Spezifikation Direktiven weder bei der FieldsInSetCanMerge-Validierung noch beim CollectFields-Algorithmus. Die folgende GraphQL-Query ist gültig, dennoch ist unklar, welche Antwort der Benutzer erhalten wird:

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

Je nach Verhalten des GraphQL-Servers kann die Antwort für das Feld name "Leo", "LEO" oder "leo" sein... wir wissen es nicht im Voraus, und das ist ein Problem.

Dasselbe Problem tritt bei Feldargumenten nicht auf. Wenn die folgende Query ausgeführt wird:

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

...schreibt die Spezifikation dem GraphQL-Server vor, einen Fehler zurückzugeben, sodass der Wert von name null ist. Wir wären dann gezwungen, Aliase einzuführen, um die Query auszuführen:

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

Direktiven sind besser für Modularität und Code-Wiederverwendbarkeit

Viele der von Direktiven angebotenen Operationen sind unabhängig von der Entität und dem Feld, auf das sie angewendet werden. @strUpperCase funktioniert zum Beispiel auf jedem String, egal ob es auf den Titel eines Beitrags, den Namen eines Benutzers, die Adresse eines Ortes oder irgendetwas anderes angewendet wird.

Daher wird der Code für diese Direktive nur einmal und an einer einzigen Stelle implementiert, dem Directive-Resolver. Ähnlich wie bei der aspektorientierten Programmierung (die die Modularität erhöht, indem sie die Trennung übergreifender Belange ermöglicht) werden Direktiven auf das Feld angewendet, ohne die Logik des Feldes zu beeinflussen.

Im Gegensatz dazu erfordert die Implementierung derselben Funktionalität über ein Feldargument die Ausführung desselben Codes über den Field-Resolver (und über verschiedene Field-Resolver) hinweg:

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

Um die Codemenge in den Resolvern zu reduzieren, sind Direktiven daher besser geeignet als Feldargumente.

Direktiven sind besser für das Schema-Design

Das Hinzufügen von Feldargumenten fügt dem Schema zusätzliche Informationen hinzu und macht es möglicherweise aufgebläht und inkonsistent.

Ein Feldargument format müsste beispielsweise allen String-Feldern hinzugefügt werden, und wenn wir nicht aufpassen, ist es möglicherweise nicht homogen über die Felder hinweg – es könnten unterschiedliche Namen, unterschiedliche Werte, unterschiedliche Standardwerte oder sogar eine Aufteilung des Arguments in mehrere Eingaben verwendet werden:

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
}

Direktiven erlauben es uns, das Schema so schlank wie möglich zu halten:

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
}

Direktiven können effizienter sein als Feldargumente

Zur Ausführungszeit wird ein Feldargument beim Auflösen des Feldes verarbeitet, was Feld für Feld und Objekt für Objekt geschieht. Beim Auflösen der Felder title und content in einer Liste von Beiträgen wird der Resolver zum Beispiel einmal pro Beitrag und Feld aufgerufen:

function resolveValue(
  object $post,
  FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
  if ($fieldDataAccessor->getFieldName() === 'title') {
    return $post->post_title;
  }
  if ($fieldDataAccessor->getFieldName() === 'content') {
    return $post->post_content;
  }
  // ...
}

Stellen wir uns vor, wir möchten diese Strings mit der Google Translate API übersetzen, wofür wir das Argument translateTo hinzufügen:

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

Da die Logik natürlicherweise pro Kombination aus Feld und Objekt ausgeführt wird, kann es passieren, dass wir eine große Anzahl von Verbindungen zur externen API herstellen, was zu einer langsamen Antwort beim Auflösen der Query führt.

Außerdem erlaubt die unabhängige Ausführung der Aufrufe keine Verknüpfung ihrer Daten, sodass die Übersetzungsqualität schlechter ist, als wenn alle Daten zusammen in einem einzigen API-Aufruf übermittelt würden.

Ein Beitragstitel "Power" lässt sich zum Beispiel besser übersetzen, wenn der Beitragsinhalt, der deutlich macht, dass dieses Wort sich auf „elektrische Energie" bezieht, gemeinsam damit übermittelt wird.

Gato GraphQL ruft eine Direktive nur einmal auf und übergibt alle Felder und Objekte, auf die sie angewendet werden soll, als Eingabe. Da alle Daten auf einmal empfangen werden, kann die @strTranslate-Direktive einen einzigen Aufruf an Google Translate ausführen und dabei alle title- und content-Felder für alle Objekte übermitteln, wie in dieser Query:

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

Direktiven können eine leistungsfähigere Möglichkeit bieten, den Wert von Feldern zu ändern, insbesondere bei der Interaktion mit externen APIs.