In circostanze ideali, dovremmo usare PHP 8.0 (l’ultima versione al momento in cui scriviamo questo articolo) per tutti i nostri siti e aggiornarlo non appena viene rilasciata una nuova versione. Tuttavia, gli sviluppatori avranno spesso bisogno di lavorare con precedenti versioni di PHP, come quando si crea un plugin pubblico per WordPress o si lavora con codice legacy che impedisce di aggiornare l’ambiente del webserver.

In queste situazioni, potremmo abbandonare la speranza di utilizzare il più recente codice PHP. Ma c’è un’alternativa migliore: possiamo ancora scrivere il nostro codice sorgente con PHP 8.0 e tradurlo in una versione precedente di PHP – anche in PHP 7.1.

In questa guida spiegheremo tutto quello che c’è da sapere sul transpiling del codice PHP.

Cos’è il Transpiling?

Il transpiling converte il codice sorgente di un linguaggio di programmazione in un codice sorgente equivalente dello stesso o di un diverso linguaggio di programmazione.

La compilazione non è un concetto nuovo nello sviluppo web: gli sviluppatori lato client avranno probabilmente familiarità con Babel, un transpiler per il codice JavaScript.

Babel converte il codice JavaScript dalla moderna versione ECMAScript 2015+ in una versione legacy compatibile con i vecchi browser. Ad esempio, data una arrow function ES2015:

[2, 4, 6].map((n) => n * 2);

…Babel la convertirà nella sua versione ES5:

[2, 4, 6].map(function(n) {
  return n * 2;
});

Che cos’è il Transpiling di PHP?

Ciò che forse è nuovo nello sviluppo web è la possibilità di effettuare il transpiling del codice lato server, in particolare di PHP.

Il transpiling di PHP funziona allo stesso modo del transpiling di JavaScript: il codice sorgente di una versione moderna di PHP viene convertito in un codice equivalente per una vecchia versione di PHP.

Seguendo lo stesso esempio di prima, una arrow function da PHP 7.4:

$nums = array_map(fn($n) => $n * 2, [2, 4, 6]);

…può essere trasformata nella sua versione equivalente in PHP 7.3:

$nums = array_map(
  function ($n) {
    return $n * 2;
  },
  [2, 4, 6]
);

Le arrow function possono essere oggetto di traspiling perché sono syntactic sugar, cioè una nuova sintassi per produrre un comportamento esistente. Questo è il beneficio maggiore.

Ma ci sono anche nuove funzioni che creano un nuovo comportamento e, come tali, non ci sarà un codice equivalente per le versioni precedenti di PHP. È il caso dei tipi unione, introdotti in PHP 8.0:

function someFunction(float|int $param): string|float|int|null
{
  // ...
}

In queste situazioni, il transpiling può comunque essere eseguito finché la nuova funzionalità è richiesta per lo sviluppo e non per la produzione. Poi, possiamo semplicemente rimuovere del tutto la funzionalità dal codice oggetto di transpiling senza gravi conseguenze.

Uno di questi esempi sono i tipi unione. Questa funzionalità permette di controllare che non ci sia una mancata corrispondenza tra il tipo di input e il suo valore fornito, il che aiuta a prevenire i bug. Se c’è un conflitto con i tipi, ci sarà un errore già nello sviluppo e dovremmo correggerlo prima che il codice raggiunga la produzione.

Quindi, possiamo permetterci di rimuovere la funzionalità dal codice per la produzione:

function someFunction($param)
{
  // ...
}

Se l’errore si verifica ancora in produzione, il messaggio di errore lanciato sarà meno preciso che se avessimo i tipi unione. Tuttavia, questo possibile svantaggio è superato in primo luogo dall’essere in grado di utilizzare i tipi unione.

Vantaggi del Transpiling del Codice PHP

Il transpiling permette di creare il codice di un’applicazione utilizzando l’ultima versione di PHP e produrre una versione che funziona anche in ambienti che eseguono vecchie versioni di PHP.

Questo può essere particolarmente utile per gli sviluppatori che creano prodotti per sistemi di gestione dei contenuti (CMS) legacy. WordPress, per esempio, supporta ancora ufficialmente PHP 5.6 (anche se raccomanda PHP 7.4+). La percentuale di siti WordPress che eseguono le versioni PHP da 5.6 a 7.2 – che sono tutte End-of-Life (EOL), il che significa che non ricevono più aggiornamenti di sicurezza – si attesta su un considerevole 34,8%, e quelli che eseguono qualsiasi versione di PHP diversa dalla 8.0 si attesta su un enorme 99,5%:

Statistiche sull'utilizzo di WordPress per versione.
Statistiche sull’utilizzo di WordPress per versione. Fonte immagine: WordPress

Di conseguenza, i temi e i plugin di WordPress destinati ad un pubblico globale saranno molto probabilmente codificati con una vecchia versione di PHP per aumentarne la portata. Grazie al transpiling, questi potrebbero essere codificati utilizzando PHP 8.0, ed essere comunque rilasciati per una vecchia versione di PHP, raggiungendo così il maggior numero di utenti possibile.

Infatti, qualsiasi applicazione che ha bisogno di supportare una qualsiasi versione di PHP diversa da quella più recente (anche all’interno della gamma delle versioni PHP attualmente supportate) può beneficiarne.

Questo è il caso di Drupal, che richiede PHP 7.3. Grazie al transpiling, gli sviluppatori possono creare moduli Drupal disponibili al pubblico utilizzando PHP 8.0 e rilasciarli con PHP 7.3.

Un altro esempio è quando si crea codice personalizzato per clienti che non possono eseguire PHP 8.0 nei loro ambienti per una ragione o per l’altra. Tuttavia, grazie al transpiling, gli sviluppatori possono sviluppare i propri prodotti usando PHP 8.0 ed eseguirli su quegli ambienti legacy.

Quando Effettuare il Transpiling di PHP

Il codice PHP può sempre essere oggetto di transpiling, a meno che non contenga qualche funzionalità di PHP che non ha un equivalente nella versione precedente di PHP.

Questo è probabilmente il caso degli attributi, introdotti in PHP 8.0:

#[SomeAttr]
function someFunc() {}

#[AnotherAttr]
class SomeClass {}

Nell’esempio precedente in cui si utilizzavano le arrow function, era possibile eseguire il transpiling perché le arrow function sono sintactic sugar. Gli attributi, al contrario, creano un comportamento completamente nuovo. Questo comportamento potrebbe anche essere riprodotto con PHP 7.4 e successivi, ma solo codificando manualmente, cioè non automaticamente sulla base di uno strumento o di un processo (l’AI potrebbe fornire una soluzione, ma non ci siamo ancora).

Gli attributi destinati allo sviluppo, come #[Deprecated]possono essere rimossi nello stesso modo in cui vengono rimossi i tipi unione. Ma gli attributi che modificano il comportamento dell’applicazione in produzione non possono essere rimossi e non possono nemmeno essere direttamente oggetto di transpiling.

Ad oggi, nessun transpiler può prendere del codice con attributi PHP 8.0 e produrre automaticamente il loro equivalente in PHP 7.4. Di conseguenza, se il vostro codice PHP ha bisogno di utilizzare gli attributi, allora il transpiling sarà difficile o irrealizzabile.

Funzionalità di PHP che Possono Essere Oggetto di Transpiling

Queste sono le funzionalità di PHP 7.1 e superiori che possono essere attualmente oggetto di transpiling. Se il vostro codice utilizza solo queste funzionalità, potete star certi che il transpiling della vostra applicazione funzionerà. Altrimenti, dovrete valutare se il codice codice oggetto di transpiling produce dei failure.

Versione PHP Funzionalità
7.1 Tutto
7.2 tipo object
ampliamento del tipo di parametro
Flag PREG_UNMATCHED_AS_NULL in preg_match
7.3 Assegnazioni di riferimento nella destrutturazione di list() / array(eccetto all’interno di foreach#4376)
Sintassi Heredoc e Nowdoc flessibile
Virgole finali nelle chiamate di funzioni
set(raw)cookie accetta l’argomento $option
7.4 Proprietà tipizzate
Arrow function
Operatore di assegnazione Null coalescente
Spacchettamento all’interno degli array
Separatore di letterali numerici
strip_tags() con array di nomi di tag
tipi di ritorno covarianti e tipi di param controvarianti
8.0 Tipi unione
pseudo tipo mixed
tipo di ritorno static
::class costante magica sugli oggetti
match alle espressioni
catch le eccezioni solo per tipo
Operatore null-safe
Promozione delle proprietà del costruttore di classe
Virgole finali negli elenchi di parametri e negli elenchi di chiusure use

Transpiler PHP

Attualmente, c’è uno strumento per il transpiling del codice PHP: Rector.

Rector è uno strumento di ricostruzione di PHP, che converte il codice basandosi su regole programmabili. Inseriamo il codice sorgente e l’insieme di regole da eseguire e Rector trasformerà il codice.

Rector viene utilizzato tramite linea di comando, installato nel progetto tramite Composer. Quando viene eseguito, Rector produce un “diff” (aggiunte in verde, rimozioni in rosso) del codice prima e dopo la conversione:

Uscita “diff” da Rector

A Quale Versione di PHP Eseguire il Transpiling

Per effettuare il transpiling del codice alle varie versioni di PHP, devono essere create le regole corrispondenti.

Oggi, la libreria Rector include la maggior parte delle regole per trasportare il codice all’interno della gamma che va da PHP 8.0 a 7.1. Quindi possiamo effettuare il transpiling del nostro codice PHP in modo affidabile fino alla versione 7.1.

Ci sono anche regole per la compilazione da PHP 7.1 a 7.0 e da 7.0 a 5.6, ma queste non sono esaustive. Si sta lavorando per completarle, così alla fine potremo effettuare il transpiling del codice PHP fino alla versione 5.6.

Transpiling e Backporting

Il backporting è simile al transpiling, ma più semplice. Il codice di backporting non si basa necessariamente su nuove funzionalità di un linguaggio, perché la stessa funzionalità può essere fornita ad una vecchia versione del linguaggio semplicemente copiando/incollando/adattando il codice corrispondente dalla nuova versione del linguaggio.

Ad esempio, la funzione str_contains è stata introdotta in PHP 8.0. La stessa funzione per PHP 7.4 e successivi può essere facilmente implementata in questo modo:

if (!defined('PHP_VERSION_ID') || (defined('PHP_VERSION_ID') && PHP_VERSION_ID < 80000)) {
  if (!function_exists('str_contains')) {
    /**
     * Checks if a string contains another
     *
     * @param string $haystack The string to search in
     * @param string $needle The string to search
     * @return boolean Returns TRUE if the needle was found in haystack, FALSE otherwise.
     */
    function str_contains(string $haystack, string $needle): bool
    {
      return strpos($haystack, $needle) !== false;
    }
  }
}

Dato che il backporting è più semplice del transpiling, dovremmo optare per questa soluzione ogni volta che il backporting può fare il lavoro.

Per quanto riguarda il range tra PHP 8.0 e 7.1, possiamo utilizzare le librerie polyfill di Symfony:

Queste librerie eseguono il backport delle seguenti funzioni, classi, costanti e interfacce:

Versione PHP Funzionalità
7.2 Funzioni:

Costanti:

7.3 Funzioni:

Eccezioni:

7.4 Funzioni:
8.0 Interfacce:
  • Stringable

Classi:

  • ValueError
  • UnhandledMatchError

Costanti:

  • FILTER_VALIDATE_BOOL

Funzioni:

Esempi di Transpiling di PHP

Esaminiamo alcuni esempi di transpiling del codice PHP e alcuni pacchetti su cui è stato eseguito il transpiling.

Codice PHP

L’espressione match è stata introdotta in PHP 8.0. Questo codice sorgente:

function getFieldValue(string $fieldName): ?string
{
  return match($fieldName) {
    'foo' => 'foofoo',
    'bar' => 'barbar',
    'baz' => 'bazbaz',
    default => null,
  };
}

…sarà tradotto nella sua versione equivalente in PHP 7.4, utilizzando l’operatore switch:

function getFieldValue(string $fieldName): ?string
{
  switch ($fieldName) {
    caso 'pippo':
      ritorna 'foofoo';
    caso 'bar':
      ritorna 'barbar';
    caso 'baz':
      restituisce 'bazbaz';
    predefinito:
      restituisce 'null';
  }
}

Anche l’operatore nullsafe è stato introdotto in PHP 8.0:

public function getValue(TypeResolverInterface $typeResolver): ?string
{
  return $this->getResolver($typeResolver)?->getValue();
}

Il codice tradotto ha bisogno di assegnare prima il valore dell’operazione ad una nuova variabile, per evitare di eseguire l’operazione due volte:

public function getValue(TypeResolverInterface $typeResolver): ?string
{
  return ($val = $this->getResolver($typeResolver)) ? $val->getValue() : null;
}

La promozione delle proprietà del costruttore, anche questa introdotta in PHP 8.0, permette agli sviluppatori di scrivere meno codice:

classe QueryResolver
{
  function __construct(protected QueryFormatter $queryFormatter)
  {
  }
}

Quando si effettua il transpiling a PHP 7.4, viene prodotto il codice completo:

 classe QueryResolver
 {
  protected QueryFormatter $queryFormatter;

  funzione __construct(QueryFormatter $queryFormatter)
  {
    $this->queryFormatter = $queryFormatter;
  }
}

Il codice tradotto qui sopra contiene proprietà tipizzate, introdotte in PHP 7.4. Traducendo quel codice a PHP 7.3, queste sono sostituite con i docblock:

 class QueryResolver
 {
  /**
   * @var QueryFormatter
   */
  protected $queryFormatter;

  function __construct(QueryFormatter $queryFormatter)
  {
    $this->queryFormatter = $queryFormatter;
  }
}

Pacchetti PHP

Le seguenti librerie sono state tradotte per la produzione:

Libreria/descrizione Codice/note
Rector
Strumento di ricostruzione PHP che rende possibile il transpiling
Codice sorgente
Codice tradotto
Note
Easy Coding Standards
Strumento per far corrispondere il codice PHP ad un insieme di regole
Codice sorgente
Codice tradotto
Note
API GraphQL per WordPress
Plugin che fornisce un server GraphQL per WordPress
Codice sorgente
Codice tradotto
Note

Pro e Contro del Transpiling di PHP

Il vantaggio del transpiling di PHP è già stato descritto: permette al codice sorgente di utilizzare PHP 8.0 (cioè l’ultima versione di PHP), che sarà trasformato in una versione inferiore di PHP per la produzione, da eseguire in un’applicazione o ambiente legacy.

Questo ci permette effettivamente di diventare sviluppatori migliori, producendo codice di qualità superiore, perché il nostro codice sorgente può utilizzare i tipi unione di PHP 8.0, le proprietà tipizzate di PHP 7.4 e i diversi tipi e pseudo-tipi aggiunti ad ogni nuova versione di PHP (mixed da PHP 8.0, object da PHP 7.2), tra le altre caratteristiche moderne di PHP.

Usando queste funzionalità, possiamo catturare meglio i bug durante lo sviluppo e scrivere codice più leggibile.

Ora diamo un’occhiata agli svantaggi.

Deve Essere Codificato e Mantenuto

Rector può eseguire il transpiling del codice automaticamente, ma il processo richiederà probabilmente qualche input manuale per adattarlo alla nostra configurazione specifica.

Anche le Librerie di Terze Parti Devono Essere Tradotte

Questo diventa un problema ogni volta che il loro traspiling produce degli errori, perché dobbiamo poi scavare nel codice sorgente per scoprirne le cause. Se il problema può essere risolto e il progetto è open source, dovremo inviare una richiesta di pull. Se la libreria non è open source, potremmo imbatterci in un blocco stradale.

Rector Non Ci Dice Quando Non Si Può Eseguire il Transpiling del Codice

Se il codice sorgente contiene attributi di PHP 8.0 o qualsiasi altra funzionalità che non può essere oggetto di transpiling, non possiamo procedere. Tuttavia, Rector non controlla questa condizione, quindi dobbiamo farlo manualmente. Potrebbe non essere un grosso problema per quanto riguarda il nostro codice sorgente, perché abbiamo già familiarità con questo, ma potrebbe diventare un ostacolo per le dipendenze di terzi.

Le Informazioni di Debugging Utilizzano il Codice Tradotto, Non il Codice Sorgente

Quando l’applicazione produce un messaggio di errore con uno stack trace in produzione, il numero di linea punterà al codice tradotto. Per trovare il numero di linea corrispondente nel codice sorgente, abbiamo bisogno di riconvertire dal codice tradotto al codice originale.

Il Codice Tradotto Deve Avere Anche il Prefisso

Il nostro progetto tradotto e qualche altra libreria installata nell’ambiente di produzione potrebbero utilizzare la stessa dipendenza di terze parti. Questa dipendenza di terze parti sarà tradotta per il nostro progetto e manterrà il suo codice sorgente originale per l’altra libreria. Quindi alla versione tradotta deve essere aggiunto un prefisso tramite PHP-Scoper, Strauss o qualche altro strumento per evitare possibili conflitti.

Il Transpiling Deve Avvenire Durante l’Integrazione Continua (CI)

Dato che il codice tradotto sovrascriverà naturalmente il codice sorgente, non dovremmo eseguire il transpiling sui nostri computer di sviluppo, o rischieremo di creare effetti collaterali. Eseguire il processo durante un’esecuzione di CI è più corretto (maggiori informazioni in seguito).

Come Eseguire il Transpiling di PHP

Per prima cosa dobbiamo installare Rector nel nostro progetto di sviluppo:

composer require rector/rector --dev

Poi creiamo un file di configurazione rector.php nella directory principale del progetto contenente l’insieme di regole necessarie. Per effettuare il downgrade del codice da PHP 8.0 a 7.1, utilizziamo questa configurazione:

use Rector\SetValueObject\DowngradeSetList;
usa Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return static function (ContainerConfigurator $containerConfigurator): void {
    $containerConfigurator->import(DowngradeSetList::PHP_80);
    $containerConfigurator->import(DowngradeSetList::PHP_74);
    $containerConfigurator->import(DowngradeSetList::PHP_73);
    $containerConfigurator->import(DowngradeSetList::PHP_72);
};

Per assicurarci che il processo venga eseguito come previsto, possiamo eseguire il comando process di Rector in modalità dry, passando la posizione o le posizioni da processare (in questo caso, tutti i file nella cartella src/):

vendor/bin/rector process src --dry-run

Per eseguire il transpiling, eseguiamo il comando process di Rector, che modificherà i file nella loro posizione esistente:

vendor/bin/rector process src

Nota bene: se eseguiamo rector process nei nostri computer di sviluppo, il codice sorgente sarà convertito sul posto, sotto src/. Tuttavia, vogliamo produrre il codice convertito in una posizione diversa per non sovrascrivere il codice sorgente durante il downgrade. Per questo motivo, l’esecuzione del processo è più corretto durante l’integrazione continua.

Ottimizzare il Processo di Transpiling

Per generare un deliverable tradotto per la produzione, deve essere convertito solo il codice per la produzione; il codice necessario solo per lo sviluppo può essere saltato. Questo significa che possiamo evitare di eseguire il transpiling di tutti i test (sia per il nostro progetto che per le sue dipendenze) e tutte le dipendenze per lo sviluppo.

Per quanto riguarda i test, sapremo già dove si trovano quelli per il nostro progetto – per esempio, nella cartella tests/. Dobbiamo anche scoprire dove sono quelli per le dipendenze – per esempio, nelle sottocartelle tests/, test/ e Test/ (per le diverse librerie). Poi diciamo a Rector di saltare l’elaborazione di queste cartelle:

return static function (ContainerConfigurator $containerConfigurator): void {
  // ...

  $parameters->set(Option::SKIP, [
    // Skip tests
    '*/tests/*',
    '*/test/*',
    '*/Test/*',
  ]);
};

Per quanto riguarda le dipendenze, Composer sa quali sono per lo sviluppo (quelle sotto la voce require-dev in composer.json) e quali sono per la produzione (quelle sotto la voce require).

Per recuperare da Composer i percorsi di tutte le dipendenze per la produzione, eseguiamo:

composer info --path --no-dev

Questo comando produrrà un elenco di dipendenze con nome e percorso, come questo:

brain/cortex                     /Users/leo/GitHub/leoloso/PoP/vendor/brain/cortex
composer/installers              /Users/leo/GitHub/leoloso/PoP/vendor/composer/installers
composer/semver                  /Users/leo/GitHub/leoloso/PoP/vendor/composer/semver
guzzlehttp/guzzle                /Users/leo/GitHub/leoloso/PoP/vendor/guzzlehttp/guzzle
league/pipeline                  /Users/leo/GitHub/leoloso/PoP/vendor/league/pipeline

Possiamo estrarre tutti i percorsi e inserirli nel comando Rector, che elaborerà la cartella src/ del nostro progetto più le cartelle contenenti tutte le dipendenze per la produzione:

$ paths="$(composer info --path --no-dev | cut -d' ' -f2- | sed 's/ //g' | tr '\n' ' ')"
$ vendor/bin/rector process src $paths

Un ulteriore miglioramento può impedire a Rector di elaborare quelle dipendenze che già utilizzano la versione PHP di destinazione. Se una libreria è stata codificata con PHP 7.1 (o qualsiasi versione inferiore), allora non c’è bisogno di effettuare il transpiling a PHP 7.1.

Per questo, possiamo ottenere l’elenco delle librerie che richiedono PHP 7.2 e superiori ed elaborare solo quelle. Otterremo i nomi di tutte queste librerie tramite il comando why-not di Composer, in questo modo:

composer why-not php "7.1.*" | grep -o "\S*\/\S*"

Dato che questo comando non funziona con il flag --no-dev, per includere solo le dipendenze per la produzione dobbiamo prima rimuovere le dipendenze per lo sviluppo e rigenerare l’autoloader, eseguire il comando e poi aggiungerle nuovamente:

$ composer install --no-dev
$ packages=$(composer why-not php "7.1.*" | grep -o "\S*\/\S*")
$ composer install

Il comando info --path di Composer recupera il percorso di un pacchetto, con questo formato:

# Executing this command
$ composer info psr/cache --path   
# Produces this response:
psr/cache /Users/leo/GitHub/leoloso/PoP/vendor/psr/cache

Eseguiamo questo comando per tutti gli elementi della nostra lista per ottenere tutti i percorsi da convertire:

for package in $packages
do
  path=$(composer info $package --path | cut -d' ' -f2-)
  paths="$paths $path"
done

Infine, forniamo questa lista a Rector (più la cartella src/ del progetto):

vendor/bin/rector process src $paths

Trappole da Evitare Quando si Esegue il Transpiling del Codice

Il Transpiling del codice potrebbe essere considerato un’arte che spesso richiede modifiche specifiche al progetto. Vediamo alcuni problemi che potremmo riscontrare.

Le Regole Concatenate non Sono Sempre Elaborate

Una regola concatenata è una regola che ha bisogno di convertire il codice prodotto da una regola precedente.

Ad esempio, la libreria symfony/cache contiene questo codice:

final class CacheItem implements ItemInterface
{
  public function tag($tags): ItemInterface
  {
    // ...
    return $this;
  }
}

Quando si esegue il transpiling da PHP 7.4 a 7.3, la funzione tag deve subire due modifiche:

Il risultato finale dovrebbe essere questo:

final class CacheItem implements ItemInterface
{
  public function tag($tags)
  {
    // ...
    return $this;
  }
}

Tuttavia, Rector emette solo la fase intermedia:

final class CacheItem implements ItemInterface
{
  public function tag($tags): self
  {
    // ...
    return $this;
  }
}

Il problema è che Rector non può sempre controllare l’ordine in cui vengono applicate le regole.

La soluzione è quella di identificare le regole concatenate che rimangono non elaborate ed eseguire di nuovo Rector per applicarle.

Per identificare le regole concatenate, eseguiamo Rector due volte sul codice sorgente, in questo modo:

$ vendor/bin/rector process src
$ vendor/bin/rector process src --dry-run

La prima volta, eseguiamo Rector come previsto, per eseguire il transpiling. La seconda volta, usiamo il flag --dry-run per scoprire se ci sono ancora modifiche da fare. Se ci sono, il comando uscirà con un codice di errore e l’output “diff” indicherà le regole che possono ancora essere applicate. Questo significherebbe che la prima esecuzione non è stata completata, e alcune regole concatenate non sono state elaborate.

Esecuzione di Rector con il flag --dry-run
Esecuzione di Rector con il flag –dry-run

Una volta che abbiamo identificato la regola (o le regole) non applicate, possiamo creare un altro file di configurazione di Rector – per esempio, rector-chained-rule.php eseguirà la regola mancante. Invece di elaborare una serie completa di regole per tutti i file sotto src/, questa volta possiamo eseguire la regola mancante sul file specifico dove deve essere applicata:

// rector-chained-rule.php
use Rector\Core\Configuration\Option;
use Rector\DowngradePhp74\Rector\ClassMethod\DowngradeSelfTypeDeclarationRector;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return static function (ContainerConfigurator $containerConfigurator): void {
  $services = $containerConfigurator->services();
  $services->set(DowngradeSelfTypeDeclarationRector::class);

  $parameters = $containerConfigurator->parameters();
  $parameters->set(Option::PATHS, [
    __DIR__ . '/vendor/symfony/cache/CacheItem.php',
  ]);
};

Infine, diciamo a Rector di utilizzare nel suo secondo passaggio il nuovo file di configurazione tramite l’input --config:

# First pass with all modifications
$ vendor/bin/rector process src

# Second pass to fix a specific problem
$ vendor/bin/rector process --config=rector-chained-rule.php

Le Dipendenze di Composer Possono Essere Incoerenti

Le librerie potrebbero dichiarare che una dipendenza è destinata allo sviluppo (cioè sotto require-dev in composer.json), ma comunque fare riferimento a codice proveniente da queste per la produzione (ad esempio su alcuni file in src/, non tests/).

Di solito, questo non è un problema perché quel codice potrebbe non essere caricato in produzione, quindi non ci sarà mai un errore nell’applicazione. Tuttavia, quando Rector elabora il codice sorgente e le sue dipendenze, conferma che tutto il codice referenziato può essere caricato. Rector darà un errore se un qualunque file fa riferimento a qualche blocco di codice di una libreria non installata (perché è stato dichiarato necessario solo per lo sviluppo).

Per esempio, la classe EarlyExpirationHandler del componente Cache di Symfony implementa l’interfaccia MessageHandlerInterface del componente Messenger:

class EarlyExpirationHandler implements MessageHandlerInterface
{
    //...
}

Tuttavia, symfony/cache dichiara symfony/messenger come dipendenza per lo sviluppo. Quindi, quando si esegue Rector su un progetto che dipende da symfony/cache, verrà emesso un errore:

[ERROR] Could not process "vendor/symfony/cache/Messenger/EarlyExpirationHandler.php" file, due to:             
  "Analyze error: "Class Symfony\Component\Messenger\Handler\MessageHandlerInterface not found.". Include your files in "$parameters->set(Option::AUTOLOAD_PATHS, [...]);" in "rector.php" config.
  See https://github.com/rectorphp/rector#configuration".   

Ci sono tre soluzioni a questo problema:

  1. Nella configurazione di Rector, saltare l’elaborazione del file che fa riferimento a quel blocco di codice:
return static function (ContainerConfigurator $containerConfigurator): void {
  // ...

  $parameters->set(Option::SKIP, [
    __DIR__ . '/vendor/symfony/cache/Messenger/EarlyExpirationHandler.php',
  ]);
};
  1. Scaricare la libreria mancante e aggiungere il suo percorso in modo che venga caricata automaticamente da Rector:
return static function (ContainerConfigurator $containerConfigurator): void {
  // ...

  $parameters->set(Option::AUTOLOAD_PATHS, [
    __DIR__ . '/vendor/symfony/messenger',
  ]);
};
  1. Fare in modo che il progetto dipenda dalla libreria mancante per la produzione:
composer require symfony/messenger

Traspiling e Integrazione Continua

Come accennato in precedenza, quando eseguiamo Rector nei nostri computer di sviluppo dobbiamo utilizzare il flag --dry-run, altrimenti il codice sorgente verrà sovrascritto con il codice tradotto. Per questo motivo, è meglio eseguire l’effettivo transpiling durante l’integrazione continua (CI), durante la quale per eseguire il processo possiamo far girare dei runner temporanei.

Un momento ideale per eseguire il processo di transpiling è quando si genera la release del progetto. Ad esempio, il codice qui sotto è un workflow per GitHub Actions, che crea la release di un plugin WordPress:

name: Generate Installable Plugin and Upload as Release Asset
on:
  release:
    types: [published]
jobs:
  build:
    name: Build, Downgrade and Upload Release
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2
      - name: Downgrade code for production (to PHP 7.1)
        run: |
          composer install
          vendor/bin/rector process
          sed -i 's/Requires PHP: 7.4/Requires PHP: 7.1/' graphql-api.php
      - name: Build project for production
        run: |
          composer install --no-dev --optimize-autoloader
          mkdir build
      - name: Create artifact
        uses: montudor/[email protected]
        with:
          args: zip -X -r build/graphql-api.zip . -x *.git* node_modules/\* .* "*/\.*" CODE_OF_CONDUCT.md CONTRIBUTING.md ISSUE_TEMPLATE.md PULL_REQUEST_TEMPLATE.md rector.php *.dist composer.* dev-helpers** build**
      - name: Upload artifact
        uses: actions/upload-artifact@v2
        with:
            name: graphql-api
            path: build/graphql-api.zip
      - name: Upload to release
        uses: JasonEtco/upload-to-release@master
        with:
          args: build/graphql-api.zip application/zip
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Questo flusso di lavoro contiene una procedura standard per la release di un plugin WordPress tramite GitHub Actions. La nuova aggiunta, per il transpiling del codice del plugin da PHP 7.4 a 7.1, avviene in questo passaggio:

      - name: Downgrade code for production (to PHP 7.1)
        run: |
          vendor/bin/rector process
          sed -i 's/Requires PHP: 7.4/Requires PHP: 7.1/' graphql-api.php

Preso tutto insieme, questo workflow ora esegue i seguenti passaggi:

  1. Controlla il codice sorgente di un plugin WordPress scritto con PHP 7.4 dalla sua repository
  2. Installa le sue dipendenze Composer
  3. Esegue il transpiling del codice da PHP 7.4 a 7.1
  4. Modifica la voce “Requires PHP” nell’intestazione del file principale del plugin da "7.4" a "7.1"
  5. Rimuove le dipendenze necessarie allo sviluppo
  6. Crea il file .zip del plugin, escludendo tutti i file non necessari
  7. Carica il file .zip come risorsa di release (e, in aggiunta, come artefatto alla GitHub Action)

Testare il Codice di Transpiling

Una volta eseguito il transpiling del codice a PHP 7.1, come facciamo a sapere che funziona correttamente? Oppure, in altre parole, come facciamo a sapere che è stato convertito completamente e che non sono stati lasciati residui di versioni superiori del codice PHP?

In modo simile al transpiling del codice, possiamo implementare la soluzione all’interno di un processo CI. L’idea è quella di impostare l’ambiente del runner con PHP 7.1 ed eseguire un linter sul codice ottenuto dal transpiling. Se qualche frammento di codice non è compatibile con PHP 7.1 (come una proprietà tipizzata di PHP 7.4 che non è stata convertita), allora il linter lancerà un errore.

Un linter PHP che funziona bene è PHP Parallel Lint. Possiamo installare questa libreria come dipendenza per lo sviluppo nel nostro progetto, o fare in modo che il processo CI la installi come progetto Composer indipendente:

composer create-project php-parallel-lint/php-parallel-lint

Ogni volta che il codice contiene PHP 7.2 e superiore, PHP Parallel Lint lancia un errore come questo:

Run php-parallel-lint/parallel-lint layers/ vendor/ --exclude vendor/symfony/polyfill-ctype/bootstrap80.php --exclude vendor/symfony/polyfill-intl-grapheme/bootstrap80.php --exclude vendor/symfony/polyfill-intl-idn/bootstrap80.php --exclude vendor/symfony/polyfill-intl-normalizer/bootstrap80.php --exclude vendor/symfony/polyfill-mbstring/bootstrap80.php
PHP 7.1.33 | 10 parallel jobs
............................................................   60/2870 (2 %)
............................................................  120/2870 (4 %)
...
............................................................  660/2870 (22 %)
.............X..............................................  720/2870 (25 %)
............................................................  780/2870 (27 %)
...
............................................................ 2820/2870 (98 %)
..................................................           2870/2870 (100 %)


Checked 2870 files in 15.4 seconds
Syntax error found in 1 file

------------------------------------------------------------
Parse error: layers/GraphQLAPIForWP/plugins/graphql-api-for-wp/graphql-api.php:55
    53|     '0.8.0',
    54|     \__('GraphQL API for WordPress', 'graphql-api'),
  > 55| ))) {
    56|     $plugin->setup();
    57| }
Unexpected ')' in layers/GraphQLAPIForWP/plugins/graphql-api-for-wp/graphql-api.php on line 55
Error: Process completed with exit code 1.

Aggiungiamo il linter nel flusso di lavoro della nostra CI. I passaggi da eseguire per convertire il codice da PHP 8.0 a 7.1 e testarlo sono:

  1. Controllare il codice sorgente
  2. Far eseguire all’ambiente PHP 8.0, così Rector può interpretare il codice sorgente
  3. Convertire il codice in PHP 7.1
  4. Installare lo strumento PHP linter
  5. Cambiare la versione PHP dell’ambiente a 7.1
  6. Eseguire il linter sul codice convertito

Questo workflow di GitHub Action fa tutto il lavoro:

name: Downgrade PHP tests
jobs:
  main:
    name: Downgrade code to PHP 7.1 via Rector, 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: 8.0
          coverage: none

      - name: Local packages - Downgrade PHP code via Rector
        run: |
          composer install
          vendor/bin/rector process

      # Prepare for testing on PHP 7.1
      - name: Install PHP Parallel Lint
        run: composer create-project php-parallel-lint/php-parallel-lint --ansi

      - name: Switch to PHP 7.1
        uses: shivammathur/setup-php@v2
        with:
          php-version: 7.1
          coverage: none

      # Lint the transpiled code
      - name: Run PHP Parallel Lint on PHP 7.1
        run: php-parallel-lint/parallel-lint src/ vendor/ --exclude vendor/symfony/polyfill-ctype/bootstrap80.php --exclude vendor/symfony/polyfill-intl-grapheme/bootstrap80.php --exclude vendor/symfony/polyfill-intl-idn/bootstrap80.php --exclude vendor/symfony/polyfill-intl-normalizer/bootstrap80.php --exclude vendor/symfony/polyfill-mbstring/bootstrap80.php

Si noti che diversi file bootstrap80.php delle librerie polyfill di Symfony (che non hanno bisogno di essere convertiti) devono essere esclusi dal linter. Questi file contengono PHP 8.0, quindi il linter potrebbe dare degli errori quando li processa. Tuttavia, escludere questi file è sicuro poiché saranno caricati in produzione solo quando si utilizza PHP 8.0 o superiore:

se (\PHP_VERSION_ID >= 80000) {
  return require __DIR__.'/bootstrap80.php';
}

Riepilogo

In questo articolo abbiamo mostrato come eseguire il transpiling del nostro codice PHP al fine di utilizzare PHP 8.0 nel codice sorgente e creare una release che funziona anche su PHP 7.1. Il transpiling viene eseguito tramite Rector, un reconstructor PHP.

Eseguire il transpiling del codice ci permette di catturare meglio i bug nello sviluppo e produrre codice naturalmente più facile da leggere e comprendere.

Il transpiling ci permette anche di disabbinare il nostro codice da CMS con specifici requisiti PHP. Ora possiamo farlo se desideriamo utilizzare l’ultima versione di PHP per creare un plugin WordPress o un modulo Drupal e renderli disponibili al pubblico senza limitare gravemente la nostra base di utenti.

Vi è rimasta qualche domanda sul transpiling di PHP? Scrivetele nella sezione dei commenti!

Leonardo Losoviz

Leo writes about innovative web development trends, mostly concerning PHP, WordPress and GraphQL. You can find him at leoloso.com and twitter.com/losoviz.