Ob bereit oder nicht, es geht los

Ob bereit oder nicht, es geht los

Die Entwicklung von PHP 8.1 hat vor kurzem den Meilenstein des "Feature Freeze" erreicht. Der Fokus liegt nun auf der Stabilisierung, neue Funktionen werden nun für PHP 8.2 entwickelt. Jetzt ist es an der Zeit, dafür zu sorgen, dass Ihre Software mit PHP 8.1 funktioniert. Ganz gleich, ob Sie Open Source-Software entwickeln oder Software für Ihr Unternehmen oder Ihre Kunden.

In diesem Artikel möchte ich darüber sprechen, wie eine Änderung, die in PHP 7.4 vorgenommen wurde plötzlich die Funktionsweise des PHPUnit-PHARs aufgrund einer Änderung, die kürzlich in PHP 8.1 vorgenommen wurde beeinflusst hat.

Diese jüngste Änderung fügte zusätzliche Typinformationen für von PHP bereitgestellten Typen wie Iterator und IteratorAggregate hinzu. Hier ist eine Auswahl von Problemen, die zeigen, wie diese Änderung einen Dominoeffekt im PHP-Ökosystem ausgelöst hat:

Ich freue mich, dass mehr Typinformationen hinzugefügt werden, und übernehme gerne die Aufgabe, meine Software mit PHP 8.1 kompatibel zu machen. Die verbesserte Typsicherheit ist die Mühe wert.

Ich war allerdings verwirrt durch die Tatsache, dass diese Änderungen dazu führten, dass das PHPUnit-PHAR nicht mehr funktionierte:

Fatal error: Could not check compatibility between PharIo\Manifest\AuthorCollection::getIterator(): PharIo\Manifest\AuthorCollectionIterator and IteratorAggregate::getIterator(): Traversable, because class PharIo\Manifest\AuthorCollectionIterator is not available in ...

Ich hatte das PHP 8.1 Kompatibilitätsproblem im phar-io/manifest Paket bereits behoben. Warum tauchte dieses Problem wieder auf?

Dann habe ich die Fehlermeldung noch einmal gelesen und mir wurde klar, dass es sich um ein gänzlich anderes Problem handelte: Der PHP-Compiler konnte die Klasse AuthorCollectionIterator beim Übersetzen der Quellcode-Datei, die die Klasse AuthorCollection deklariert, nicht erkennen.

Typbeziehungen können knifflig sein

Wenn eine Klasse eine Schnittstelle implementiert, dann muss für jede Methode, die von dieser Schnittstelle deklariert und in dieser Klasse implementiert wird, die Kompatibilität der Signatur dieser Methode geprüft werden. Dies gilt auch für Methoden, die in einer Klasse überschrieben werden, die eine andere Klasse erweitert.

Eine Methode einer Schnittstelle, die in einer Klasse implementiert ist (oder in einer Klasse überschrieben wird, die eine andere Klasse erweitert), kann kontravariant sein und einen allgemeineren Parameter akzeptieren sowie kovariant sein und einen spezifischeren Typ zurückgeben:

class   AuthorCollection   implements   IteratorAggregate
{
     // ...
 
     public   function   getIterator ( ) :   AuthorCollectionIterator
     {
         return   new   AuthorCollectionIterator ( $this ) ;
     }
}
class   AuthorCollectionIterator   implements   Iterator
{
     public   function   __construct ( AuthorCollection   $authors )
     {
         $this -> authors   =   $authors -> getAuthors ( ) ;
     }
 
     // ...
}

Das PHPUnit-PHAR funktionierte nicht mehr, weil die Kontravarianz von Parametertypen und die Kovarianz von Rückgabetypen nur dann vollständig unterstützt werden, wenn alle beteiligten Schnittstellen und Klassen verfügbar sind, bevor sie referenziert werden.

Der Compiler übersetzt AuthorCollection.php, sieht, dass die Klasse AuthorCollection die Schnittstelle IteratorAggregate implementiert, sieht, dass AuthorCollection::getIterator() AuthorCollectionIterator zurückgibt, will prüfen, ob AuthorCollection::getIterator() mit IteratorAggregate::getIterator() kompatibel ist, kann diese Kompatibilitätsprüfung aber nicht durchführen, weil AuthorCollectionIterator noch nicht übersetzt wurde.

An dieser Stelle fragen Sie sich wahrscheinlich: Warum unterbricht der PHP-Compiler die Übersetzung von AuthorCollection.php nicht und verwendet Autoloading, um die Kompilierung von AuthorCollectionIterator auszulösen? Nun ...

Unter der Haube des PHPUnit-PHAR

Um Probleme zu vermeiden, die auftreten, wenn der zu testende Code Abhängigkeiten mit PHPUnit teilt, aber andere Versionen benötigt als die, die im PHAR gebündelt sind, wurden einige Maßnahmen implementiert.

Die meisten Codeeinheiten, die in der PHAR-Distribution von PHPUnit gebündelt sind, einschließlich aller Abhängigkeiten, wie beispielsweise vendor Verzeichnisse, werden in einen neuen und eindeutigen Namespace verschoben. Klassen wie PHPUnit\Framework\TestCase, die Teil der öffentlichen API von PHPUnit sind, sind hiervon ausgenommen.

Außerdem verwendet das PHPUnit-PHAR kein dynamisches Autoloading, um die gebündelten Codeeinheiten zu laden. Stattdessen werden alle im PHAR gebündelten Codeeinheiten beim Start geladen. Bis vor kurzem wurde dies mit Hilfe einer langen Liste von require-Anweisungen umgesetzt:

// ...
require   'phar://phpunit.phar'   .   '/AuthorCollection.php' ;
require   'phar://phpunit.phar'   .   '/AuthorCollectionIterator.php' ;
// ...

Die Pfade im oben gezeigten Code wurden der Lesbarkeit halber gekürzt.

Die require Anweisungen wurden mit Hilfe von statischer Analyse sowie einer topologischen Sortierung der implements, extends und uses Beziehungen zwischen den Schnittstellen, Klassen und Traits erstellt.

Dies funktionierte gut, solange der Quellcode von PHPUnit und seinen Abhängigkeiten nur triviale Subtyp-Beziehungen zwischen den Codeeinheiten enthielt. Dies änderte sich, als PHP 8.1 Rückgabetypen für Iterator und IteratorAggregate hinzufügte.

Probleme, wohin man schaut

Meine erste Idee war die Verwendung von dynamischem Autoloading statt statischem Preloading und mit einem Fehler abzubrechen, wenn das PHAR verwendet wird, PHPUnit aber auch mit Composer installiert wurde. Letzteres ist notwendig, weil Composer den von ihm erzeugten Autoloader an den Anfang der Autoloader-Kette stellt. Dies führt zu Konflikten, die von PHPUnit nur erkannt, von PHPUnit aber nicht aufgelöst werden können.

Es hat sich jedoch herausgestellt, dass dies zu neuen Problemen führt. Juliette Reinders Folmer kommentiert kommentierte:

Die meisten Projekte, an denen ich arbeite, haben PHPUnit in ihrem Composer require-dev, aber da sich ein großer Teil meiner Arbeit auf die Kompatibilität zwischen verschiedenen PHP-Versionen konzentriert, führe ich die Tests lokal aus, indem ich ein PHAR für die PHP-Version verwende, die ich zu diesem Zeitpunkt testen muss.

Eine Möglichkeit, dies zu umgehen, wäre gewesen, Composer so zu konfigurieren, dass er seinen Autoloader nicht mit einer höheren Priorität als die des vom PHPUnit-PHAR registrierten Autoloaders registriert. Leider muss dies jedoch in der Datei composer.json geschehen, die in den meisten Fällen nicht unter Juliettes Kontrolle steht, so dass ihr dieser Ansatz nicht hilft.

Eine andere Möglichkeit wäre gewesen, den Autoloader von PHPUnit erneut zu registrieren, nachdem das konfigurierte Bootstrap-Skript geladen wurde. Dies funktionierte jedoch nicht wie erhofft.

Ich war mit dieser Situation nicht zufrieden. Einerseits konnte ich mich nicht dazu durchringen, die Änderung rückgängig zu machen, dass ein Fehler auftritt, wenn das PHAR verwendet wird und PHPUnit ebenfalls mit Composer installiert wurde. Andererseits hatte ich das Gefühl, dass ich Juliette mit dem Problem, das diese Änderung für sie verursachte, allein ließ.

Gemeinsam die Dinge verbessern

In einem Gespräch mit Arne Blankerts erklärte ich:

Aufgrund dieser Änderung in PHP 8.1 können wir kein statisches Preloading mehr verwenden. Wir müssen dynamisches Autoloading verwenden. Aber das verursacht andere Probleme ...

In diesem Moment unterbrach mich Arne und sagte:

Warte mal! Du vergisst, dass Du statisches Preloading und dynamisches Autoloading kombinieren kannst und in diesem Fall auch solltest.

Das war eine großartige Idee und ziemlich einfach zu implementieren.

Anstatt das statische Preloading durch dynamisches Autoloading zu ersetzen, kombinieren wir nun das Beste aus beiden Welten. Das PHAR lädt immer noch alle darin gebündelten Quellcodedateien beim Start, wird dabei aber von einem dynamischen Autoloader unterstützt. So kann der PHP-Compiler Codeeinheiten finden, die noch nicht geladen wurden, um beispielsweise die Kompatibilität von Methodensignaturen zu überprüfen.

Ich bin Juliette dankbar, dass sie mich so hartnäckig dazu gebracht hat, eine bessere Lösung zu finden. Und natürlich bin ich Arne dankbar, dass er die richtige Idee zur richtigen Zeit hatte. Das ist für mich das, worum es bei Open Source geht: zusammenzuarbeiten, um unsere Software für alle Beteiligten besser zu machen.

Was bedeutet das für Sie?

Sie fragen sich vielleicht: Was bedeutet das für mich? Meine Software muss doch sicher nicht mit solchen seltsamen Edge Cases arbeiten.

Nun, ich betrachte diesen Code nicht als einen Edge Case:

class   A
{
     public   function   method ( ) :   B
     {
     }
}
class   B   extends   A
{
     public   function   method ( ) :   C
     {
     }
}
class   C   extends   B
{
}

In seiner Antwort auf eine Frage von mir bezüglich des statischen Preloading und der Kompatibilitätsprüfung von Methoden lieferte der PHP Core Developer Nikita Popov das oben gezeigte Beispiel. Er erklärte:

Es gibt keine gültige Reihenfolge, in der diese Klassen ohne dynamisches Autoloading geladen werden können.

Ich würde es Ihnen nicht verübeln, wenn Sie den Eindruck haben, dass die technischen Details, die in diesem Artikel besprochen werden, für Ihre Arbeit nicht relevant sind. Aber sie sind relevant und wichtig, wenn Sie das in PHP 7.4 eingeführte Preloading nutzen wollen. Folgen Sie diesem Link und lesen Sie, was wir in unserem eBook PHP 7 Explained über die Verbesserung der Leistung Ihrer Anwendung mit Preloading geschrieben haben.

Was ich 2016 geschrieben habe, als der aktive Support für PHP 5 endete, gilt noch immer:

Bestehende Systeme altern in erster Linie, weil sich die Technik weiterentwickelt. Langfristig ist der sichere und reibungslose Betrieb von Software nur auf aktuellen Systemen möglich. [...] Ein Upgrade der von Ihnen verwendeten PHP-Version darf kein seltenes Ereignis sein, vor dem Sie sich fürchten. Sie dürfen das Upgrade Ihres PHP-Stacks nicht als „besonderes Projekt“ betrachten. Sie müssen das Upgrade der von Ihnen verwendeten PHP-Version zu einem Teil Ihrer normalen Arbeitsabläufe machen und den Upgrade-Zyklus Ihres PHP-Stacks mit dem Release-Zyklus des PHP-Projekts abstimmen.

Die Entwicklung von PHP 8.1 ist nun auf die Stabilisierung ausgerichtet. Jetzt ist es an der Zeit, Ihre Software mit den Betaversionen und Release Candidates von PHP 8.1 zu testen. Die Chancen stehen gut, dass dies eher langweilig sein wird: Ihre Software funktioniert einfach weiter mit PHP 8.1. Großartig! Aber wenn Ihre Software Änderungen erfordert, um mit PHP 8.1 zu funktionieren, können Sie sich umso eher darauf einstellen, je früher Sie es wissen.