Dependency Injection mit PHP 5.3, Runkit-Erweiterung und Doctrine 2-Annotationen

Unter Dependency Injection versteht man heute nicht nur ein einfaches Entwurfsmuster, sondern vor allem Framework-gestützte Mechanismen, die den konkreten Implementierungsaufwand verringern (Entwicklungszeitoptimierung), dem Entwickler bessere Übersicht über Abhängigkeiten zu schaffen (Applicationdesignoptimierung) und die Anzahl der Instanzen gleichen Prototyps zu minimieren (Performanceoptimierung).

Heute möchte ich einen alternativen, vielleicht pragmatischeren Ansatz als der andererer populärer Implementierungenn herbeispinnen, um Dependency Injection (DI) in PHP 5.3 zu realisieren.

Für diverse Programmiersprachen gibt es – auf den jeweiligen Anwendungsbereich mehr oder weniger spezialisierte – sogenannte Dependency-Injection (DI)-Container. Der DI-Container als solcher dient im Grunde als Manager oder Konfigurator, der das Zusammenspiel unserer Abhängigkeiten definiert – meist auf Grundlage einer Konfigurationsdatei, die bspw. in XML vorliegt:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-2.0.xsd">
<bean id="..." class="...">
<!-- collaborators and configuration for this bean go here -->
</bean>
<bean id="..." class="...">
<!-- collaborators and configuration for this bean go here -->
</bean>
<!-- more bean definitions go here... -->
</beans>

(Quelle: Springsource)

Eine weitere Möglichkeit Abhängigkeiten zu definieren, bietet das Convention Over Configuration-Paradigma: Heißt bspw. eine Member-Variable “Mailservice”, so kann der zugrundeliegende DI-Container entsprechend konfiguriert werden, sodass bei Instanziierung automatisch eine Klasse gleichen Namens (Mailservice) instanziiert und an das Objekt gebunden wird (so macht’s bspw. Grails in seinen MVC-Controller-Instanzen, wobei hier natürlich wieder das Spring IoC (Das hat nichts mit dem internationalen olympischen Komitee zu tun, sondern ist die Abkürzung für “Inversion of Control” und steht gleichbedeutend für “Dependency Injection”). Diese Implementierung erfordert natürlich ausgereifte Introspektionsmechanismen der zugrundeliegenden Programmiersprache (Stichwort “Reflection“), außerdem muss man dem DI bzw. IOC-Container vorher beigebracht haben, welche Klassen oder Verzeichnisstrukturen überhaupt durchsucht und unter “DI-Kontrolle” gestellt werden müssen – ein zusätzlicher Programmieraufwand ist also immer noch gegeben. Unvorhersehbarkeiten im Code und eine erhöhte WTF’s/min-Rate kommen dabei frei Haus.

PHP bieted zu dem ganzen Thema ab Werk erstmal – nix. Die Reflection-API ist aber schonmal ein ausgereiftes Werkzeug, um definierte Abhängigkeiten auszuwerten. Das allerdings ausschließlich mittels lesendem Zugriff auf Metadaten von bspw. Klassen, Methoden oder auch System- bzw. benutzerdefinierten Funktionen. Schreibende Operationen sind im Sprachkern erst einmal nicht vorgesehen. Es ist also prinzipiell möglich, zur Laufzeit Abhängigkeiten durch Konvention zu definieren, die Auflösung selbiger ist aber “mit Boardmitteln” nicht zu realisieren.

Ein eigenes Sprachkonstrukt wie Mixins ist auch (noch) nicht implementiert, und anonyme (“Lambda“)-Funktionen lassen sich nicht wie in Javascript oder auch Groovy direkt an den Objekt-Prototypen hängen oder auch an einzelne Instanzen, sondern müssen mühsam “by reference” gezogen und dann erst ausgewertet werden.

Javascript:

var foo = function() { }
foo.prototype.bar = function() { return "bar"; }
foo.baz = foo.prototype.bar;
var foobar = new foo;
foo.bar(); // -> "bar"
foo.baz(); // -> "bar"

PHP:

$foo = new stdClass();
$foo->bar = function() 
{
return "bar";
}
$foo->bar(); // method not found
$bar = $foo->bar;
$bar(); // -> "bar"

Zugegeben: Gäbe es bereits Mixins oder Prototypen im PHP-Kontext, wäre die Implementierung eines DI-Containers wohl eher unspannend, mindestens aber überflüssig. Dennoch fehlt eine zuverlässige Möglichkeit, PHP-Instanzen zur Laufzeit mit neuen Methoden anreichern zu können. Daher gehen die meisten PHP-DI-Frameworks immer den Weg über einen recht aufgeblähten DI-Container. Dieser macht aber im Grunde nichts anderes, als Referenzen zu verwalten. Der Container selbst wird dann an die anzureichernde Instanz gebunden, im Idealfall eventuell aber noch via magischer Methode __call() verborgen. Dann aber muss die zu aufnehmende Instanz wiederum mindestens ein Interface “Injectable” o.Ä. implementieren. Letzlich aber passiert am Ende immer folgendes:

class myController extends Controller
{
public function indexAction(HttpRequest $request)
{
$db = $this->getService('Database');
}
}

Der Service-Container übernimmt also die klassische Aufgabe des “ApplicationContext”: Er managed Referenzen, rückt diese aber erst “on demand” heraus. Meiner Meinung hat man dabei nicht allzu viel gewonnen, vor allem, wenn das obige Codeschnippsel noch eine Menge Konfiguration, Code zum Bootstrappen des DI-Containers und/oder Caching voraussetzt.

Ich persönlich würde mir dann doch eher “ganz oldschool” ein paar getter/setter zusätzlich schreiben und mich einfach darauf verlassen, dass der gute, alte ApplicationContext sowieso (fast) alles enthält, was ich brauch’, mit der Konsequenz, dass dieser eben nicht gerade leichtgewichtig ist.

Wozu also das ganze?

Ein alternativer DI-Container mit Doctrine 2 Annotations und Runkit

Die Annotations-Implementierung von Doctrine 2 ist einfach und mächtig genug, um eine beliebigen Klasse mit Metainformationen auszustatten, die die zu injizierenden Abhängigkeiten definieren. Runkit ermöglicht es, auf Basis dieser Metadaten Abhänigkeiten an das ensprechende Objekt zu binden – zur Laufzeit und ohne zusätzliche Implementierung von Schnittstellen o.Ä. Vom Prinzip her sollte es also möglich sein, eine Klasse zu schreiben, diese zu annotieren und dann bei Instanziierung automatisch alle gewünschten Abhängigkeiten zur Verfügung stehen zu haben. Ein Codebeispiel:

index.php:

require __DIR__ . '/lib/vendor/doctrine-common/lib/Doctrine/Common/ClassLoader.php';
use DoctrineCommonClassLoader;
$classLoader = new ClassLoader('DoctrineCommon', __DIR__ . '/lib/vendor/doctrine-common/lib');
$classLoader->register();
$classLoader = new ClassLoader('deifschleife', __DIR__ . '/lib');
$classLoader->register();
// INITIALISIERUNG DES DI-CONTAINERS
$di_container = new deifschleifediContainer;
$di_container->setClassnames(array(
'deifschleifeDITest'
));
// "AUFNEHMENDE" KLASSE
$test = new deifschleifeDITest();
// GEHÖRT deifschleifeADependency
echo $test->foo();
// GEHÖRT deifschleifeAnotherDependency
echo $test->bar();
// GEHÖRT deifschleifeYetAnotherDependency
echo $test->baz();
// GIBT's NICHT, ÜBER __call() ABGEFANGEN
echo $test->methodThatDoesNotExist();

Kurz erklärt:

$di_container = new deifschleifediContainer;
$di_container->setClassnames(array(
'deifschleifeDITest'
));

Instanziiert den DI-Container und stellt eine Klasse (deifschleifeDITest) unter seine “Kontrolle”.

$test = new deifschleifeDITest();
// GEHÖRT deifschleifeADependency
echo $test->foo();
// GEHÖRT deifschleifeAnotherDependency
echo $test->bar();
// GEHÖRT deifschleifeYetAnotherDependency
echo $test->baz();
// GIBT's NICHT, ÜBER __call() ABGEFANGEN
echo $test->methodThatDoesNotExist();

Instanziiert die überwachte Klasse deifschleifeDITest und ruft einige Methoden auf, die sämtlich aus anderen Klassen nach DITest injiziert wurden. Die letze Methode ist nirgends existent. deifschleifeDITest implementiert ausschließlich __call(), ansonsten ist die Klasse (fast) leer:

<?php
namespace deifschleife;
/**
* @Services({
*   @Service(
*    class="deifschleifeADependency",
*    constructorArgs={
*      "pong"
*    }
*  ),
*  @Service(
*    class="deifschleifeAnotherDependency",
*    constructor={"deifschleifeAnotherDependency", "getAnotherDependency"}
*  ),
*  @Service(
*    class="deifschleifeYetAnotherDependency",
*    constructor="makeYetAnotherDependency"
*  )
* })
*/
class DITest
{
public function __call($method, $args)
{
return sprintf("Called __call, Method: %s, Args: %s", $method . '()', implode(', ', $args));
}
public function makeYetAnotherDependency()
{
return new YetAnotherDependency();
}
}

Interessant sind die Klassen-Annotationen ganz oben: @Services teilt dem Doctrine 2 Annotation Parser mit, dass hier eine Reihe von PHP-Klassen, die zu injizierende Methoden enthalten, zusammenzusuchen sind. Jeder einzelne @Service in der Liste kann noch grob konfiguriert werden, so kann bspw. eine eigene Factory-Methode für jede Dependency angegeben werden.

Die einzelnen Dependencies sind “ganz normale” PHP-Klassen. Intern passiert folgendes: Die Runkit-Erweiterung dient dazu, über runkit_method_add() “on the fly” eine magische Methode __call() an die mit zusätzlcihen Methoden anzureichernde Klasse deifschleifeDITest zu binden. Existiert bereits eine entsprechende Methode __call(), wird diese intern via runkit_method_rename() umbenannt und am Ende von __call() (neu) aufgerufen. Das bedingt natürlich eine gewisse Rücksichtname bei zusätzlicher Verwendung von __call().

Die Idee unterscheidet sch also nur unwesentlich von bereits vorhandenen Konzepten, spart aber einige Schritte ein:

1.) Man benötigt keine Abstrakte Klasse und/oder Interface, die dass die getService()-Logik bereitstellt.
2.) Man benötigt kaum zusätzliche Konfigurationsaufwand – eine einfache Annotation im PHPDOc-Comment-Format reicht aus (es spricht allerdings nichts dagegen, je nach Gschmäckle einen Config-Adapter dazwischen zu schieben, der das Mapping via XML oder bspw. Yaml erlaubt)
3.) Man kann auf Proxy-Instanzen verzichten, die __call() transparent bereitstellen.

Fazit: Es ist prinzipiell möglich, Dependency Injection – passender noch “Mixins” – mit PHP zu realisieren, ohne nennenswert viel Codeoverhead zu produzieren.

Allerdings muss man berücksichtigen, dass Runkit eine experimentelle Erweiterung ist, deren Zuverlässigkeit in komplexen Projekten nicht vorauszusehen ist. Außerdem wird es sehr wahrscheinlich in Bälde ein mächtiges, neues Sprachkonstrukt geben, das Mixins ermöglicht (siehe RFC zu Traits). Und natürlich kann ich mir auch totalen Murks zusammen gereimt haben, denn sicherlich sind die existierenden Implementierungen von fähigeren Architekten entworfen worden und haben bestimmt ihre Daseinsberechtigungen in einem Kontext, den ich vielleicht einfach noch nicht erfasst haben. Comments dazu wären wir sehr willkommen!

Ausblick

Die Lösung zentral über __call() ist die Schnelle, aber nicht optimale. Vorstellbar ist eine injizierung sämtlicher öffentlicher Methoden wie runkit_method_add, und ein globaler Accessor, der die Objektinstanz der Abhängigkeit liefert. Das ganze könnte man (optional) konfigurierbar gestalten (über Doctrine 2 Annotationen), um eventuellen Namenskonflikten auszuweichen.

Test-Sourcen

Die PHP-Quelltexte zu den obigen Beispielen kann man hier herunterladen:
http://ifschleife.de/dependency_injection.tar.gz

Ich habe die DoctrineCommon-Package bereits beigelegt, das Beispiel sollte also unter PHP5.3 funktionieren (bitte di.php aufrufen, der Rest ist noch durchsetzt mit Doctrine 2 Testklamotten).

Eine Anleitung, wie man Runkit unter php5.3 zum laufen kriegt, gibt es hier (Japanisch, aber der Quelltext ist auf PHP ;))

Die Test-Sourcen liegen, wie in den obigen Codebeispielen beschrieben, unter lib/de/ifschleife. Ich möchte betonen, dass das nur eine Art Fallstudie ist, ich wäre aber über jeden Kommentar erfreut, der mir widerlegt oder bestätigt, ob man den programmatischen Ansatz vielleicht weiterverfolgen sollte.

Vielen Dank für die Aufmerksamkeit! :)

4 Replies to “Dependency Injection mit PHP 5.3, Runkit-Erweiterung und Doctrine 2-Annotationen”

  1. Habe eine Weile gebraucht das zu verstehen, aber das ist wirklich supercool! :)

    vor allem dass man dann die Methoden der Dependencies direkt auf der so erweiterten Klasse aufrufen kann.

    Also nicht:

    echo $test->SomeInjectedDependency->baz();
    

    Was ja vielleicht auch einfacher zu realisieren wäre.

    sondern quasi “nativ”:

    echo $test->baz();
    

    Wobei einem genau das aber auch wieder zum Verhängnis werden könnte in Sachen Methodennamen.
    Was passiert denn, wenn die Dependencies gleichnamige Methoden haben?
    Kriegt man dann “cannot redeclare”?

Leave a Reply

Your email address will not be published. Required fields are marked *