Lazy Objects in einem PSR-11 kompatiblen Dependency Injection Container mit PHP 8.4

Ich weiß, ich bin ein bisschen zu spät zur Party erschienen. Immerhin wurde PHP 8.5 bereits veröffentlicht und Lazy Objects gibt es bereits seit PHP 8.4. Dennoch möchte ich in diesem Artikel beschreiben, wie ihr Euren eigenen PSR-11 kompatiblen Dependency Injection Container programmieren könnt.

Was sind Lazy Objects in PHP?

Mit PHP 8.4 wurden Lazy Objects in die Reflection API implementiert. Dabei handelt es sich aus meiner ganz persönlichen Sicht um ein ziemlich beeindruckendes Feature. Lazy Objects ermöglichen es die tatsächliche Initialisierung des Objekts zurückzustellen bis sie tatsächlich notwendig ist. Sie wird immer erst dann notwendig, wenn eine Methode oder eine Instanz des Objekts aufgerufen wird. Natürlich ist das ein großer Schritt in Richtung Performance-Verbesserung, da bis zur tatsächlichen Initialisierung keine vollständigen Instanzen von PHP Objekten im Speicher vorhanden sind, sondern lediglich Ghost oder Proxy Objekte, die wesentlicher kleiner und performanter sind.

Ein Beispiel aus dem RFC

Um zu veranschaulichen, welche Möglichkeiten Lazy Objects bieten können, gibt es folgendes Beispiel aus dem offiziellen RFC.

class MyClass
{
    public function __construct( private int $foo)
    {
        // Heavy initialization logic here.
    }

    // ...
}

$initializer = static function (MyClass $ghost): void {
    $ghost->__construct(123);
}

$reflector = new ReflectionClass(MyClass::class);
$object = $reflector->newLazyGhost($initializer);

Das oben gezeigte Beispiel ermöglicht es Entwicklern den Initialisierungsprozess eines Objekts sehr viel genauer zu kontrollieren, als es bisher der Fall war. Resourcen werden erst dann wirklich geladen, wenn auf sie zugriffen wird. Vor einem direkten Zugriff auf das Objekt gibt es lediglich eine Lazy Ghost Instanz.

Was ist ein Dependency Injection Container?

Ein Dependency Injection Container (DI-Container) in PHP ist eine zentrale Instanz, die Objekte erstellt, konfiguriert und deren Abhängigkeiten automatisch auflöst. Ein DI-Container übernimmt das Verwalten, Instanziieren und Injizieren aller benötigten Abhängigkeiten einer Klasse. Um es auf den Punkt zu bringen, ist ein DI-Container ein Werkzeug, welches automatisch die richtigen Objekte zur richtigen Zeit bereitstellt und so sauberen, entkoppelten und testbaren Code ermöglicht.

Warum ist das wichtig?

  • Saubere Skalierung
    Besonders bei größeren Projekten, in denen PHP-Frameworks, wie z.B. Laminas oder Symfony, zum Einsatz kommen. Abhängigkeiten werden zentral initialisiert und verwaltet.
  • Reduzierte Kopplung
    Klassen müssen nicht wissen, wie ihre Abhängigkeiten initialisiert werden müssen. Dies führt zur Reduktion von Code in den Klassen selbst, da der Container die Initialisierung zentral übernimmt.
  • Zentrale Konfiguration
    Services werden an einem Ort definiert und verwaltet. Das krasse Gegenstück: Legacy Code, in dem Klassen in vielen verschiedenen Stellen im Code unterschiedlich initialisiert werden. Pain in the ass.
  • Erhöhte Testbarkeit
    Abhängigkeiten können sehr leicht ausgetauscht oder gemockt werden. Dies hat immense Vorteile beim automatisierten Testen von Code.

Lazy Objects im DI-Container

Kombiniert man beide zuvor genannten Komponenten, wird ein sehr Performance-optimierter Dependency Injection Container erschaffen. PHP Frameworks, wie z.B. Symfony oder Laminas, nutzen Lazy Objects bereits in ihren PSR-11 Service Containern, so dass in diversen Benchmarks bereits Performance-Vorteile gegenüber herkömmlichen PSR-11 Containern, wie z.B. in Laravel, von teilweise über 30% erzielt werden konnten.

Die Grundlage mit Lazy Objects

Der Kern unseres PSR-11 kompatiblen DI-Containers besteht darin Instanzen lediglich als Lazy Objects zu initialisieren. Somit werden die tatsächlich benötigten Objekte erst instanziiert, wenn sie im weiteren Verlauf tatsächlich benötigt werden. Dies bedeutet, dass die Instanziierung erst dann stattfindet, wenn auf eine Methode oder eine Eigenschaft der Klasse konkret zugegriffen wird.

public function set(string $class, callable|string $callback): void
{
    $initializer = $callback;

    // Factories
    if (is_string($callback)) {
        if (class_exists($callback) === false) {
            throw new Exception\ContainerException('The given callback "%s" does not exist.');
        }
            
        $initializer = function(object $instance) use ($callback): object {
            $this->instances[$instance::class] = (new $callback())($this);
            return $this->instances[$instance::class];
        };
    }

    // Invokables
    if (is_callable($callback) === true) {
        $initializer = function(object $instance) use ($callback): object {
            $this->instances[$instance::class] = $callback($this);
            return $this->instances[$instance::class];
        };
    }

    $this->instances[$class] = $this->getReflectionClass($class)->newLazyProxy($initializer);
}

Services aus dem Container holen

Natürlich müssen die registrierten Services in einem Container auch wieder vom Container abgeholt werden können. Das PSR-11 Container Interface implementiert zu diesem Zweck die get-Methode, die wir für unseren Container wie folgt gestalten können.

public function get(string $id): object
{
    if ($this->has($id) === true) {
        return $this->instances->offsetGet($id);
    }

    if (! class_exists($id)) {
        throw new Exception\NotFoundException(sprintf('Class %s not found.', $id));
    }

    $reflector = $this->getReflectionClass($id);

    if ($reflector->isInstantiable() === false) {
        throw new Exception\ContainerException('Class %s can not be initialized.');
    }

    $constructor = $reflector->getConstructor();

    // Autowiring
    if ($constructor === null) {
        $this->set($id, function() use ($id) {
            return new $id();
        });

        return $this->instances->offsetGet($id);
    }

    $arguments = [];
    foreach ($constructor->getParameters() as $parameter) {
        $type = $parameter->getType();

        if (! $type instanceof ReflectionNamedType || $type->isBuiltin()) {
            throw new Exception\ContainerException(sprintf(
                'Can not resolve parameter $%s of constructor of class %s.',
                $parameter->getName(),
                $id
            ));
        }

        $arguments[] = $this->get($type->getName());
    }

    $this->set($id, function() use($reflector, $arguments) {
        return $reflector->newInstanceArgs($arguments);
    });

    return $this->instances->offsetGet($id);
}

Wie ihr vielleicht schon im Code gelesen habt, wurde direkt auch die Autowiring Funktionalität implementiert, so dass Type Hints vom Constructor einer Klasse ermittelt und initialisiert werden können. Diese Vorgehensweise ist weit verbreitet (z.B. in Symfony, Laminas, PHP-DI und vielen anderen Container-Implementierungen) und reduziert unsere Konfiguration, indem man nur noch die tatsächlich notwendigen Objekte dort erwähnen muss.

Ich verzichte in der Container Implementierung bewusst auf Arrays, in denen man die bereits initialisierten Lazy Object Instanzen hinterlegt. Stattdessen werden Objekte verwendet, die in PHP sehr viel performanter sind als Arrays.

Kurz und knapp: Die Implementierung der get-Methode versucht die Constructor Argumente des übergebenen Klassennamens herauszufinden, um alle Abhängigkeiten dieser Klasse auflösen zu können. Sind entsprechende Objekte bereits im Container vorhanden, werden sie als Lazy Object initialisiert. Sind entsprechende Objekte noch nichts vorhanden, werden sie neu im Container registriert.

Fazit

PHP 8.4 hat mit der Implementierung von Lazy Objects viele neue Möglichkeiten geschaffen, um Code einfacher und effizienter zu nutzen. Dieser Artikel beschreibt lediglich eine Einsatzmöglichkeit in einem PSR-11 kompatiblen DI-Container. Große PHP Frameworks, wie z.B. Symfony oder Laminas haben ihre aktuellen Container Implementierungen bereits angepasst. Das ORM System Doctrine nutzt Lazy Objects ebenfalls, um relationale Objektstrukturen sehr viel einfacher als bisher darstellen zu können. Lazy Objects bieten auf jeden Fall eine große Möglichkeit Deinen Code performanter zu machen.

Laravel hat als großes PHP-Framework noch nicht auf Lazy Objects gesetzt. Hier werden Services nach wie vor direkt instanziiert, sobald sie über make() oder Autowiring benötigt werden. Es gibt hier keinerlei Proxy-Klassen, keine Lazy-Factories oder eine Deferred-Initialisierung auf Objektebene. Somit ist mit dem Einsatz des Laravel DI-Containers mit Performance Nachteilen zu rechnen. Die gute Nachricht ist aber, dass man Lazy Objects mit ein wenig Aufwand in Laravel nachrüsten kann.

Das komplette Code Beispiel

Die komplette PSR-11 Service Container Umsetzung dieses Artikels findet ihr auf GitHub. Habt Spaß damit, äußert gern konstruktive Kritik, teilt Verbesserungsvorschläge und lasst ein Like dort.

1 Gedanke zu „Lazy Objects in einem PSR-11 kompatiblen Dependency Injection Container mit PHP 8.4“

Kommentar verfassen

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre, wie deine Kommentardaten verarbeitet werden.