FOL9000

Der f9-Dependency-Injection-Container

von | 2 Kommentare

Kaum zu glauben, aber wahr: Es gibt in f9 auch Klassen, die nicht aus epiphany genommen oder zumindest davon beeinflusst sind. Aber der Dependency-Injection-Container von f9 ist auch wieder komplett aus fremder Quelle: Diesmal ist es Pimple.

Pimple passt perfekt in ein Micro-Framework: Eine Datei mit einer Klasse und angeblich 80 Zeilen Code — mehr micro geht wahrscheinlich nicht. Vor einer kleinen Einführung und einer Darstellung der Änderungen für f9 aber ein paar Zeilen zu Dependency Injection.

Dependency Injection

Das Dependency Injection-Pattern dürfte zumindest in JavaEE-Welt eines der bekanntesten sein. Zunächst durch Spring bekannt und populär gemacht und später von JavaEE übernommen, ist es in der Java-Welt von enormer Bedeutung. Aber auch in anderen Sprachen gibt es eine ganze Reihe von Implementierungen.

Was ist nun Dependency Injection genau? Eine mit einer objektorientierten Sprache geschriebene Anwendung ist eine Menge miteinander kommunizierender Objekte. Um miteinander kommunizieren zu können (oder: gegenseitig Methoden aufrufen zu können), müssen sie einander kennen. Ein Service, der etwas in der Datenbank speichern möchte, muss z.B. das Datenbank-Objekt kennen. Durch dieses gegenseitige sich-kennen-müssen entstehen Abhängigkeiten. Im Beispiel ist der Service vom Datenbank-Objekt abhängig, denn ohne dieses Objekt könnte der Beispiel-Service nichts speichern.

Die große Frage ist nun: Wie werden diese Abhängigkeiten aufgelöst? Wie ist der Mechanismus, mit dem der Service vom Datenbank-Objekt erfährt?

Die erste — schlechte — Möglichkeit: Man lässt ein Objekt selbst seine Abhängigkeiten auflösen und das Objekt selbst Instanzen der Objekte erzeugen, von denen es abhängig ist. Das könnte dann so aussehen:


/* Schlechtes Beispiel! */

class MyService {

  private $db;

  public __construct() {
    $this->db = new MySQLDatabase()
  }
}

Diese Lösung ist schlecht, weil man die Abhängigkeit direkt in die Klasse MyService hineinprogrammiert hat und nicht mehr los wird. MySQLDatabase ist so auf immer und ewig mit dem Service verbunden, ein Austausch ist nicht mehr möglich.

Oft wird hier als Beispiel gebracht, dass man nie seine Datenbank ändern könnte, weil der Service immer ein MySQLDatabase-Objekt instantiiert. Das ist natürlich richtig, aber so oft wird ein solcher Wechsel nicht geschehen und so wird dieses Argument viele auch nicht überzeugen. Ein anderes Argument dürfte in der Praxis wichtiger sein. Sind die Abhängigkeiten fest im Code verankert, ist es nicht mehr möglich, bei Tests die Abhängigkeiten durch Mock-Objekte zu ersetzen. Dann wäre unser Beispiel-Service nicht isoliert von der Datenbank testbar und Unit-Tests im eigentlichen Sinne wären unmöglich.

Die zweite Lösung wäre deshalb, die Abhängigkeiten von außen zu setzen:

class MyService {

  private $db;

  public function setDatabase($db) {
    $this->db = $db;
  }
}

// Irgendwo in der Initialisierung:

$dbase = new MySQLDatabase();

$service = new MyService();
$service.setDatabase($dbase);

Der Service hat so sein Datenbank-Objekt zur Verfügung, aber bei einem Wechsel der Datenbank oder beim Testen kann man einfach ein anderes (Mock-)Datenbank-Objekt setzen und man kann mit dem unveränderten(!) Service weiterarbeiten. Diese Umstellung ist der Kern von Dependency Injection: Die Abhängigkeit wird einem Objekt von außen injiziert. Im Beispiel wurde Setter-Injection benutzt: Das Objekt, von dem die Service-Klasse abhängig ist, wurde per set-Methode gesetzt. Genauso hätte man mit dem gleichen Effekt das Datenbank-Objekt per Constructor-Injection im Konstruktor setzen können.

Dies funktioniert natürlich nur, wenn die Abhängigkeiten auch wirklich austauschbar sind. In typisierten Sprachen wie Java muss man dies über Interfaces sicher stellen. In php kann man dies so machen, man kann es aber auch bleiben lassen. Lässt man es, hat man aber das Problem, dass Klassen mit falscher Signatur übergeben werden können; eine nicht zu unterschätzende Fehlerquelle.

Bleibt die Frage, wer dieses Injizieren wann macht. In der Regel wird dafür ein Dependency-Injection-Container benutzt, d.h. eine Klasse, die Objekte und ihre Abhängigkeiten verwaltet. Die Initialisierung eines solchen Containers ist dann die erste Aufgabe, wenn eine Anwendung gestartet wird. Der Container liest eine Konfigurationsdatei ein oder wird programmatisch konfiguriert. Danach hat er eine Reihe von verwalteten Objekten und kennt deren Abhängigkeiten untereinander. Wird nun ein Objekt von ihm angefordert, sorgt der Container dafür dass dessen Abhängigkeiten korrekt gesetzt werden und gibt ein konfiguriertes Objekt zurück.

Da Pimple sehr schlicht ist, lässt sich alles weitere am besten durch das direkte Beispiel von Pimple bzw. den BeanContainer von f9 erklären.

Namespace und Namensänderung

Pimple ist im Original-Code nicht namespaced — was mir überhaupt nicht passt. Folgerichtig hab ich den Code nicht einfach übernommen bzw. integriert, sondern leicht angepasst. Zunächst liegt er deshalb im f9-Namespace, aber auch den Namen hab ich gleich mit geändert, denn ich bevorzuge Klassennamen, die deutlich machen, welchen Zweck die Klasse hat. Bei mir heißt der Dependency-Injection-Container deshalb schlicht BeanContainer. Alles weitere ist aber komplett von Pimple übernommen.

Zum Einsetzen des Containers ist er deshalb so einzubinden:

use net\f9\BeanContainer;

Der BeanContainer

Wie gesagt, in f9 heißt der Dependency-Injection-Container BeanContainer, ist aber ansonsten mit Pimple identisch. Deshalb wird sich die folgende Beschreibung des BeanContainers auch stark an der Pimple-Doku orientieren.

Konstruktor

Einen neuen Container erhält man am einfachsten über einen schlichten parameterlosen Konstruktoraufruf. Die Daten, die man dann später per Setter setzen muss, können aber auch schon dem Konstruktor als Array übergeben werden:

// So:
$container1 = new BeanContainer();
// Oder so:
$data = array('str'=>'Hallo');
$container2 = new BeanContainer($data);

Wie kommt was kommt in den Container?

Da der Name des Containers BeanContainer ist, könnte man vermuten, man legt Beans in den Container, also letztlich: Objekte. Das ist aber so nicht ganz richtig. Die Pimple-Dokumentation benennt die Art der vom Container gemanagten Daten Parameter und Services. Ich finde diese Benennung etwas unglücklich, da sie mit anderem, was unter dem gleichen Namen läuft, kollidiert. Vielleicht kann man es von den möglichen Rückgabe-Werten her verstehen:

In den Container kommen zum einen Daten (gleich welchen Typs), die einfach nur gespeichert und zurückgegeben werden sollen, wie bei einem Array. Außerdem kommen in den Container Funktionen, die Objekte erzeugen und zurückgeben.

Was auch immer im Container abgelegt wird, es wird erleichtert dadurch, dass der BeanContainer das ArrayAccess-Interface implementiert.

Daten

Das Ablegen einzelner Werte oder von Daten im Container ist vor allem für Konfigurationsdaten wichtig und sinnvoll:

$container = new BeanContainer();
$container['database-schema'] = 'my_db';
$container['domain'] = 'example.local';
$container['cookie_name'] = 'schubbidubbi';

Auch wenn es ein typischer Anwendungsfall ist, darf man nicht meinen, dass nur skalare Daten wie Strings oder Integers auf diese Weise abgelegt werden können. Grundsätzlich lässt sich alles im Container speichern, Skalare, Objekte oder auch Funktionen. In der Form, wie es in obigem Beispiel geschieht, wird dies aber meist nur für skalare Daten, seltener einzelne Objekte gemacht, auch wenn es für beliebige andere Daten natürlich auch immer gute Beispiele gibt. Für die Objekte, die die Anwendung ausmachen, sieht der BeanContainer aber Factory-Funktionen vor.

Factory-Funktionen

Die eine Anwendung ausmachenden Objekte sollen vom Container verwaltet werden, können aber natürlich nicht schon als Instanzen beim Anwendungsstart dort abgelegt werden.

Aus diesem Grund werden im Container statt der Objekte Funktionen gespeichert, die bei Bedarf ein neues Objekt erzeugen, die Abhängigkeiten setzten und die neuen Objekte zurückgeben. Diese Funktionen dienen also als Factory-Funktionen: Bei Bedarf erzeugen sie ein gewünschtes Objekt.

Im folgenden fiktiven Beispiel wird zunächst in den Zeilen 3/4 ein Konfigurationswert gesetzt: Ein Verbindungs-String für eine Datenbank. Es folgt eine Factory-Funktion für ein Datenbank-Objekt, das unter dem Namen storage abgelegt wird. Diese Funktion erzeugt jedes mal, wenn ein Datenbank-Objekt aus dem Container abgefragt wird (wie z.B. in Zeile 12, beim Definieren das data-handlers) eine neue Instanz der Database-Klasse.

Möglich ist dies durch eine Sonderbehandlung von Funktionen: Wenn das im Container unter einem Namen hinterlegte Datum eine Funktion ist, gibt der Container die Funktion nicht zurück; vielmehr ruft er sie auf und gibt zurück, was die Funktion zurückgibt — und das ist in diesem Beispiel einmal Database-Objekt und einmal ein DataHandler-Objekt.

$container = new BeanContainer();

$container['connection] =
  'Server=myServer;Database=myDB;Uid=me;Pwd=myPassword;'; 

$container['storage'] = function($c) {
  return new Database($c['connection']);
};

$container['data-handler'] = function($c) {
  $handler = new DataHandler();
  $handler->setStorage($c['storage']);
  return $handler;
};

Aus dem Beispiel werden noch einige weitere wichtige Dinge deutlich.

Zunächst ist ersichtlich, dass die Funktion im Parameter eine Instanz des Containers übergeben bekommt (im Beispiel $c genannt). Dadurch ist es innerhalb der Funktion möglich, auf alle anderen Werte und Objekte im Container zuzugreifen (was im Beispiel auch bei beiden Funktionen geschieht).

Außerdem ist die Reihenfolge der Definition bzw. der Zuweisung zum Container unwichtig. Denn die Funktionen werden ja erst dann ausgeführt, wenn ein Objekt abgefragt wird und nicht in der Reihenfolge der Definition.

Und schließlich ist wichtig, dass jedes mal, wenn ein Objekt abgefragt wird, eine neue Instanz erzeugt wird. Will man dies verhindern, möchte man also eine Art Singleton, ist aber auch dies möglich.

Singletons

Wenn nicht bei jeder Container-Abfrage eine neue Objekt-Instanz erzeugt werden soll, lässt sich dies erreichen, indem man sie mit der shared()-Methode ablegt:

$container['same'] = $container->shared(function($c) {
  return new SomeClass();
});

Fragt man nun das Objekt mit dem Key same ab, bekommt man immer das selbe Objekt zurückgegeben, die Factory-Function wird also nur einmal ausgeführt.

Funktionen als Daten, nicht als Factories

In den beiden vorhergehenden Abschnitten wurde der Unterschied zwischen Daten und Funktionen im Container erläutert und beschrieben, dass Funktionen auf besondere Art behandelt werden, wenn sie abgerufen werden: Sie werden aufgerufen, nicht zurückgegeben.

Es ist aber durchaus möglich, dass man den Funktionsaufruf verhindern möchte und die Funktion als Funktion zurückgeben möchte. Wenn man die im Container abgelegte Funktion nicht als Factory-Funktion behandelt sehen möchte, sondern sie als Datum zurückgegeben werden soll, kann man sie mit der protect()-Methode vor dem Aufruf schützen:

$container['func'] = $container->protect(function ($c) {
   return rand();
});
Die Factory-Funktion abfragen

Wird der Container mit einem Key abgefragt, unter dem eine Factory-Funktion abgelegt ist, wird die Funktion ausgeführt. Will man die Ausführung verhindern und stattdessen die Funktion selbst zurückbekommen, kann man dazu die Methode raw() nutzen:

$container['sth'] = $container->share(function ($c) {
  return new Sth($c['sth_else']);
});

$sthFunction = $container->raw('sth');

Container wiederverwenden

Oft wird man in einer Anwendung nur einen Container nutzen und ihn alle Objekte und Abhängigkeiten der Anwendungsobjekte managen lassen. Es ist aber auch problemlos möglich, mehrere Container zu nutzen. Dies kann dazu genutzt werden, um Container wiederzuverwenden.

Einen wiederverwendbaren Container erhält man, indem man eine eigene Klasse von ihm ableitet und ihn im Konstruktor konfiguriert:

class DBContainer extends BeanContainer {

    public function __construct() {
        $this['parameter'] = 'schubbi';
        $this['object'] = function () {
          return DBClass();
        };
    }
}

Diesen Container kann man nun einfach als shared Container in einen anderen integrieren:

$container = new BeanContainer();

$container['db'] = $container->share(function () {
  return new DBContainer();
});

$container['embedded']['parameter'] = 'dubbi';

$container['embedded']['object']->method();

Auswirkungen auf die Anwendungs-Architektur

Der Einsatz eines Dependency-Injection-Container hat natürlich Einfluss auf die Anwendungsarchitektur. Weil die Frage der Architektur aber so zentral ist, will ich sie mir für einen weiteren Artikel aufsparen, in dem ich eine komplette Beispiel-Anwendung vorstellen möchte.

Das Beispiel bei den Downloads ist aber auch eine komplette (naja) Anwendung. Eine Beispielhafte f9-Architektur wird auch daraus deutlich.

Und noch etwas ist wichtig: Vor allem der RequestDispatcher und der ViewResolver arbeiten noch nicht perfekt mit dem BeanContainer zusammen. Denn beiden kann man noch keine Bean-Namen als Routing-Targets bzw. Views übergeben. Deshalb wird dies der nächste Schritt sein: Aus beiden jeweils eine neue Klasse abzuleiten, die mit Beans aus dem Container umgehen kann.

Schluss

Mit dem Dependency-Injection-Container ist f9 erstmal komplett. Alles, was man für eine Anwendung mit f9 unbedingt benötigt wird, ist damit vorhanden. Sicher, es kann bei den paar Klassen nicht sein, dass alles abgedeckt ist, was ein Framework ausmacht. So fehlen z.B. noch sämtliche Security-Funktionen. Aber das kann ja noch werden…

Download

Der BeanContainer von f9 als einzelner Download findet sich hier:
f9-BeanContainer

Ein komplettes Beispiel für den BeanContainer ist ebenfalls verfügbar:
f9-BeanContainer-Beispiel

Das komplette f9-Framework mit allen Klassen und Beispielen:
f9

2 Kommentare

  1. Pingback: Eine beispielhafte f9-Anwendungsarchitektur | FOL9000

  2. Pingback: Sessions in f9 | FOL9000

Schreibe einen Kommentar

Pflichtfelder sind mit * markiert.