Blog

💬 Einen neuen Ansatz für 'Gutenberg und entkoppelte Anwendungen' vorschlagen

Leonardo Losoviz
Von Leonardo Losoviz ·

Vor ein paar Tagen hat WPGraphQL-Entwickler Jason Bahl Gutenberg and Decoupled Applications veröffentlicht und darin die Vor- und Nachteile von 3 Ansätzen zur Integration von GraphQL mit Gutenberg analysiert.

Eine Woche früher hatte er auch auf Twitter gesagt, dass der Ansatz von Gato GraphQL zur Modellierung von Gutenberg ungeeignet ist:

Das ist meiner Meinung nach nichts, womit man angeben sollte. Eine Sache, die GraphQL mit einem typisierten Schema zu lösen versucht, ist es, Clients Vorhersagbarkeit und Konsistenz zu bieten und ihnen die Kontrolle zu geben, bis auf Feldebene nach dem zu fragen, was sie wollen.

Einen generischen "Object"-Typ ohne vorhersagbare Form zurückzugeben bedeutet, dass Client-Anwendungen jederzeit brechen können, weil es keinen Vertrag mehr zwischen Server und Client gibt. Der Server hat dem Client damit die Kontrolle entzogen.

Mit diesem Artikel beteilige ich mich an der Diskussion. Ich werde Jasons Kritik aufgreifen und dabei den Ansatz meines Plugins beschreiben und zeigen, warum ich glaube, dass er tatsächlich sehr gut zu Gutenberg passen kann.

COPE verwenden, um Gutenberg-Metadaten zu extrahieren

Meine Lösung könnte als 4. Ansatz betrachtet werden, und sie lautet wie folgt:

Um die Gutenberg-Daten für GraphQL zu erhalten, kein zusätzliches Schema auf der PHP-Seite erstellen und keine bestehenden Daten duplizieren. Stattdessen die Daten aus dem gespeicherten Inhalt der Blöcke extrahieren, mithilfe der COPE-Strategie ("Create Once, Publish Everywhere").

(COPE ist eine Strategie, die es ermöglicht, eine einzige Wahrheitsquelle für Inhalte zu haben und diese verschiedenen Anwendungen bereitzustellen. In unserem Fall ist die einzige Wahrheitsquelle die Gutenberg-Blockdaten, so wie sie in der Datenbank gespeichert sind. Ich habe COPE und seine Implementierung für WordPress in diesem Artikel beschrieben.)

Schließlich können wir GraphQL verwenden, um die extrahierten Daten für jeden Gutenberg-Block abzurufen, indem wir alle Blöcke einem einzigen Block-Typ zuordnen.

Diese Strategie ist ein Kompromiss, keine endgültige Lösung

Diese Strategie löst das Problem nicht, auf das Jason hinweist: das Fehlen eines serverseitigen Schemas, das die Schaffung eines Vertrags zwischen Server und Client ermöglichen würde.

COPE kann dieses Problem nicht lösen, weil wir allein aus dem gespeicherten Inhalt das Schema nicht rekonstruieren können:

  • Der gespeicherte Inhalt gibt nicht den Typ des Feldes an
  • Der gespeicherte Inhalt gibt nicht an, welche Einschränkungen das Feld hat (ist es nullable? ist es eine positive ganze Zahl? ist der String für eine E-Mail oder eine URL?)
  • Nullable-Felder können einen Standardwert haben, der im gespeicherten Inhalt nicht vorhanden sein wird

Jedoch kann Gato GraphQL mithilfe der COPE-Strategie und eines einzigen Block-Typs für alle Blöcke eine sehr gute Integration mit Gutenberg aufbauen, die die bestehenden Einschränkungen überwindet.

Das werde ich im Laufe dieses Artikels erklären.

Die Integration von Gato GraphQL mit Gutenberg

Diese Lösung ist noch in Arbeit, aber ich kann bereits erklären, wie sie sich verhalten wird.

Anstatt von einem anderen Typ pro Block abhängig zu sein (wie es WPGraphQL tut, wenn es sich auf das Plugin WPGraphQL for Gutenberg stützt), wird Gato GraphQL einen einzigen Block-Typ bereitstellen, um alle Blöcke darzustellen.

In dieser Query ruft das Feld Post.blockDataItems eine Liste von Block-Elementen aus dem Beitrag ab (für verschiedene Gutenberg-Blöcke, einschließlich Absätze, Bilder, Listen und andere):

{
  post(by: { id: 1499 }) {
    title
    blockDataItems
  }
}

Wenn wir Daten für einen bestimmten Block abrufen möchten, können wir anhand des Blocknamens filtern (core/paragraph, core/quote, usw.).

In dieser Query rufen wir nur die Bildblöcke ab:

{
  post(by: { id: 1177 }) {
    title
    blockDataItems(
      filterBy: { include: "core/image" }
    )
  }
}

Inspektion des einzigen Block-Typs

Mit diesem Ansatz kann die Antwort je nach gespeichertem Inhalt variieren, nicht basierend auf einem Schema. Diese Eigenschaft ist sowohl sein Vorteil (da sie die API flexibel macht) als auch sein Nachteil (wir können keine Server-Client-Verträge durchsetzen).

Jedes Block-Element enthält zwei Eigenschaften:

  • name: Der Name des Blocks (core/paragraph, core/quote, usw.)
  • meta: Die im Block enthaltenen Metadaten

Jeder Gutenberg-Block ist unterschiedlich und enthält unterschiedliche Daten (einen Absatzinhalt, ein YouTube-Video, eine Bild-Quell-URL und Abmessungen usw.). Daher werden auch die in der Antwort für das Feld meta enthaltenen Daten unterschiedlich sein.

Als solches wurde das Feld meta einfach als JSON-Objekt (das "rohe" Daten enthalten kann) über einen entsprechenden JSONObject-Typ im GraphQL-Schema abgebildet.

Es erzeugt diese Antwort:

{
  "data": {
    "post": {
      "title": "COPE with WordPress: Post demo containing plenty of blocks",
      "blockDataItems": [
        {
          "name": "core/paragraph",
          "attributes": {
            "content": "Lorem ipsum dolor sit amet"
          }
        },
        {
          "name": "core/image",
          "attributes": {
            "src": "https://ps.w.org/gutenberg/assets/banner-1544x500.jpg"
          }
        },
        {
          "name": "core/quote",
          "attributes": {
            "quote": "Etiam tempor orci eu lobortis elementum nibh tellus molestie",
            "cite": "Aristoteles"
          }
        },
        {
          "name": "core/heading",
          "attributes": {
            "size": "xl",
            "heading": "Welcome to my site"
          }
        },
        {
          "name": "core/list",
          "attributes": {
            "items": [
              "First element",
              "Second element",
              "Third element"
            ]
          }
        },
      ]
    }
  }
}

Wie wir sehen können, haben wir verschiedene Blöcke, die verschiedene Eigenschaften abrufen:

  • core/paragraph hat die Eigenschaft content
  • core/image hat die Eigenschaft src und optional die Eigenschaften width, height und caption (nicht in der obigen Antwort erscheinend)
  • core/quote hat die Eigenschaften quote und cite (für die zitierte Person)
  • core/heading hat die Eigenschaften header und size (der Wert xl steht für <h2>, weil COPE den Wert von der Zielanwendung entkoppelt, in diesem Fall einer Website)
  • core/list hat die Eigenschaft items, die eine Liste von Elementen ist

Warum der JSONObject-Typ nicht Teil der Spezifikation ist

Der oben beschriebene JSONObject-Typ ermöglicht es GraphQL, "dynamische" Felder abzurufen (wie Felder, die wir nicht kennen), oder Felder, die mehrere Konfigurationen haben können (wie es bei Gutenberg-Blöcken der Fall sein kann).

Nun unterstützt die GraphQL-Spezifikation derzeit weder den JSONObject- noch den Map-Typ. Die Unterstützung wurde angefordert, aus Gründen wie:

[...] das Fehlen dieser Funktion ist besonders problematisch, weil sie in vielen der Typsysteme und Dienste unterstützt wird, mit denen GraphQL eine Schnittstelle hat.

Das führt dazu, dass man benutzerdefinierte Resolver auf dem Server implementiert, gefolgt von benutzerdefinierten Transformationen auf dem Client, um mit Situationen umzugehen, in denen mein Server eine Map sendet und mein Client eine Map möchte, aber GraphQL in der Mitte ohne Map-Unterstützung steht. Ja, es ist möglich, und ich habe es getan, aber es ist eine Menge Boilerplate und Abstraktion, die den Zweck des Schreibens der API-Spezifikation in GraphQL zunichte zu machen scheint.

Diese Funktion wird von der Spezifikation nicht unterstützt, weil der Umgang mit dynamischen Feldern dem stark typisierten Verhalten von GraphQL widerspricht, was den Vertrag zwischen Server und Client bricht.

Dennoch kann dieser Typ für Gutenberg nützlich sein, wie ich später zeigen werde.

Probleme bei der Verwendung eines anderen Typs pro Block und einem serverseitigen Registry

Wenn man einen neuen GraphQL-Typ pro Block erstellt, müssen alle Plugins ihre Blöcke dem GraphQL-Schema hinzufügen. Dies könnte automatisch erreicht werden, indem alle Blöcke ihre Eigenschaften im vorgeschlagenen neuen serverseitigen Registry definieren.

Wenn sie das nicht tun, werden ihre Blöcke für die API nicht verfügbar sein, und das kann zusätzliche Konsequenzen haben. In manchen Fällen kann der gesamte abgefragte Beitragsinhalt unzuverlässig werden.

Das kann der Fall sein, wenn GraphQL mit einem externen cloudbasierten Dienst interagiert, der eine Funktion auf alle Blöcke im Beitrag anwendet (denk an Übersetzung, Grammatikkorrektur, SEO-Vorschläge, Analysen usw.).

Sehen wir uns ein Beispiel dafür an.

Da mehrsprachige Funktionen in Phase 4 zu Gutenberg hinzugefügt werden, modellieren wir, wie alle Blöcke im Plugin übersetzt werden können, über einen Aufruf an die Google Translate API, der über eine @strTranslate-Direktive ausgeführt wird.

(Nach dieser anfänglichen API-basierten Übersetzung kann der Benutzer den Blogbeitrag weiter bearbeiten, in der übersetzten Sprache, immer innerhalb des WordPress-Editors.)

Verschiedene Blöcke enthalten verschiedene Informationen, die übersetzt werden müssen:

  • core/paragraph: der Text
  • core/image: die Bildunterschrift
  • core/quote: das Zitat und die zitierte Person (da es der Titel der Person sein könnte, wie "The school headmaster")
  • core/heading: die Überschrift
  • core/list: alle Elemente der Liste

Bei Verwendung eines anderen Typs pro Block könnte die resultierende Query etwa so aussehen:

{
  post(by: { id: 1 }) {
    blocks {
      ... on CoreParagraphBlock {
        content @strTranslate
      }
      ... on CoreImageBlock {
        caption @strTranslate
      }
      ... on CoreQuoteBlock {
        quote @strTranslate
        cite @strTranslate
      }
      ... on CoreHeadingBlock {
        heading @strTranslate
      }
      ... on CoreListBlock {
        items @strTranslateList
      }
      ... on EmbedTwitterBlock {
        caption @strTranslate
      }
      ... on EmbedYoutubeBlock {
        caption @strTranslate
      }
      ... on EmbedVimeoBlock {
        caption @strTranslate
      }
    }
  }
}

Und so weiter. Je mehr Blöcke wir haben, desto länger wird diese Query, die leicht hundert Zeilen und mehr umfassen kann.

Das offensichtliche Problem ist, dass die Query zu einem wilden Tier wird, das wir pflegen müssen.

Außerdem müssen wir benutzerdefinierte Funktionalität einführen, damit es für jeden Block funktioniert. Zum Beispiel funktioniert @strTranslate nicht mit CoreListBlock.items, das eine Liste von Strings zurückgibt (d.h. es gibt [String] zurück, während die Direktive String erwartet), und daher müssen wir @strTranslateList erstellen.

Und dann würde core/table eine eigene benutzerdefinierte Direktive benötigen (@strTranslateTable?).

Und benutzerdefinierte Drittanbieter-Blöcke könnten ihre eigenen benutzerdefinierten Direktiven benötigen.

Und dann sehe ich noch ein paar weitere Probleme.

Alles oder nichts

Ein Blogbeitrag kann jeden im WordPress-Editor installierten Block enthalten. Und wir wissen nicht im Voraus (beim Schreiben der Query), welche Blöcke der Beitrag verwendet.

Mit einem Typ pro Block wird die Anzahl der in der Query zu behandelnden Typen also nicht der Anzahl der Blöcke im Beitrag entsprechen. Stattdessen wird sie der Anzahl der im WordPress-Editor installierten Blöcke entsprechen.

Was passiert, wenn wir 100 Blöcke auf unserer Website haben, sowohl aus dem WordPress-Core als auch aus Plugins? Dann müssen wir 100 Typen dem GraphQL-Schema zugeordnet haben. Ein einziger, der nicht zugeordnet ist, kann den "Inhaltsvertrag" brechen, sodass einige Blöcke von Englisch auf Französisch übersetzt werden, während andere auf Englisch bleiben.

Infolgedessen können wir den übersetzten Beiträgen nicht mehr vertrauen, egal ob sie den problematischen Block enthalten oder nicht. Wenn also nicht alle Blöcke zum Registry hinzugefügt werden, kann die Anwendung unzuverlässig werden.

Die Query muss jedes Mal aktualisiert werden, wenn ein neuer Block installiert wird

Ebenso muss jeder Block in der GraphQL-Query behandelt werden. Das bedeutet, dass wir immer dann, wenn wir einen neuen Block installieren, in den Code unserer Anwendung gehen, ihn aktualisieren und neu deployen müssen.

Das ist nicht nur zusätzliche Bürokratie: Wir werden keinen Block auf einer live Website installieren können, ohne die Angst, die Anwendung zu brechen (bis alle queries aktualisiert wurden).

GraphQL muss WordPress dienen, nicht umgekehrt

Wenn wir noch einmal überlegen, warum JSONObject nicht zur GraphQL-Spezifikation hinzugefügt wurde, dann weil es nicht zur GraphQL-Arbeitsweise passt.

Hier geht es uns jedoch nicht wirklich um GraphQL. Wir kümmern uns nur um WordPress und, in diesem Fall spezifisch, um Gutenberg.

Bei der Integration von GraphQL mit Gutenberg wird GraphQL im Kontext von WordPress operieren. Das bedeutet, dass WordPress die Anforderungen von GraphQL erfüllen muss. Aber noch wichtiger ist, dass GraphQL die Anforderungen von WordPress erfüllen muss.

Und im Konfliktfall hat WordPress Vorrang.

Wenn eine Funktion nicht zu GraphQL passt, aber dennoch zu Gutenberg passt, sollte sie dann berücksichtigt werden?

Ich denke schon.

Sehen wir uns an, wie ein einziger Block-Typ Gutenberg besser bedienen kann.

Die vorherigen Probleme durch einen einzigen Block-Typ lösen

Dem vorherigen Beispiel folgend wird das Übersetzen aller Blöcke in einem Beitrag von Englisch auf Französisch mit einem einzigen Block-Typ so gemacht (oder so ähnlich):

{
  post(by: { id: 1 }) {
    blocks {
      name
      meta
        @advancePointersInArray(paths: "{{ translatablePaths }}")
          @underEachArrayItem
            @strTranslate(from: "en", to: "fr")
    }
  }
}

Das war's? Die ganze Query? Um alle Blöcke zu übersetzen? Ja.

Wird es für alle Blöcke funktionieren, sowohl aus dem Core als auch aus Plugins, bereits vorhandene oder noch zu erstellende? Ja.

Kommt dir diese Query etwas seltsam vor? Wenn ja, dann deshalb, weil sie nicht standardmäßige GraphQL-Funktionen verwendet, die nur von Gato GraphQL unterstützt werden:

  • {{ translatablePaths }} ist ein einbettbares Feld, um den Wert eines Feldes als Argument an ein anderes Feld oder eine Direktive zu übergeben (in diesem Fall wird der Block-Typ ein Feld translatableFields haben, dessen Wert in die Direktive @advancePointersInArray injiziert wird)
  • Direktiven können aus anderen Direktiven zusammengesetzt werden

Wenn eine Funktion genau das erfüllt, was das CMS benötigt, aber nicht standardmäßig ist, sollten wir sie dennoch verwenden? Ich denke schon.

Ich habe diese Funktionen auch für die GraphQL-Spezifikation beantragt (auch wenn sie nicht akzeptiert werden):

Wie der einzige Block-Typ funktioniert

Warnung: technischer Abschnitt voraus.

Der Block-Typ wird ein Feld translatablePaths haben, das ein Array der Eigenschaften aus dem JSONObject zurückgibt, die übersetzt werden müssen:

  • core/paragraph gibt ["content"] zurück
  • core/image gibt ["caption"] zurück
  • core/quote gibt ["quote", "cite"] zurück
  • core/heading gibt ["header"] zurück
  • core/list gibt ["items.0", "items.1", "items.2", ...] zurück

@advancePointersInArray ist eine Meta-Direktive: Sie modifiziert den Kontext für eine nachfolgende Direktive. Sie lässt die nachfolgende Direktive ein Unterelement aus dem abgefragten JSONObject empfangen, wie die Eigenschaft content aus dem Absatzblock. Die Liste der Pfade wird über das Feld translatablePaths erhalten, das für dieselbe abgefragte Entität ausgewertet wird.

Dann ist @underEachArrayItem eine weitere Meta-Direktive, die über eine Liste von Elementen der abgefragten Entität iteriert und einen Verweis auf das iterierte Element an die nächste Direktive weitergibt. In diesem Fall erhält sie die gesamte Liste der zu übersetzenden Eigenschaften für alle Entitäten, jede vom Typ String, und gibt einzelne String-Elemente weiter.

Schließlich empfängt die Direktive @strTranslate ein Element vom Typ String, das im JSONObject enthalten ist, und übersetzt es direkt dort, innerhalb des JSONObject selbst.

Bitte beachte, wie flexibel diese Lösung ist. Es reicht, den Pfad zur Zeichenkette innerhalb des JSONObject anzugeben, um auf den Wert zuzugreifen, ihn mit @strTranslate (oder einer anderen Direktive) zu modifizieren und den Wert möglicherweise sogar wieder in der DB zu speichern (die Arbeit daran ist derzeit im Gange).

Es funktioniert bereits für core/list, da alle Elemente der Liste unter ihrem eigenen Pfad erreicht werden können (items.0 ist das 1. Element im Array usw.). Dann kann es auf den String-Wert von jedem zugreifen und ihn an @strTranslate weitergeben, sodass es nicht notwendig ist, @strTranslateList zu erstellen.

Ebenso wird es auch mit core/table funktionieren. Wir müssen nur die Daten über die Eigenschaft cells bereitstellen, die ein 2-dimensionales Array sein wird (eines für Zeilen, das eines für Spalten enthält). Dann kann translatablePaths alle Elemente als ["cells.0.0", "cells.0.1", "cells.1.0", ...] erreichen.

Und es wird auch für jeden Drittanbieter-Block funktionieren. Dafür müssen wir aufmerksam sein, wie die Blockdaten gespeichert werden, und von dort aus können wir den Pfad zu seinen Eigenschaften ableiten.

Ein einziger Block erfordert Konfiguration, basierend auf PHP-Code

Das Zuordnen der Blöcke, damit wir wissen, wo wir ihre Metadaten-Eigenschaften finden, kann durch Konfiguration erreicht werden. So können wir damit auf eine sehr flexible Weise umgehen.

In Gutenberg gibt es zwei Stellen, an denen eine Eigenschaft des Blocks gespeichert werden kann: als Attribut oder im gerenderten Inhalt.

Zum Beispiel wird der core/image-Block so gespeichert:

<!-- wp:image {"id":1670,"sizeSlug":"large","linkDestination":"none"} -->
<figure class="wp-block-image size-large">
<img src="https://newapi.getpop.org/wp/wp-content/uploads/2021/01/dynamic-include-first-query.webp" alt="" class="wp-image-1670"/>
</figure>
<!-- /wp:image -->

In diesem Fall haben wir:

  1. Eigenschaften id, sizeSlug und linkDestination werden als Attribute gespeichert
  2. Eigenschaft src wird im gerenderten Inhalt gespeichert

Wenn wir nun die API abfragen, wird die Antwort für den core/image-Block folgende sein:

{
  "data": {
    "blocks": [
      {
        "name": "core/image",
        "meta": {
          "id": 1670,
          "sizeSlug": "large",
          "linkDestination": "none",
          "src": "https://newapi.getpop.org/wp/wp-content/uploads/2021/01/dynamic-include-first-query.webp"
        }
      }
    ]
  }
}

Die API weiß, wie sie die Eigenschaften abruft, indem sie den in Gutenberg gespeicherten Block analysiert (das ist die COPE-Strategie). Dieser Prozess kann bis zu einem gewissen Grad automatisch durchgeführt werden, und dann mit einigen manuellen Eingaben über Hooks oder über eine Benutzeroberfläche.

Die direkt als Attribute zugeordneten Eigenschaften zu erhalten ist trivial. Der GraphQL-Server kann bereits alle Attribute aus dem Block abrufen und sie als Eigenschaften verfügbar machen. Oder, wenn wir explizit definieren möchten, welche wir bereitstellen wollen, können wir das über Filter-Hooks tun:

$attrs = apply_filters("blockPropsAsAttr:core/image", []);
 
add_filter("blockPropsAsAttr:core/image", function ($attrs) {
  return array_merge($attrs, ['id', 'sizeSlug', 'linkDestination']);
})

Die im Inhalt gespeicherten Eigenschaften können über einen Regex extrahiert werden:

$propRegexes = apply_filters("blockPropsAsRegex:core/image", []);
 
add_filter("blockPropsAsRegex:core/image", function ($propRegexes) {
  $propRegexes['src'] = '/<img src="(.*?)"/';
  return $propRegexes;
})

Schließlich geben wir an, welche die übersetzbaren Eigenschaften des Blocks sind, auf die @strTranslate wirken soll:

$propRegexes = apply_filters("translatableProperties:core/image", []);
 
add_filter("translatableProperties:core/image", function ($properties) {
  $properties[] = 'caption';
  return $properties;
})

Diese Eigenschaften müssen noch von jemandem erfüllt werden, höchstwahrscheinlich dem Plugin-Entwickler. Daher wird das serverseitige Registry dabei helfen, dieses Ziel zu erreichen.

Aber was, wenn die WordPress-Community das vorgeschlagene serverseitige Registry nicht hinzufügen möchte? Nun, diese Strategie kann sich leicht anpassen, weil das Mapping über PHP-Code erfolgen kann, wie gerade gezeigt.

Wenn ein Block nicht zugeordnet wurde, kann der Benutzer es auch selbst tun, wenn er nur ein bisschen über Gutenberg weiß und nichts über GraphQL oder Schemas.

Außerdem können wir GraphQL dazu bringen, den Benutzer zu benachrichtigen, wenn es einen Block gibt, der nicht zugeordnet wurde (und daher nicht übersetzt werden kann). Wir können das tun, indem wir eine @if-Meta-Direktive hinzufügen, die, wenn die Bedingung zutrifft, die @sendEmail-Direktive ausführt:

{
  post(by: { id: 1 }) {
    blocks {
      name
      meta
        @advancePointersInArray(paths: "{{ translatablePaths }}")
          @underEachArrayItem
            @strTranslate(from: "en", to: "fr")
        @if(condition: "{{ isTranslatablePathsUnmapped }}")
          @sendEmail(
            to: "{{ root.adminEmail }}",
            subject: "Block with name {{ name }} has 'translatablePaths' unmapped"
          )
    }
  }
}

Diese Lösung ist flexibel und einfach und sorgt dafür, dass GraphQL WordPress dient, ohne dass Entwickler eine neue Technologie erlernen oder die Funktionsweise von Gutenberg ändern müssen.

Fazit

Wenn wir darüber nachdenken, wie eine mögliche Integration zwischen GraphQL und Gutenberg aussehen könnte (im Hinblick auf eine mögliche Aufnahme in den WordPress-Core), müssen wir sicherstellen, dass GraphQL alle zukünftigen Anforderungen von Gutenberg erfüllen kann, einschließlich vollständiger Unterstützung für:

  • mehrsprachige Blöcke
  • Full Site Editing
  • kollaborative Bearbeitung
  • Interaktion mit Drittanbieter-Diensten auf einer live Website

All das muss hoffentlich ohne wesentliche Änderungen an Gutenberg erreicht werden und die neuen Aufgaben für Plugin-Entwickler reduzieren.

Unter Berücksichtigung all dessen glaube ich, dass der 4. Ansatz, den ich hier vorschlage, tatsächlich sehr gut funktionieren kann.


Abonniere unseren Newsletter

Bleib über alle Updates zu Gato GraphQL auf dem Laufenden.