Blog

🍾 Gato GraphQL ist jetzt scoped, dank PHP-Scoper!

Leonardo Losoviz
Von Leonardo Losoviz ·

Das Plugin Gato GraphQL ist jetzt scoped. Das bedeutet, dass das Plugin endlich in das WordPress-Plugin-Verzeichnis hochgeladen werden kann.

Geschäftsgespräch

Dafür nutze ich das wunderbare PHP-Scoper. Diese Bibliothek mit WordPress zu verwenden ist nicht ohne Tücken, deshalb erkläre ich in diesem Blogbeitrag, wie ich das hinbekommen habe.

Abschnitte:

Die Entscheidung zum Scopen treffen

Vor einigen Wochen hat Matt Mullenweg angekündigt, er werde „das GraphQL-Plugin" im Auge behalten – und meinte damit offensichtlich WPGraphQL. Seine Aussage zeigt, dass er glaubt, es gebe nur ein GraphQL-Plugin, obwohl es in Wirklichkeit zwei gibt (das übergangene ist, nun ja, meins). Das hat mir klar gemacht, wie wenig Sichtbarkeit mein Plugin hat, und ich war ziemlich niedergeschlagen.

Matt wusste nicht, dass mein Plugin existiert. Der Großteil der WordPress-Community auch nicht. Offenbar mache ich nicht genug Werbung dafür. Ich weiß, dass ich im Marketing und in den sozialen Medien schlecht bin; mit technischen Dingen komme ich ganz gut zurecht (zumindest glaube ich das). Also habe ich beschlossen, etwas zu tun – zumindest im Rahmen meiner Möglichkeiten.

Das ist es, woran ich gerade arbeite:

  • Ich habe gerade diese Website fertig programmiert, gatographql.com, und sie vor 2 Wochen gestartet (juchhu! 🥳 Wie findest du sie übrigens? Ich freue mich über Feedback, per DM oder E-Mail)
  • Vor 3 Tagen habe ich endlich angefangen, das Plugin zu scopen, und diese Aufgabe gestern abgeschlossen! (Um 3 Uhr morgens, aber es hat sich gelohnt 😅)
  • Und schließlich arbeite ich bereits an der kommenden Version 0.8, die die erste im Plugin-Repository sein wird

Das Scopen des Plugins ist Pflicht, um es in das Repository hochzuladen, denn andernfalls könnte es mit einem anderen Plugin in Konflikt geraten, das dieselbe Abhängigkeit wie mein Plugin benötigt, aber in einer anderen Version. Das geschafft zu haben ist ein wirklich wichtiger Meilenstein; keine andere Entwicklung ist so bedeutsam. Zum Beispiel muss ich noch das GraphQL-Schema vervollständigen, damit es vollständig dem WordPress-Datenmodell entspricht – aber das wird in jeder neuen Version schrittweise erledigt.

In einigen Wochen wird das Plugin also erscheinen, wenn jemand nach „GraphQL" sucht, und die Leute, die tatsächlich eine GraphQL-API implementieren wollen, werden von der Existenz meines Plugins erfahren.

Ich möchte, dass mein Plugin ernsthaft für die Zukunft von WordPress in Betracht gezogen wird. Ich arbeite seit mehreren Jahren daran. Das Repository wurde im August 2016 angelegt – das war noch vor WPGraphQL und in den Anfängen von GraphQL. Aber ich wusste nicht, dass das Projekt ein GraphQL-Server werden würde; diese Richtung hat es erst vor etwa 1,5 Jahren eingeschlagen.

(Das Projekt ist eigentlich ein Framework zum Erstellen von Anwendungen mit serverseitigen Komponenten, und ein GraphQL-Server lässt sich mit dieser Architektur problemlos bauen. Also habe ich ihn einfach gebaut.)

WPGraphQL ist ein etabliertes Plugin, und das zu Recht: Es wurde vor einigen Jahren gestartet, und eine Community hat sich darum gebildet. Die Arbeit von Jason Bahl (der bei Gatsby angestellt ist) und der Beitragenden zu seinem Projekt war herausragend: WordPress in den Jamstack zu integrieren ist jetzt einfacher als je zuvor.

Aber Gatsby und der Jamstack sind eine Sache, WordPress eine andere. WordPress macht 40 % des Webs aus – es ist nicht nur eine Eingabe für einen statischen Site-Generator.

Wir können also jetzt abwägen, ob WPGraphQL die richtige Option ist, ohne dass diese Entscheidung mangels Alternativen für uns getroffen wird. Wir können beide Plugins analysieren und sehen, welche Ziele besser zu dem passen, was für WordPress wichtig ist.

Gato GraphQL kann auch mit dem Jamstack arbeiten. Aber seine Hauptziele sind meiner Meinung nach noch großartiger: „Die Veröffentlichung von Daten zu demokratisieren", damit das Bearbeiten einer API so einfach wird wie das Bearbeiten eines Beitrags (etwas, das jeder kann), und WordPress zum Betriebssystem des Webs zu machen.

Sobald das Plugin im Repository verfügbar ist, hoffe ich, dass mehr Menschen es ausprobieren und sagen: „Hey, das ist ja verdammt nochmal fantastisch! Wie konnte ich das vorher nicht kennen?".

Und dann ist die Wahl des „GraphQL-Plugins" nicht vorbestimmt, und die WordPress-Community kann sowohl WPGraphQL als auch Gato GraphQL nach ihren eigenen Vorzügen beurteilen.

Jetzt, wo meine Beweggründe geklärt sind, kommen wir zum technischen Teil 🤓.

Die Optionen prüfen

Beim Scopen eines Plugins läuft ein Werkzeug durch, das den Plugin-Code als Eingabe nimmt und das gescopte Plugin ausspuckt. Kein großes Ding, oder? Wie schwierig kann das sein?

Technisches Gespräch

Nun ja, je nach Codebasis reicht es nicht, einfach nur den Scope-Befehl auszuführen. Danach müssen wir Fehler in der Konsole prüfen, sie beheben, die Anwendung gründlich testen, Fehler identifizieren und verstehen, warum sie auftreten, sie beheben und iterieren. Um alles perfekt hinzubekommen, kann das einige Zeit in Anspruch nehmen.

Es gibt 2 Bibliotheken zum Scopen, die unterschiedliche Ziele verfolgen:

  • Mozart, für WordPress-Code
  • PHP-Scoper, für beliebigen PHP-Code, insbesondere beim Erstellen von PHARs

Da ich ein WordPress-Plugin habe, habe ich zuerst Mozart ausprobiert. Schauen wir, wie es lief.

Mozart ausprobieren – und scheitern

Ich habe Mozart vor etwa 1 Jahr ausprobiert. Laut Dokumentation „erledigt der Befehl mozart compose die ganze Magie". Ich erwartete also, dass alles sehr schnell und einfach geht, und wollte den Rest des Tages einen Daiquiri genießen.

Leider hat Mozart bei meiner Codebasis nie funktioniert. Es sind immer wieder Probleme aufgetreten, sodass das Scopen nie zustande kam. Und ich konnte keine Hilfe bekommen: Ich habe eine PR eingereicht, aber sie wurde nicht für den Merge berücksichtigt, und ich wurde nicht einmal darüber benachrichtigt – ich wartete so lange, bis ich das Interesse an diesem Projekt natürlicherweise verlor.

Ich glaube, Mozart konnte einige der Abhängigkeiten in meinem Plugin nicht handhaben. Ich verwende mehrere Symfony-Komponenten, darunter DependencyInjection, Cache und Dotenv, alles über Composer verwaltet.

PHP zu scopen geht über reines PHP hinaus, daher hat ein Scoper viele Hürden zu nehmen und Herausforderungen zu lösen. Zum Beispiel verwendet Symfony DependencyInjection YAML-Dateien für die Konfiguration, und diese müssen ebenfalls gescoped werden. Und die composer.json-Datei enthält die Konfiguration für PSR-4-Autoloading, und auch diese muss gescoped werden. Ich glaube, Mozart konnte diese Komplexitäten nicht richtig handhaben.

Ich bin aber sicher, dass meine Erfahrung nicht die einzige ist und dass es da draußen viele zufriedene Nutzer gibt. Außerdem liegt mein fehlgeschlagener Versuch 1 Jahr zurück, also frage ich mich, ob das Tool seitdem verbessert wurde. Und vergiss das Sprichwort nicht: „Alle gescopten Plugins sind gleich; jedes ungescoptete Plugin ist auf seine eigene Weise ungescopt" – also scheitert es vielleicht nur bei mir.

Wenn dein WordPress-Plugin einfach ist, mit eigenständiger Logik, und das Scopen nur innerhalb von PHP-Code erfolgen muss, dann stehen die Chancen gut, dass Mozart funktioniert. Du musst es einfach herausfinden.

PHP-Scoper entdecken – und in Panik geraten

Also wandte ich mich PHP-Scoper zu. Ich habe es jedoch nie wirklich ausprobiert, weil ich sofort davon erschreckt wurde.

Erstens unterstützt dieses Tool WordPress nicht von Haus aus. Und zweitens empfehlen sie, sich ihr eigenes Makefile anzuschauen, das so aussieht:

# See https://tech.davis-hansson.com/p/make/
MAKEFLAGS += --warn-undefined-variables
MAKEFLAGS += --no-builtin-rules
 
.DEFAULT_GOAL := help
 
PHPBIN=php
PHPNOGC=php -d zend.enable_gc=0
IS_PHP8=$(shell php -r "echo version_compare(PHP_VERSION, '8.0.0', '>=') ? 'true' : 'false';")
 
SRC_FILES=$(shell find bin/ src/ -type f)
 
.PHONY: help
help:
	@echo "\033[33mUsage:\033[0m\n  make TARGET\n\n\033[32m#\n# Commands\n#---------------------------------------------------------------------------\033[0m\n"
	@fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//' | awk 'BEGIN {FS = ":"}; {printf "\033[33m%s:\033[0m%s\n", $$1, $$2}'
 
 
#
# Build
#---------------------------------------------------------------------------
 
.PHONY: clean
clean:	 ## Clean all created artifacts
clean:
	git clean --exclude=.idea/ -ffdx
 
update-root-version: ## Check the lastest GitHub release and update COMPOSER_ROOT_VERSION accordingly
update-root-version:
	rm .composer-root-version || true
	$(MAKE) .composer-root-version

Und weitere 600 Zeilen, alle so ähnlich. Das sieht aus wie ein Rätsel. Als ich dachte, ich müsste diesen Code verstehen, nur um mein Plugin zu scopen, bin ich fluchtartig abgehauen.

(Nun ja, diesen Code zu verstehen ist ihre Empfehlung zum Testen der gescopten Anwendung, aber es ist nicht zwingend erforderlich. Wir können auch einfach den Befehl php-scoper add-prefix ausführen, ihn die ganze Magie erledigen lassen und unsere Daiquiris trinken gehen.)

Zu PHP-Scoper zurückkehren, diesmal ernsthaft

Vor 3 Tagen habe ich also die Entscheidung getroffen, das Scoping irgendwie umzusetzen. Ich musste es schaffen.

Ich bin zu PHP-Scoper zurückgekehrt und habe es ernsthaft ausprobiert. Ich wusste, dass WordPress damit gescoped werden kann, nachdem ich PHP Scoper: How to Avoid Namespace Issues in your Composer Dependencies (von den brillanten Leuten von Delicious Brains) gelesen hatte. Es war nur eine Frage der Einstellung und Ausdauer.

Ich habe einige der vorhandenen Lösungen erkundet, darunter:

Aber sie alle erschienen mir nicht wirklich befriedigend: Entweder wirkt der Code hacky, oder fragil und darauf wartend, irgendwann zu brechen.

Zum Beispiel scoped das Plugin Google Web Stories den Code und macht dann jeden Konflikt einzeln rückgängig:

return [
  'patchers'                   => [
		function ( $file_path, $prefix, $contents ) {
			/*
			 * There is currently no easy way to simply whitelist all global WordPress functions.
			 *
			 * This list here is a manual attempt after scanning through the AMP plugin, which means
			 * it needs to be maintained and kept in sync with any changes to the dependency.
			 *
			 * As long as there's no built-in solution in PHP-Scoper for this, an alternative could be
			 * to generate a list based on php-stubs/wordpress-stubs. devowlio/wp-react-starter/ seems
			 * to be doing just this successfully.
			 *
			 * @see https://github.com/humbug/php-scoper/issues/303
			 * @see https://github.com/php-stubs/wordpress-stubs
			 * @see https://github.com/devowlio/wp-react-starter/
			 */
			$contents = str_replace( "\\$prefix\\_doing_it_wrong", '\\_doing_it_wrong', $contents );
			$contents = str_replace( "\\$prefix\\__", '\\__', $contents );
			$contents = str_replace( "\\$prefix\\esc_html_e", '\\esc_html_e', $contents );
			$contents = str_replace( "\\$prefix\\esc_html", '\\esc_html', $contents );
			$contents = str_replace( "\\$prefix\\esc_attr", '\\esc_attr', $contents );
			$contents = str_replace( "\\$prefix\\esc_url", '\\esc_url', $contents );
      $contents = str_replace( "\\$prefix\\do_action", '\\do_action', $contents );
      // ...
    }
  ]
]

Ich verstehe, warum sie das tun, aber ich mag es nicht. Immer wenn eine neue WordPress-Funktion referenziert wird, müssen sie sicherstellen, dass sie auch in dieser Liste landet. Das ist zu manuell, zu fragil.

Das war also meine Herausforderung: Gibt es keinen einfacheren Weg, ein Plugin zu scopen, mit Code, den wir unseren Freunden und Kollegen zeigen können, ohne rot zu werden?

PHP-Scoper, der einfache Weg 😎

Es stellte sich als einfacher heraus, als ich dachte! In nur wenigen Stunden hatte ich alles am Laufen.

Scoping in wenigen Stunden

Wenn ich „einfach" und „Stunden" sage, meine ich eigentlich: Es hat sofort funktioniert, aber erst nach 2 Monaten Arbeit, in denen ich die richtige Struktur für die Codebasis erstellt habe (ich erkläre das später genauer).

Aber das Wichtige ist: Wenn du die richtige Projektstruktur hast, kann das Scopen in kürzester Zeit erledigt werden.

Das Problem beim Scopen von WordPress-Code ist, nun ja, WordPress-Code. Das Problem wird hier erklärt, aber im Wesentlichen läuft es darauf hinaus, dass alle WordPress-Funktionen und -Klassen ebenfalls unter einen Namespace gestellt werden. Wenn wir also WP_Query referenzieren oder get_posts in unserem Code aufrufen, werden diese in MyPrefixedNamespace\WP_Query und MyPrefixedNamespace\get_posts umgewandelt – was zur Laufzeit krachend scheitert. Und das kann in PHP-Scoper nicht ohne Hacks vermieden werden.

Was ist also die Lösung? Ganz einfach: WP_Query nicht referenzieren, get_posts nicht aufrufen und keinen WordPress-Code in der Codebasis verwenden, die gescoped werden soll.

Bin ich verrückt?

Nein, ich bin nicht verrückt, und du bist es auch nicht. Und ja, ich weiß, dass wir ein WordPress-Plugin bauen... Lass mich erklären.

Wie können wir WordPress-Code ausschließen? Indem wir die Codebasis in 2 Gruppen von Paketen aufteilen:

  • Jene, die WordPress-Code enthalten, ohne Code aus externen Bibliotheken zu referenzieren
  • Jene, die Geschäftslogik enthalten, ohne jeglichen WordPress-Code, und mit allen erforderlichen Abhängigkeiten und Verweisen auf deren Code

Statt einer einzelnen Codebasis haben wir so mehrere Codebasen (oder Pakete), von denen manche gescoped werden und manche nicht – und zusammen bilden sie das Plugin, verbunden über Composer.

Dann scopen wir das Paket mit WordPress-Code nicht, wodurch wir den Konflikt vermeiden. Das funktioniert, weil es keinen Code referenziert, der zu einer externen Abhängigkeit gehört. Alle Referenzen sind intern, wie MyNamespace\MyPlugin\MyClass. Diese müssen nicht gescoped werden, weil wir sicher davon ausgehen können, dass nur 1 Version des Plugins auf der WordPress-Website installiert ist, und wir unseren Namespace MyNamespace\* auf die Whitelist setzen können.

Außerdem ist das Whitelisting unseres eigenen Namespace Pflicht, wenn unser Plugin erweiterbar ist. Zum Beispiel wird ein Field-Resolver für Gato GraphQL durch Erweiterung der Klasse PoP\ComponentModel\FieldResolvers\AbstractFieldResolver implementiert. Würde ich ihn scopen, wären Entwickler gezwungen, für die Entwicklung PoP\ComponentModel\FieldResolvers\AbstractFieldResolver zu referenzieren, und für die Produktion PrefixedByPoP\PoP\ComponentModel\FieldResolvers\AbstractFieldResolver. Das geht gar nicht.

Dann scopen wir nur die Geschäftslogik-Pakete, die Verweise auf alle externen Bibliotheken, aber keinen WordPress-Code enthalten.

Zusammenfassend wechseln wir von dieser Strategie:

„Eine einzige Codebasis haben, sie scopen, und dann mit viel Schmerz und Geduld den Schaden rückgängig machen, während wir beten, dass kein Konflikt unbemerkt bleibt und in der Produktion 💣 explodiert"

Zu dieser:

„Die Codebasis in 2 Gruppen aufteilen, nur die mit den Verweisen auf externe Abhängigkeiten und ohne WordPress-Code scopen, und dann den wohlverdienten Daiquiri genießen 🍹".

Zeig mir das echte Zeug

Es ist Zeit, die Wurst aufzuschneiden und zu sehen, ob echtes Fleisch drin ist 🌭.

Vor 4 Tagen hatte ich folgenden Code in meinem Plugin:

namespace GraphQLAPI\GraphQLAPI\ContentProcessors;
 
use Parsedown;
 
class MarkdownContentParser
{
  protected function getHTMLContent(string $fileContent): string
  {
    return (new Parsedown())->text($markdownContent);
  }
}

Die Klasse Parsedown stammt aus der externen Abhängigkeit erusev/parsedown, wie in der composer.json des Plugins definiert:

{
  "require": {
    "erusev/parsedown": "^1.7"
  }
}

Mein Plugin enthielt also Verweise auf eine externe Bibliothek, daher musste ich es scopen, um Parsedown in PrefixedByPoP\Parsedown umzuwandeln. Dabei würde jedoch auch der gesamte WordPress-Code im Plugin gescoped, was die Konflikte verursachen würde.

Also habe ich den Code in ein separates Paket extrahiert, graphql-api/markdown-convertor, und die Drittanbieter-Abhängigkeit in composer.json durch meine eigene Abhängigkeit ersetzt:

{
  "require": {
    "graphql-api/markdown-convertor": "^0.8"
  }
}

Jetzt vermeidet das Plugin die direkte Referenz zur externen Bibliothek; stattdessen referenziert es den Service MarkdownConvertorInterface aus dem neuen Paket:

namespace GraphQLAPI\GraphQLAPI\ContentProcessors;
 
use GraphQLAPI\MarkdownConvertor\MarkdownConvertorInterface;
 
class MarkdownContentParser extends AbstractContentParser
{
    protected MarkdownConvertorInterface $markdownConvertorInterface;
 
    function __construct(MarkdownConvertorInterface $markdownConvertorInterface)
    {
        $this->markdownConvertorInterface = $markdownConvertorInterface;
    }
 
    protected function getHTMLContent(string $fileContent): string
    {
        return $this->markdownConvertorInterface->convertMarkdownToHTML($fileContent);
    }
}

Die Referenz zur Drittanbieter-Abhängigkeit erfolgt im neuen Paket:

namespace GraphQLAPI\MarkdownConvertor;
 
use Parsedown;
 
class MarkdownConvertor implements MarkdownConvertorInterface
{
  public function convertMarkdownToHTML(string $markdownContent): string
  {
    return (new Parsedown())->text($markdownContent);
  }
}

Abschließend müssen wir:

  • Die Abhängigkeit graphql-api/markdown-convertor scopen
  • Das Scopen des Plugin-Codes überspringen
  • Den Namespace GraphQLAPI\* auf die Whitelist setzen, um zu verhindern, dass meine eigenen Klassen gescoped werden

Das ist im Wesentlichen die Strategie. Von nun an wiederholt sich dieselbe Idee, um alle externen Abhängigkeiten aus dem Code zu entfernen, bis das Plugin gescoped werden kann – voilà.

Die zu extrahierenden Abhängigkeiten sind nur jene aus dem require-Abschnitt deiner composer.json-Datei; bei require-dev kannst du beliebige Abhängigkeiten behalten, denn wir müssen keine Abhängigkeiten scopen, die nur für die Entwicklung genutzt werden; nur jene, die für das Erstellen und Ausliefern des Plugins in der Produktion benötigt werden, müssen gescoped werden.

Am Ende sollte die composer.json deines Plugins keine externen Abhängigkeiten mehr enthalten. Für mein Plugin sieht sie so aus:

{
  "require": {
    "php": "^7.4|^8.0",
    "getpop/engine-wp": "^0.8",
    "graphql-api/markdown-convertor": "^0.8",
    "graphql-by-pop/graphql-clients-for-wp": "^0.8",
    "graphql-by-pop/graphql-endpoint-for-wp": "^0.8",
    "graphql-by-pop/graphql-server": "^0.8",
    "pop-schema/basic-directives": "^0.8",
    "pop-schema/comment-mutations-wp": "^0.8",
    "pop-schema/commentmeta-wp": "^0.8",
    "pop-schema/comments-wp": "^0.8",
    "pop-schema/custompost-mutations-wp": "^0.8",
    "pop-schema/custompostmedia-mutations-wp": "^0.8",
    "pop-schema/custompostmedia-wp": "^0.8",
    "pop-schema/custompostmeta-wp": "^0.8",
    "pop-schema/generic-customposts": "^0.8",
    "pop-schema/media-wp": "^0.8",
    "pop-schema/pages-wp": "^0.8",
    "pop-schema/post-mutations": "^0.8",
    "pop-schema/post-tags-wp": "^0.8",
    "pop-schema/posts-wp": "^0.8",
    "pop-schema/taxonomymeta-wp": "^0.8",
    "pop-schema/taxonomyquery-wp": "^0.8",
    "pop-schema/user-roles-access-control": "^0.8",
    "pop-schema/user-roles-wp": "^0.8",
    "pop-schema/user-state-mutations-wp": "^0.8",
    "pop-schema/user-state-wp": "^0.8",
    "pop-schema/usermeta-wp": "^0.8",
    "pop-schema/users-wp": "^0.8"
  }
}

All diese Pakete mit den Namespaces getpop, graphql-api, graphql-by-pop und pop-schema gehören alle mir: Abhängigkeiten, die den gesamten Code des Plugins enthalten. Sie sind auf verschiedene Namespaces verteilt, um den Code besser zu verwalten, aber das ist nicht nötig: Ein einziger Namespace funktioniert genauso gut.

Wenn die Anzahl der Pakete in deiner Anwendung wächst, wirst du sie alle in einem Monorepo hosten müssen, oder du wirst beim Erstellen von Pull Requests, die mehr als ein Paket betreffen, verrückt werden (glaub mir, ich war da schon). In meinem Fall sind alle meine Pakete im GatoGraphQL/GatoGraphQL-Monorepo gehostet, und ich halte sie mit dem wunderbaren Monorepo Builder synchron (ich muss mal einen Artikel über dieses Tool schreiben, es ist ein echter Lebensretter!).

Die Namespaces für diese Pakete sind PoP, GraphQLAPI, GraphQLByPoP und PoPSchema. Da sie mir gehören, weiß ich, dass sie nur einmal in der Anwendung auftauchen, und ich kann das Scopen daher vermeiden.

Dazu setze ich sie in scoper.inc.php auf die Whitelist:

return [
  'whitelist' => [
    // Own namespaces
    'PoPSchema\*',
    'PoP\*',
    'GraphQLByPoP\*',
    'GraphQLAPI\*',
    // Own container cache
    'PoPContainer\*',
  ],
];

Der letzte Eintrag entspricht dem Dependency-Injection-Container, der ebenfalls gescoped werden muss. Standardmäßig wird diesem Container der Name ProjectServiceContainer direkt im globalen Namespace zugewiesen. PHP-Scoper unterstützt jedoch kein Whitelisting bestimmter Klassen aus dem globalen Namespace. Daher habe ich den künstlichen Namespace PoPContainer zur Whitelist hinzugefügt und diesen Namespace zugewiesen, wenn der Container auf die Festplatte gedumpt wird:

$dumper = new PhpDumper($containerBuilder);
file_put_contents(
  self::$cacheFile,
  $dumper->dump(
    // Save under own namespace to avoid conflicts
    array('namespace' => 'PoPContainer')
  )
);

Du wirst bemerken, dass einige Pakete auf -wp enden (wie pop-schema/users-wp), während andere es nicht tun (wie graphql-by-pop/graphql-server). Richtig geraten: Die ersteren enthalten WordPress-Code und keine Verweise auf externe Bibliotheken, die letzteren können Verweise auf externe Bibliotheken enthalten, aber keinen WordPress-Code.

Dann überspringe ich das Scopen der WordPress-Pakete:

return [
  'finders' => [
    // Scope packages under vendor/, excluding local WordPress packages
    Finder::create()
      ->files()
      ->notPath([
        // Exclude libraries ending in "-wp"
        '#getpop/[a-zA-Z0-9_-]*-wp/#',
        '#pop-schema/[a-zA-Z0-9_-]*-wp/#',
        '#graphql-by-pop/[a-zA-Z0-9_-]*-wp/#',
      ])
      ->in('vendor')
  ]
];

Was passiert, wenn ein WordPress-Paket eine externe Bibliothek referenzieren muss, und diese nicht in ein anderes Paket ausgelagert werden kann? Zum Beispiel hängt mein Paket getpop/routing-wp von brain/cortex ab, und das ist unvermeidlich.

Ich kann das gesamte Paket nicht scopen, da getpop/routing-wp WordPress-Code enthält. Stattdessen identifiziere ich die Dateien, in denen diese Verweise vorkommen, und stelle sicher, dass sie keinen WordPress-Code enthalten. Dann kann ich nur diese Dateien scopen.

In diesem Fall erfolgt der Verweis auf Cortex/Brain in 2 Dateien, darunter layers/Engine/packages/routing-wp/src/Hooks/SetupCortexHookSet.php:

namespace PoP\RoutingWP\Hooks;
 
use PoP\Hooks\AbstractHookSet;
use Brain\Cortex\Route\RouteCollectionInterface;
use Brain\Cortex\Route\RouteInterface;
use Brain\Cortex\Route\QueryRoute;
use PoP\RoutingWP\WPQueries;
use PoP\Routing\Facades\RoutingManagerFacade;
 
class SetupCortexHookSet extends AbstractHookSet
{
  protected function init()
  {
    $this->hooksAPI->addAction(
      'cortex.routes',
      [$this, 'setupCortex'],
      1
    );
  }
 
  /**
   * @param RouteCollectionInterface<RouteInterface> $routes
   */
  public function setupCortex(RouteCollectionInterface $routes): void
  {
    $routingManager = RoutingManagerFacade::getInstance();
    foreach ($routingManager->getRoutes() as $route) {
      $routes->addRoute(new QueryRoute(
        $route,
        function (array $matches) {
          return WPQueries::STANDARD_NATURE;
        }
      ));
    }
  }
}

Fällt dir die Besonderheit auf? Das ist eine Hook-Implementierung, aber es wird kein add_action aufgerufen, weil ich hier keinen WordPress-Code haben kann. Stattdessen wird die Funktion addAction des Services HooksAPIInterface aufgerufen, und dieser Service wird von der Klasse HooksAPI im Paket getpop/hooks-wp implementiert, wo wir WordPress-Code haben können:

namespace PoP\HooksWP;
 
use PoP\Hooks\HooksAPIInterface;
 
class HooksAPI implements HooksAPIInterface
{
  public function addAction(string $tag, callable $function_to_add, int $priority = 10, int $accepted_args = 1): void
  {
    add_action($tag, $function_to_add, $priority, $accepted_args);
  }
}

Jetzt, wo der Code sauber aufgeteilt ist, können wir diese 2 Dateien, die externe Abhängigkeiten referenzieren, scopen:

return [
  'finders' => [
    Finder::create()->append([
      'vendor/getpop/routing-wp/src/Component.php',
      'vendor/getpop/routing-wp/src/Hooks/SetupCortexHookSet.php',
    ])
  ]
];

Vorhin habe ich erwähnt, dass das Einrichten des Scopings ein paar Stunden gedauert hat, aber erst nach 2 Monaten Arbeit. Dieses Beispiel zeigt, was ich damit meinte: Die eigentliche Arbeit liegt darin, die Codebasis sauber in die 2 Gruppen aufzuteilen.

In meinem Fall dauerte die Arbeit 2 Monate, weil der Detailgrad extrem war: Das Plugin wurde zu einer Komposition aus 125 Paketen! Aber das ist ein Ausnahmefall, mit dem Ziel, dass der zugrunde liegende Server des Plugins CMS-agnostisch sein soll, um eine Implementierung für andere CMS/Frameworks zu unterstützen, indem einfach die entsprechenden -wp-Pakete neu implementiert werden.

(Ich habe diese Strategie ausführlich beschrieben, in den Artikeln Abstracting WordPress Code To Reuse With Other CMSs: Concepts und Implementation.)

Es ist durchaus viel Arbeit, aber die verbesserte Sauberkeit des Codes macht es wert. Und nicht nur wegen des Scopings des Plugins, das mich völlig überrascht hat und mich noch immer in unerwarteter Freude jubeln lässt. Zum Beispiel führe ich PHPStan und PHPUnit getrennt für WordPress- und Nicht-WordPress-Code aus, was mir viele Kopfschmerzen erspart.

Wenn die Codebasis erst einmal aufgeräumt ist, wird die Welt plötzlich zu einem viel besseren Ort.

Testen

Also, wie testen wir dieses Biest?

Die Lösung, auf die ich gekommen bin, ist, mich auf Rector zu verlassen, dasselbe Tool, das ich zum Downgraden von Code von PHP 7.4 für die Entwicklung auf 7.1 für die Produktion verwende.

Die Idee ist folgende:

  1. Das Plugin scopen
  2. Es mit Rector analysieren, indem eine beliebige Regel angewendet wird (es spielt keine Rolle, welche)

Wenn beim Scopen etwas schiefgelaufen ist, kann Rector eine Klasse nicht laden und wirft einen Fehler. Wenn zum Beispiel die Klasse Brain\Cortex als PrefixedByPoP\Brain\Cortex gescoped wurde, aber ein Verweis darauf als Brain\Cortex geblieben ist, schlägt das Autoloading dieser Klasse fehl.

Das ist meine GitHub Action für Tests (working-directory wird verwendet, weil ich vom Root des Monorepos aus arbeite, aber das Scoping im Plugin-Ordner stattfindet):

name: Scope Gato GraphQL tests
on:
  push:
    branches:
      - master
  pull_request: null
 
env:
  COMPOSER_ROOT_VERSION: "dev-master"
 
jobs:
  main:
    defaults:
      run:
        working-directory: layers/GraphQLAPIForWP/plugins/graphql-api-for-wp
 
    name: Scope the plugin code via PHP-Scoper, and execute tests
 
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v2
 
      - name: Set-up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: 7.4
          coverage: none
        env:
          COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Install root dependencies
        uses: "ramsey/composer-install@v1"
 
      - name: Install plugin dependencies for PROD
        run: composer install --no-dev --no-progress --no-interaction --ansi
 
      - name: Install PHP-Scoper
        run: |
          composer global config minimum-stability dev
          composer global config prefer-stable true
          composer global require humbug/php-scoper
 
      # The scoped results correspond to vendor/, so must generate them in such folder
      - name: Scope plugin into separate folder
        run: php-scoper add-prefix --output-dir ../../../../build-prefixed/vendor --ansi
 
      - name: Copy scoped code back into plugin
        run: rsync -av build-prefixed/ layers/GraphQLAPIForWP/plugins/graphql-api-for-wp/ --quiet
        working-directory: .
 
      - name: Regenerate autoloader
        run: composer dumpautoload --optimize --classmap-authoritative --ansi
 
      - name: Run Rector on the scoped code
        run: vendor/bin/rector process --config=layers/GraphQLAPIForWP/plugins/graphql-api-for-wp/rector-test-scoping.php --ansi
        working-directory: .
 

Und das ist meine Rector-Konfiguration:

use Rector\CodeQuality\Rector\LogicalAnd\AndAssignsToSeparateLinesRector;
use Rector\Core\Configuration\Option;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
 
return static function (ContainerConfigurator $containerConfigurator): void {
  $services = $containerConfigurator->services();
  $services->set(AndAssignsToSeparateLinesRector::class);
  $parameters->set(Option::AUTO_IMPORT_NAMES, true);
 
  $parameters->set(Option::AUTOLOAD_PATHS, [
    __DIR__ . '/vendor/scoper-autoload.php',
    __DIR__ . '/vendor/erusev/parsedown/Parsedown.php',
    __DIR__ . '/vendor/jrfnl/php-cast-to-type/cast-to-type.php',
    __DIR__ . '/vendor/jrfnl/php-cast-to-type/class.cast-to-type.php',
  ]);
 
  // files to rector
  $parameters->set(Option::PATHS, [
    __DIR__ . '/vendor',
  ]);
 
  // files to skip
  $parameters->set(Option::SKIP, [
    // Exclude tests
    '*/tests/*',
    __DIR__ . '/vendor/nikic/fast-route/test/*',
    __DIR__ . '/vendor/psr/log/Psr/Log/Test/*',
    __DIR__ . '/vendor/symfony/service-contracts/Test/*',
  ]);
};

Du wirst bemerken, dass einige Abhängigkeitsdateien, wie erusev/parsedown/Parsedown.php', zu Option::AUTOLOAD_PATHS hinzugefügt werden müssen. Das liegt daran, dass das Scopen der composer.json des Pakets nicht 100 % zuverlässig ist und deren Autoloading dann fehlschlagen kann.

Wenn das passiert, beschwert sich Rector, dass eine Klasse beim Autoloading fehlgeschlagen ist. Daraus identifizieren wir die entsprechende Datei und fügen sie manuell zu den Autoloading-Pfaden hinzu.

Die Ergebnisse ansehen

Das ist der Quellcode des Plugins, und das ist die gescoped (und auf PHP 7.1 downgegrade) Version.

Finde die 7 Unterschiede 😁. (Ich gebe dir einen Hinweis: suche nach PrefixedByPoP.)

Und das ist die finale Plugin-Datei graphql-api.zip, bereit zur Installation auf deiner Website.

Das war's. Ich hoffe, das war hilfreich 😃💪🚀


Abonniere unseren Newsletter

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