Avocoado Logo

Benutzerdefinierte Direktiven in GraphQL

author image

Louis Kuhnt, 30. September 2024

Lesezeit: 20 Minuten

Mit der Einführung von GraphQL und dem avocadoQL-Leitfaden in vielen Projekten bei avocado software engineering hat sich GraphQL als Alternative zu REST rasant verbreitet. Immer mehr Projekte setzen auf eine GraphQL-First-Architektur, die das Ziel verfolgt, eine optimierte Datenabfrage und -manipulation zu gewährleisten. Durch den vermehrten Einsatz von GraphQL, kommen auch spezifische Probleme auf, welche bei einer handelsüblichen Rest(-ful) Implementierung entweder schon gelöst oder gar nicht erst auftreten. So zum Beispiel bei der Datenmanipulation während der Laufzeit.

Ein häufig auftretendes Problem ist die Abweichung von Server- und Client-Datenstrukturen, was oft bei der Verwendung von Enums oder verschachtelten Enums ("nested Enums") vorkommt. Um solche Herausforderungen zu lösen, greifen Entwickler auf benutzerdefinierte Direktiven zurück, die direkt in die GraphQL-Laufzeit eingreifen und so Daten manipulieren oder Zugriffe authentifizieren.

In diesem Beitrag zeige ich, wie man eine benutzerdefinierte Direktive implementiert und welche Konzepte dabei wichtig sind. Ich setze voraus, dass du mit der GraphQL-Laufzeit und deren Manipulationsmöglichkeiten vertraut bist. Solltest du noch ein tieferes Verständnis benötigen, empfehle ich die Blog-Beiträge Web-APIs mit GraphQL und Strapi-Content mit GraphQL einbinden, die als Einstieg hilfreich sind.

Problemstellung

Ein häufiges Szenario in Anwendungen ist die Lokalisierung von Inhalten, insbesondere bei der Anzeige von Daten in verschiedenen Sprachen. Die Frage, die sich häufig stellt: Wo sollte die Übersetzungslogik implementiert werden – in der Server- oder Client-Anwendung? Sollen die angezeigten Models auf dem Server-System übersetzt werden, wobei mit einem normalen Rest-API Zugriff das komplette Model neu gezogen werden muss, wenn eine Sprachänderung im Client vorgenommen wird oder im Client selbst? Zwar ermöglicht die Übersetzung auf Client-Seite eine schnelle Sprachänderung, jedoch kann dies das Frontend durch die zusätzliche sprachliche Komponente unnötig komplex machen und potenziell zu Performance-Einbußen führen.

Hier kommt GraphQL ins Spiel. Mit GraphQL lässt sich das Problem eleganter lösen: Statt das komplette Model zu übertragen, kann die Übersetzung direkt bei der Datenabfrage erfolgen, und nur der relevante Wert wird an das Frontend gesendet. Das Ziel ist es, die Übersetzungslogik auf den Server auszulagern, sodass die Entwickler sich nicht mehr um die Sprachverwaltung kümmern müssen.

Konzept

Ausgangslage

In vielen Projekten benötigt jedes Model eine Bezeichnung oder einen Namen, die je nach Sprache variieren. Um diese Herausforderung zu meistern, haben Benutzer in unserem Projekt die Möglichkeit, den Namen/Bezeichnungen für verschiedene Sprachen selbst zu definieren. Dies geschieht über ein MultiLanguageText-Model.

public class MultiLanguageText implements Serializable {

    private Map<String, String> values = new HashMap<>();

 } 

In einer typischen REST-API gibt das Backend das MultiLanguageText-Objekt an den Client, wo dann die Aufgabe darin besteht, den richtigen Text auszuwählen und anzuzeigen. In Single-Page-Applications (SPA), wie sie häufig mit Angular realisiert werden, bringt dies jedoch Performanceprobleme mit sich. Durch die kontinuierliche Change Detection wird das User-Interface ständig neu gerendert, wodurch auch die Lokalisierungslogik aus dem MultiLanguageText-Model wiederholt ausgelöst wird. Das wiederum wirkt sich negativ auf die Performance aus.

Lösungsansatz

Um dieses Problem zu umgehen, ändern wir den Architektur-Ansatz leicht ab: Statt das komplette MultiLanguageText-Objekt an das Frontend zu geben, fangen wir die Daten direkt in der GraphQL-Laufzeit ab und übergeben an das Frontend nur noch den übersetzten String.

/uploads/graphql_directives_grafik_492de7ab27.png

Leider erlaubt es GraphQL nicht, den Datentyp eines bestehenden Feldes zur Laufzeit zu ändern. Ein Feld vom Typ Object kann also nicht einfach in einen String umgewandelt werden. Um dies zu umgehen, fügen wir ein neues Feld hinzu, das als Platzhalter für den übersetzten Wert dient.

Implementierung

Die Implementierung teilt sich in zwei Bereiche: Schema-Anpassungen und die programmatische Umsetzung der Direktive.

Schema Anpassungen

Im GraphQL-Schema wird die Direktive durch das Schlüsselwort directive definiert und auf Felder angewendet, die bei der Datenabfrage übersetzt werden sollen.

"""
Directive for current language transformation - primary for MultilanguageObject
Input should be the field for the transformation.
Output is the value in the field where the directive is defined
""" 
directive @currentLanguage(field: String) on FIELD_DEFINITION 
  • Die Direktive wird mit einem Camel-Case-Namen versehen, mit dem Prefix @, z.B. @currentLanguage.
  • Argumente können der Direktive optional mitgegeben werden, z.B. der Name des zu übersetzenden Feldes.
  • Die Direktive wird auf FIELD_DEFINITION angewendet, das bedeutet, sie wirkt auf Felder, die an den Client zurückgegeben werden. Als Gegenteil dazu gibt es noch INPUT_FIELD_DEFINITION, welche wiederum auf Felder in Input-Typen anwendbar sind und somit zum Server führen. Es gibt noch weitere Definitionen, welche eine Direktive annehmen kann, aber FIELD_DEFINITION und INPUT_FIELD_DEFINITION kommen am häufigsten vor.

Durch die oben genannte Schwierigkeit den Datentyp eines Feldes im Schema dynamisch zu ändern, haben wir ein neues Feld eingefügt. Dieses gilt als Platzhalter für den übersetzten Wert aus dem vom Server übergebenen Multi-Language-Objekt. So kommen wir zu einer Implementierung auf der Schema-Ebene wie folgt:

type Article {
    id: Int "MultiLanguageText"     
    name: Object     
    currentName: String @currentLanguage(field: "name")
}

Die Direktive liegt auf dem neuen Feld, sodass sie zusammen mit dem Übersetzungs-Workflow erst aktiv wird, wenn es vom Client-System angefragt wird. Als Argument der Direktive wird dann der Name des zu übersetzenden Feldes mitgegeben. In unserer hauseigenen Implementierung ist es nicht zu vernachlässigen, dass hier ein MultiLanguageText-Objekt verlangt wird.

Damit ist die Schema Umsetzung abgeschlossen und wir können den Fokus auf die programmatische Umsetzung der Direktive in der GraphQL-Laufzeit angehen.

Umsetzung der Direktive

Die eigentliche Logik der Direktive wird in der Java-Klasse CurrentLanguage implementiert. Diese Klasse erbt von SchemaDirectiveWiring, was bedeutet, dass sie in der Lage ist, Änderungen an den Schemaelementen vorzunehmen.

Zuerst wird die Klasse durch die Annotation @DgsDirective registriert, sodass sie mit der Direktive im Schema verknüpft wird:

@DgsDirective(name = "currentLanguage")
public class CurrentLanguage implements SchemaDirectiveWiring {
     @Override
     public GraphQLFieldDefinition onField(SchemaDirectiveWiringEnvironment<GraphQLFieldDefinition> env) {
         // Logik hier...
     }
 }

Die Methode onField wird aufgerufen, wenn das Schema ein Feld findet, das mit der Direktive annotiert ist. Innerhalb dieser Methode wird der Zugriff auf die Felddefinition hergestellt und man kann die Logik zur Manipulation der Daten implementieren.

Extrahieren des ursprünglichen DataFetchers

In GraphQL ist ein DataFetcher dafür verantwortlich, die Daten für ein bestimmtes Feld abzurufen. Der erste Schritt besteht darin, den ursprünglichen DataFetcher zu extrahieren, um später auf die Originaldaten zugreifen zu können.

GraphQLFieldsContainer fieldsContainer = env.getFieldsContainer();
GraphQLFieldDefinition fieldDefinition = env.getFieldDefinition();
GraphQLAppliedDirective fieldDirective = env.getAppliedDirective();

fieldNameMap.put(fieldDefinition.getName(),fieldDirective.getArgument(FIELD_MAPPING).getValue().toString());
DataFetcher<?> originalDataFetcher = env.getCodeRegistry().getDataFetcher(fieldsContainer, fieldDefinition);
  • fieldsContainer: Das übergeordnete Objekt, das das Feld enthält (z.B. Article).

  • fieldDefinition: Die Definition des aktuellen Feldes (z.B. currentName).

  • originalDataFetcher: Der ursprüngliche DataFetcher, der für das Abrufen des Wertes für das Feld zuständig ist.

Die Map fieldNameMap speichert die Zuordnung zwischen dem GraphQL-Feldnamen und dem tatsächlichen Feldnamen im Datenmodell. Dies ist notwendig, da der Name des GraphQL-Felds im Schema möglicherweise nicht mit dem tatsächlichen Feldnamen in der Datenquelle übereinstimmt.

Modifikation des DataFetchers

Nun modifizieren wir den ursprünglichen DataFetcher, um die Übersetzungslogik hinzuzufügen. Der modifizierte DataFetcher greift zuerst auf den ursprünglichen Wert zu, und falls ein mehrsprachiges Objekt vorhanden ist, wird der übersetzte Wert zurückgegeben.

  DataFetcher<?> modifiedDataFetcher = dataFetchingEnvironment -> {
      Object originalValue = originalDataFetcher.get(dataFetchingEnvironment);
      Object parent = dataFetchingEnvironment.getSource();
      
      if (parent != null) {
          // Reflection: Daten aus dem Parent-Objekt holen
          Map<String, Object> data = convertUsingReflection(parent);
          String currentField = dataFetchingEnvironment.getFieldDefinition().getName();
          MultiLanguageText text = (MultiLanguageText) data.get(fieldNameMap.get(currentField));
  
         // Rückgabe des mehrsprachigen Inhalts basierend auf der aktuellen Sprache
         return MultiLanguageText.getCurrentName(text, messageSourceService);
     }
     
     return originalValue;
 };
  
  • Der ursprüngliche Wert wird mithilfe von originalDataFetcher.get(dataFetchingEnvironment) abgerufen.
  • Das übergeordnete Objekt (in diesem Fall Article) wird aus dataFetchingEnvironment.getSource() extrahiert.
  • Die Methode convertUsingReflection wird verwendet, um auf die Felder des Objekts dynamisch zuzugreifen.

Falls das übergeordnete Objekt ein Feld vom Typ MultiLanguageText enthält, wird mithilfe des messageSourceService der Text in der aktuellen Sprache zurückgegeben.

Verwenden von Java Reflection

Reflection ermöglicht es uns, auf die Felder eines Objekts zuzugreifen, ohne deren Struktur zur Compilezeit zu kennen. In unserem Fall verwenden wir Reflection, um auf die Felder des Objekts zuzugreifen und den entsprechenden Wert für die Übersetzung zu extrahieren.

  private Map<String, Object> convertUsingReflection(Object object) throws IllegalAccessException {
      Map<String, Object> map = new HashMap<>();
      Field[] fields = object.getClass().getDeclaredFields();
   
      for (Field field : fields) {
          field.setAccessible(true);  // Zugriff auf private Felder ermöglichen
          map.put(field.getName(), field.get(object));  // Feldname und -wert in die Map schreiben
      }
   
     return map;
 }

Die Methode durchläuft alle Felder des Objekts und fügt sie in eine Map ein, die Feldnamen und deren Werte speichert. Diese Map wird dann verwendet, um den korrekten Wert für das mehrsprachige Feld zu extrahieren.

Der neu erstellte DataFetcher, der die Übersetzungslogik enthält, muss nun in der GraphQL-Laufzeit registriert werden. Dies geschieht, indem der modifizierte DataFetcher dem Feld zugewiesen wird.

 env.getCodeRegistry().dataFetcher(fieldsContainer, fieldDefinition, modifiedDataFetcher);

Fazit

Die Implementierung der benutzerdefinierten @currentLanguage-Direktive in GraphQL bietet eine elegante Lösung für das Problem der Mehrsprachigkeit in API-Responses. Diese Direktive ermöglicht es, dynamisch übersetzte Inhalte basierend auf der aktuell gewählten Sprache direkt im GraphQL-Schema zu integrieren, ohne dass zusätzliche Logik im Frontend notwendig ist.

Zentraler Bestandteil der Implementierung ist die Modifikation des DataFetchers, der für das Abrufen der Feldwerte verantwortlich ist. Durch den Einsatz von Reflection werden die Felder des zurückgegebenen Objekts dynamisch analysiert, und bei mehrsprachigen Feldern wird mithilfe eines MessageSourceService die passende Übersetzung zur Laufzeit bereitgestellt.

Die Vorteile dieser Lösung sind: 1. Entkopplung der Übersetzungslogik: Die Übersetzungslogik wird vollständig in die GraphQL-Laufzeit verlagert, sodass das Frontend nur noch das gewünschte Feld abfragt, ohne sich um sprachspezifische Details kümmern zu müssen. 2. Wiederverwendbarkeit: Die Direktive kann flexibel auf verschiedene Felder und Objekte im GraphQL-Schema angewendet werden, was die Wartung und Erweiterung des Codes erheblich vereinfacht. 3. Performance: Durch die Integration in den DataFetcher wird die Logik nur bei Bedarf (d.h. wenn das Feld abgefragt wird) ausgeführt, was die Effizienz erhöht und unnötige Operationen vermeidet.

Diese Lösung bietet somit eine flexible, wartungsfreundliche und skalierbare Architektur für mehrsprachige Anwendungen, indem sie die Vorteile von GraphQL voll ausschöpft und die Backend-Logik optimiert.

Teile den Beitrag mit deinen Freunden!