Blog

🍾 Gato GraphQL ora è scoped, grazie a PHP-Scoper!

Leonardo Losoviz
Di Leonardo Losoviz ·

Il plugin Gato GraphQL ora è scoped. Questo significa che il plugin può finalmente essere caricato nella directory dei plugin di WordPress.

Parlare di affari

Per riuscirci, sto usando il meraviglioso PHP-Scoper. Usare questa libreria con WordPress non è privo di difficoltà, quindi in questo articolo del blog spiegherò come sono riuscito a portarlo a termine.

Sezioni:

Prendere la decisione di rendere scoped

Qualche settimana fa, Matt Mullenweg ha annunciato che avrebbe tenuto d'occhio "il plugin GraphQL", riferendosi ovviamente a WPGraphQL. La sua espressione dimostra che crede esista un solo plugin GraphQL, quando in realtà ce ne sono due (quello lasciato fuori è, beh, il mio). Questo mi ha fatto capire quanto poca visibilità abbia il mio plugin, e mi sono sentito male.

Matt non sapeva che il mio plugin esistesse. Nemmeno la maggior parte della comunità WordPress, del resto. Chiaramente non lo pubblicizzo abbastanza bene. So di essere negato per il marketing e i social media; me la cavo solo con le cose tecniche (o almeno così credo). Quindi ho deciso di fare qualcosa al riguardo, almeno nei limiti delle mie capacità.

Ecco a cosa sto lavorando:

  • Ho appena finito di programmare questo stesso sito web, gatographql.com, e l'ho lanciato 2 settimane fa (evviva! 🥳 A proposito, ti piace? Sentiti libero di darmi un feedback, tramite DM o email)
  • 3 giorni fa, ho finalmente iniziato a rendere scoped il plugin, e ho terminato questo compito ieri! (Alle 3 del mattino, ma ne è valsa la pena 😅)
  • E infine, sto già lavorando alla prossima versione 0.8, che sarà la prima disponibile nel repository dei plugin

Rendere scoped il plugin è obbligatorio per caricarlo nel repository, perché altrimenti potrebbe entrare in conflitto con un altro plugin che richiede la stessa dipendenza del mio plugin, ma con una versione diversa. Averlo fatto è una tappa davvero importante; nessun altro sviluppo è altrettanto importante. Per esempio, devo ancora completare lo schema GraphQL perché corrisponda pienamente al modello di dati di WordPress, ma questo verrà fatto in modo costante ad ogni nuova release.

Così, tra qualche settimana, il plugin comparirà quando si cerca "GraphQL", e le persone che hanno realmente bisogno di implementare una API GraphQL verranno a conoscere l'esistenza del mio plugin.

In effetti, voglio che il mio plugin sia preso seriamente in considerazione per il futuro di WordPress. Ci lavoro ormai da diversi anni. Il repository è stato creato nell'agosto 2016; ciò è addirittura prima che WPGraphQL esistesse, e agli inizi di GraphQL. Ma non sapevo che il progetto sarebbe diventato un server GraphQL; ha preso quella direzione solo circa 1,5 anni fa.

(Il progetto è in realtà un framework per costruire applicazioni utilizzando componenti lato server, e un server GraphQL potrebbe benissimo essere costruito usando questa architettura. Quindi l'ho semplicemente costruito.)

WPGraphQL è un plugin affermato, e a ragione: è stato avviato qualche anno fa, e si è costruita una comunità attorno ad esso. Il lavoro di Jason Bahl (che è impiegato da Gatsby) e dei contributori al suo progetto è stato eccezionale: integrare WordPress nel Jamstack è ora più facile che mai.

Ma una cosa sono Gatsby e il Jamstack, e un'altra cosa è WordPress. WordPress rappresenta il 40% del web, non solo un input per un generatore di siti statici.

Quindi ora possiamo valutare se WPGraphQL sia l'opzione giusta, senza che questa decisione sia presa per noi per mancanza di alternative. Possiamo ora analizzare entrambi i plugin per vedere i cui obiettivi siano meglio allineati con ciò che è importante per WordPress.

Anche Gato GraphQL può funzionare con il Jamstack. Ma i suoi obiettivi principali sono, credo, più splendidi: "democratizzare la pubblicazione dei dati", così che modificare una API diventi facile come modificare un articolo (qualcosa che chiunque può fare), e rendere WordPress il sistema operativo del web.

Una volta che il plugin sarà disponibile nel repository, spero che più persone lo proveranno e diranno "Ehi, questo è davvero incredibile! Com'è possibile che non conoscessi questa roba prima?".

E allora, la scelta del "plugin GraphQL" non sarà predeterminata, e la comunità WordPress potrà considerare sia WPGraphQL che Gato GraphQL in base ai loro propri meriti.

Ora che le mie motivazioni sono state esposte, parliamo di cose tecniche 🤓.

Esaminare le opzioni

Rendere scoped un plugin implica eseguire alcuni strumenti, che prendono in input il codice del plugin e producono il plugin scoped. Niente di che, vero? Quanto può essere difficile?

Parlare di tecnica

Beh, a seconda della codebase, eseguire da solo il comando di scope non sarà sufficiente. Dopo, dobbiamo controllare gli errori nella console, correggerli, testare l'applicazione a fondo, identificare gli errori e capire perché si verificano, correggerli, e iterare. Per farlo perfettamente, potrebbe richiedere un po' di tempo.

Ci sono 2 librerie per il scoping, che hanno obiettivi differenti:

  • Mozart, per il codice WordPress
  • PHP-Scoper, per qualsiasi codice PHP, in particolare quando si producono PHAR

Dato che ho un plugin WordPress, ho provato prima Mozart. Vediamo come è andata.

Provare Mozart, e fallire

Ho provato Mozart circa 1 anno fa. Stando a ciò che dice la documentazione, "il comando mozart compose fa tutta la magia". Quindi mi aspettavo che fosse tutto molto rapido e semplice, e di andarmi a godere un daiquiri per il resto della giornata.

Ahimè, Mozart non ha mai funzionato per la mia codebase. Continuava a incappare in problemi, quindi il scoping non si è mai concretizzato. E non sono riuscito a ottenere l'assistenza necessaria: ho inviato una PR, ma non è stata presa in considerazione per il merge, e non ne sono nemmeno stato notificato, così ho continuato ad aspettare finché non ho naturalmente perso interesse in questo progetto.

Credo che Mozart non riuscisse a gestire alcune delle dipendenze del mio plugin. Faccio uso di diversi componenti di Symfony, tra cui DependencyInjection, Cache e Dotenv, con tutto gestito tramite Composer.

Rendere scoped del PHP non riguarda solo il PHP, quindi lo scoper avrà molti ostacoli da evitare e sfide da risolvere. Per esempio, Symfony DependencyInjection usa file YAML per impostare la configurazione, e anche questi devono essere resi scoped. E il file composer.json contiene la configurazione per l'autoloading PSR-4, e anche questo deve essere reso scoped. E, credo, Mozart non riuscisse a gestire correttamente queste complessità.

Ma sono sicuro che la mia esperienza non sia l'unica, e che ci siano molti utenti soddisfatti là fuori. Inoltre, il mio tentativo fallito è avvenuto 1 anno fa, quindi mi chiedo se lo strumento sia stato migliorato da allora. E poi, non dimenticare il detto: "Tutti i plugin scoped si assomigliano; ogni plugin non-scoped lo è a modo suo", quindi forse fallisce solo per me.

Se il tuo plugin WordPress è semplice, con logica autonoma, e il scoping deve essere eseguito solo all'interno del codice PHP, allora ci sono buone probabilità che Mozart funzioni. Devi solo scoprirlo.

Scoprire PHP-Scoper, e uscirne nel panico

Mi sono quindi diretto verso PHP-Scoper. Tuttavia, non ho nemmeno provato a provarlo, perché ne ho avuto paura immediatamente.

Tanto per cominciare, questo strumento non supporta naturalmente WordPress. E per proseguire, raccomandano di dare un'occhiata al loro stesso Makefile, che assomiglia a questo:

# 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

E altre 600 righe, tutte così. Sembra un rompicapo. Pensando di dover capire quel codice solo per rendere scoped il mio plugin, sono fuggito senza tante cerimonie.

(Beh, capire quel codice è la loro raccomandazione per testare l'applicazione scoped, ma non è obbligatorio. Possiamo anche semplicemente eseguire il comando php-scoper add-prefix, lasciargli fare tutta la magia, e andare a bere i nostri daiquiri.)

Tornare a PHP-Scoper, questa volta per davvero

Allora, 3 giorni fa, ho preso la decisione di implementare il scoping, in un modo o nell'altro. Dovevo riuscirci.

Sono tornato a PHP-Scoper, per provarlo sul serio. Sapevo che WordPress poteva essere reso scoped con esso dopo aver letto PHP Scoper: How to Avoid Namespace Issues in your Composer Dependencies (dalle brillanti persone di Delicious Brains). Era solo una questione di atteggiamento, e perseveranza.

Ho esplorato alcune delle soluzioni esistenti, tra cui:

Ma mi sembrano tutte non del tutto soddisfacenti: o il codice sembra hacky, oppure fragile e destinato a rompersi prima o poi.

Per esempio, il plugin Google Web Stories rende scoped il codice, e poi annulla ciascuno dei conflitti:

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

Capisco perché lo fanno, ma non mi piace. Ogni volta che viene referenziata una nuova funzione WordPress, devono assicurarsi che venga aggiunta anche a questa lista. È troppo manuale, troppo fragile.

Quindi questa era la mia sfida: non esiste un modo più semplice per rendere scoped un plugin, basandosi su codice che possiamo presentare ai nostri amici e colleghi senza arrossire?

PHP-Scoper, il modo facile 😎

Si è rivelato più facile di quanto pensassi! In sole poche ore, avevo tutto funzionante.

Scoping in poche ore

Ora, quando dico "facile" e "ore", intendo in realtà: ha funzionato tutto immediatamente, ma solo dopo aver passato 2 mesi a creare la struttura adeguata per la codebase (lo spiegherò meglio più avanti).

Ma la cosa importante è: se hai la giusta configurazione per il progetto, renderlo scoped può essere realizzato in un attimo.

Il problema con il scoping del codice WordPress è, beh, il codice WordPress. Il problema è spiegato qui, ma si riduce al fatto che anche tutte le funzioni e classi di WordPress vengono messe sotto namespace. Quindi se referenziamo WP_Query o chiamiamo get_posts nel nostro codice, questi verranno trasformati in MyPrefixedNamespace\WP_Query e MyPrefixedNamespace\get_posts, producendo un fallimento epico in fase di esecuzione. E questo non può essere evitato in PHP-Scoper senza hack.

Allora, qual è la soluzione a ciò? Facilissimo: non referenziare WP_Query, non chiamare get_posts, e non usare alcun codice WordPress nella codebase che verrà resa scoped.

Sono pazzo?

No, non sono pazzo, e sono sicuro che non lo sei nemmeno tu. E sì, lo so che stiamo costruendo un plugin WordPress... Lascia che ti spieghi.

Come possiamo non includere codice WordPress? Dividendo la codebase in 2 insiemi di package:

  • Quelli contenenti codice WordPress, senza referenziare codice di alcuna libreria esterna
  • Quelli contenenti la logica di business, senza contenere alcun codice WordPress, e includendo tutte le dipendenze richieste e i riferimenti al loro codice

In questo modo, invece di avere un'unica codebase, abbiamo più codebase (o package), alcune delle quali verranno rese scoped e altre no, e tutte insieme formano il plugin, legate tra loro tramite Composer.

Poi, non rendiamo scoped il package contenente il codice WordPress, evitando così il conflitto. Questo funziona perché non referenzia alcun codice appartenente a una dipendenza esterna. Tutti i riferimenti sono interni, come MyNamespace\MyPlugin\MyClass. Ma questi non hanno bisogno di essere resi scoped, perché possiamo presumere con sicurezza che ci sarà una sola versione del plugin installata nel sito WordPress, e possiamo mettere in whitelist il nostro namespace MyNamespace\*.

Inoltre, se il nostro plugin può essere esteso, allora mettere in whitelist il nostro stesso namespace è obbligatorio. Per esempio, un field resolver per Gato GraphQL viene implementato estendendo la classe PoP\ComponentModel\FieldResolvers\AbstractFieldResolver. Se lo rendessi scoped, gli sviluppatori sarebbero costretti a referenziare PoP\ComponentModel\FieldResolvers\AbstractFieldResolver per lo sviluppo, e PrefixedByPoP\PoP\ComponentModel\FieldResolvers\AbstractFieldResolver per la produzione. Inaccettabile.

Poi, rendiamo scoped solo i package di logica di business, che contengono riferimenti a tutte le librerie esterne ma nessun codice WordPress.

In sintesi, stiamo passando da questa strategia:

"Avere un'unica codebase, renderla scoped, e poi disfare dolorosamente e con tanta pazienza i danni, pregando che nessun conflitto passi inosservato e 💣 esploda in produzione"

A questa:

"Dividere la codebase in 2 gruppi, rendere scoped solo quello che contiene i riferimenti alle dipendenze esterne e nessun codice WordPress, e andare a prendersi il proprio meritatissimo daiquiri 🍹".

Mostrami la roba vera

È ora di aprire la salsiccia e vedere se c'è dentro della vera carne 🌭.

4 giorni fa, avevo il seguente codice nel mio plugin:

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

La classe Parsedown proviene dalla dipendenza esterna erusev/parsedown, come definito nel composer.json del plugin:

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

Pertanto, il mio plugin conteneva riferimenti a una libreria esterna, quindi dovevo renderlo scoped, per trasformare Parsedown in PrefixedByPoP\Parsedown. Ma farlo avrebbe reso scoped anche tutto il codice WordPress del plugin, causando i conflitti.

Ho quindi estratto il codice in un package separato, chiamato graphql-api/markdown-convertor, e ho sostituito la dipendenza di terze parti in composer.json con una mia dipendenza:

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

Ora, il plugin evita di referenziare la libreria esterna; invece, referenzia il servizio MarkdownConvertorInterface del nuovo package:

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

Il riferimento alla dipendenza di terze parti viene fatto nel nuovo package:

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

Infine, dobbiamo:

  • Rendere scoped la dipendenza graphql-api/markdown-convertor
  • Saltare il scoping del codice del plugin
  • Mettere in whitelist il namespace GraphQLAPI\*, per evitare che le mie classi vengano rese scoped

Questa è praticamente la strategia. Da qui in poi, sarà una ripetizione di questa stessa idea, per rimuovere tutte le dipendenze esterne dal codice, finché, voilà, il plugin può essere reso scoped.

Le dipendenze da estrarre sono solo quelle della sezione require del tuo file composer.json; per require-dev puoi tenere qualsiasi dipendenza, esterna o meno, dato che non abbiamo bisogno di rendere scoped le dipendenze usate per lo sviluppo; solo quelle per creare e distribuire il plugin, per la produzione, devono essere rese scoped.

Alla fine, il composer.json del tuo plugin non dovrebbe contenere alcuna dipendenza esterna. Per il mio plugin, è così:

{
  "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"
  }
}

Tutti quei package, con i namespace getpop, graphql-api, graphql-by-pop, e pop-schema, sono tutti miei: dipendenze contenenti tutto il codice del plugin. Sono distribuiti in namespace diversi per gestire meglio il codice, ma non è necessario: usare un singolo namespace funziona bene.

Ora, man mano che il numero di package nella tua applicazione cresce, dovrai ospitarli tutti in un monorepo, oppure impazzirai a creare pull request che coinvolgono più di un package (credimi, ci sono passato). Nel mio caso, tutti i miei package sono ospitati nel monorepo GatoGraphQL/GatoGraphQL, e li tengo sincronizzati tramite il meraviglioso Monorepo Builder (devo scrivere un articolo su questo strumento, è davvero un salvavita!).

I namespace per questi package sono PoP, GraphQLAPI, GraphQLByPoP e PoPSchema. Dato che sono miei, so che appariranno una sola volta nell'applicazione, e quindi posso evitare di renderli scoped.

Per farlo, li metto in whitelist in scoper.inc.php:

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

L'ultima voce corrisponde al container di dependency injection, che deve anch'esso essere reso scoped. Per impostazione predefinita, a questo container viene assegnato il nome ProjectServiceContainer, direttamente nel namespace globale. Ma PHP-Scoper non supporta il whitelisting di classi specifiche dal namespace globale. Ho quindi aggiunto il namespace artificiale PoPContainer alla whitelist, e assegnato questo namespace durante il dump del container su disco:

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

Puoi notare che, riguardo ai package, alcuni terminano con -wp (come pop-schema/users-wp) mentre altri no (come graphql-by-pop/graphql-server). Sì, hai indovinato: i primi contengono codice WordPress e nessun riferimento a librerie esterne, e i secondi possono contenere riferimenti a librerie esterne, ma nessun codice WordPress.

Poi, salto il scoping dei package WordPress:

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')
  ]
];

Cosa succede se un package WordPress ha bisogno di referenziare una libreria esterna, e questa non può essere estratta in un altro package? Per esempio, il mio package getpop/routing-wp dipende da brain/cortex, e questo è inevitabile.

Non posso rendere scoped l'intero package, dato che getpop/routing-wp contiene codice WordPress. Invece, quello che faccio è identificare i file in cui vengono fatti quei riferimenti, e assicurarmi che non contengano alcun codice WordPress. Posso quindi rendere scoped solo quei file.

In questo caso, il riferimento a Cortex/Brain viene fatto in 2 file, tra cui 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;
        }
      ));
    }
  }
}

Noti l'anomalia qui? Questa è un'implementazione di un hook, ma non viene chiamato alcun add_action, dato che non posso avere codice WordPress qui. Invece, chiama la funzione addAction del servizio HooksAPIInterface, e questo servizio è implementato dalla classe HooksAPI nel package getpop/hooks-wp, dove possiamo avere codice WordPress:

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

Ora che il codice è diviso in modo pulito, possiamo rendere scoped questi 2 file che referenziano dipendenze esterne:

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

Prima ho menzionato che configurare il scoping ha richiesto qualche ora, ma solo dopo 2 mesi di lavoro. Beh, questo esempio dimostra cosa intendevo: il vero lavoro sta nel dividere in modo pulito la codebase nei 2 insiemi.

Nel mio caso, il lavoro ha richiesto 2 mesi perché il livello di dettaglio era estremo: il plugin è diventato una composizione di 125 package! Ma questo è un caso eccezionale, con l'obiettivo che il server sottostante del plugin sia CMS-agnostic, così da supportare un'implementazione per altri CMS/framework semplicemente reimplementando i corrispondenti package -wp.

(Ho scritto in dettaglio su questa strategia, negli articoli Abstracting WordPress Code To Reuse With Other CMSs: Concepts e Implementation.)

È certamente parecchio lavoro, ma la migliorata pulizia del codice ne vale la pena. E non solo per rendere scoped il plugin, cosa che mi è arrivata come una totale sorpresa, e sono ancora euforico per questa felice scoperta inaspettata. Per esempio, eseguo PHPStan e PHPUnit separatamente sul codice WordPress e non-WordPress, evitandomi molti mal di testa.

Una volta che la codebase è messa in ordine, il mondo diventa improvvisamente un posto molto migliore.

Test

Allora, come testiamo questa bestia?

La soluzione che ho trovato è affidarmi a Rector, lo stesso strumento che uso per fare il downgrade del codice da PHP 7.4, per lo sviluppo, a 7.1, per la produzione.

L'idea è la seguente:

  1. Rendere scoped il plugin
  2. Analizzarlo con Rector, applicando una qualsiasi regola (non importa quale)

Se qualcosa è andato storto durante il scoping, allora Rector non sarà in grado di caricare qualche classe, e lancerà un errore. Per esempio, se la classe Brain\Cortex è stata resa scoped come PrefixedByPoP\Brain\Cortex, ma un riferimento ad essa è stato lasciato come Brain\Cortex, allora l'autoloading di questa classe fallirà.

Questa è la mia GitHub Action per i test (working-directory viene usato, perché opero dalla radice del monorepo, ma il scoping avviene nella cartella del plugin):

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: .
 

E questa è la mia configurazione Rector:

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/*',
  ]);
};

Puoi notare che alcuni file di dipendenze, come erusev/parsedown/Parsedown.php' devono essere aggiunti a Option::AUTOLOAD_PATHS. Questo perché rendere scoped il composer.json del package non è affidabile al 100%, e quindi il loro autoloading può fallire.

Ogni volta che ciò accade, Rector si lamenterà che una certa classe ha fallito l'autoloading. Da lì, identifichiamo il file corrispondente, e lo aggiungiamo manualmente ai percorsi di autoloading.

Guarda i risultati

Questo è il codice sorgente del plugin, e questa è la sua versione scoped (e con downgrade a PHP 7.1).

Trova le 7 differenze 😁. (Ti do un indizio: cerca PrefixedByPoP.)

E questo è il file finale del plugin graphql-api.zip, pronto per essere installato sul tuo sito.

È tutto. Spero che sia stato utile 😃💪🚀


Iscriviti alla nostra newsletter

Resta aggiornato su tutte le novità di Gato GraphQL.