In and out of Control

Mit Dependency Injection Klassenabhängigkeiten kontrollieren
Kommentare

Symfony2, TYPO3 Flow und Zend Framework 2 haben eine Gemeinsamkeit: Sie bringen einen Dependency-Injection-Container mit, um Testbarkeit zu erhöhen und Komplexität zu minimieren. Was genau hinter diesem Muster steckt und wie man es anwenden kann, erläutert dieser Artikel.

Ein guter Softwareentwickler macht es sich zur Aufgabe, implizite Abhängigkeiten in seinen Projekten zu minimieren. Das liegt daran, dass die Anzahl der abhängigen Klassen linear zum Testaufwand steht. Steht man Qualität und Stabilität seiner Applikation eher gleichgültig gegenüber, kann man seine Systeme weiterhin stark koppeln. Legt man jedoch Wert darauf, sollte man sich mit dem Dependency-Injection-Entwurfsmuster auseinandersetzen. In der Softwareentwicklung kommt man häufig an den Punkt, dass Klassen Abhängigkeiten zu anderen Klassen besitzen. In der klassischen Programmierung kümmert sich dabei jede Klasse selbst um ihre Abhängigkeiten und instanziiert benötigte Objekte oder nutzt Singletons in ihrem lokalen Kontrollfluss. Dass es sich bei diesem Vorgehen um ein potenzielles Problem handelt, veranschaulicht Listing 1. Diese oder ähnliche Zeilen Quellcode sind in jedem größeren Projekt zu finden. In einer Zeit, in der Testbarkeit noch keinen hohen Stellenwert im PHP-Umfeld hatte, war das eine gut funktionierende Lösung. Aus der Sicht der Qualitätssicherung sieht es aber ganz anders aus.

Listing 1

class A
{
  private $db;

  public function __construct( )
  {
    $this->db = new Db( );
  }

  public function store( )
  {
    $this->db->store($this->getData());
  }

  ...
}

$a = new A();

Bei dem Versuch die Klasse A mit Unit Tests abzudecken, wird schnell klar, dass hier eine Abhängigkeit aufgebaut wurde, die nicht so einfach von außen aufzulösen oder zu ersetzen ist. Für diesen Fall bedeutet das konkret, dass jedes Mal wenn Klasse A getestet werden soll, eine reale Datenbank benötigt wird. Das ist nicht sehr aufwendig und kostspielig, sondern eigentlich sogar unnötig: Dass die Anbindung funktioniert, wird eigentlich in den Unit Tets für die Datenbank verifiziert und nicht an dieser Stelle. Das scheint im Kontext einer kleinen Webanwendung noch akzeptabel, steigt jedoch die Komplexität der Anwendung, steigt auch der Aufwand des Testens entsprechend. Damit es unter Projektdruck dann nicht heißt, dass man ab sofort auf Tests verzichten muss, sollte man es gar nicht erst so weit kommen lassen und diese Abhängigkeiten bereits im Vorfeld aus dem Weg räumen.

Ein weiterer großer Nachteil beim Aufbau von Software auf die oben beschriebene Weise ist die Art der Abhängigkeiten: Sie sind implizit und von außen nicht zu sehen. Das Information-Hiding-Prinzip klingt zunächst positiv. Soll das System aber verständlich und auf sauberer Architektur aufgebaut sein, die auf Redundanz und lose Kopplung fußt, so führt der Weg über das Geheimnisprinzip schnell in eine Sackgasse: Systemweit Datenbanken auszutauschen wird in einem solchen Fall zur Herausforderung. An dieser Stelle dienten Singletons einige Zeit als Lösung, da sie eine globale Variable zur Verfügung stellen, die von überall genutzt werden kann, bestehen bleibt dabei dennoch das Testbarkeitsproblem, da die Abhängigkeiten immer noch hart verdrahtet sind.

Ein Lösungsansatz ist Dependency Injection (DI): Abhängigkeiten werden explizit gemacht, die Testbarkeit hergestellt, und dabei ist Dependency Injection in ihrer Reinform trotzdem einfach zu verstehen und umzusetzen. Eine wichtige Idee hinter dem DI-Entwurfsmuster entspringt dem Inversion-of-Control-Konzept. Das bedeutet, dass die verwendeten Klassen sich nicht mehr selbst um Ablauf und Abhängigkeiten kümmern, sondern dass dies ein Framework (nicht im Sinne von Webframework) übernimmt. Die Klasse gibt sozusagen die Kontrolle ab und lässt sich von außen steuern. Oft wird die DI mit Inversion of Control (IOC) gleichgesetzt, wobei sie aber genaugenommen nur eine mögliche Implementierung von IOC ist. Wie die Abhängigkeiten in dem oben erwähnten Beispiel herauslöst werden können, wird anhand des neuen, in Listing 2 dargestellten, Codes deutlich.

Listing 2

class A
{
  private $db;

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

  public function store( )
  {
    $this->db->store($this->getData());
  }

  ...
}

$a = new A(new Db());

Werden die Abhängigkeiten über den Konstruktor injiziert, spricht man von einer Constructor Injection. Eine oft vertretene Meinung ist, dass der Konstruktor möglichst dumm sein soll, daher entwerfen wir hier auch eine sauberere Lösung: Die Constructor Injection sollte immer dann verwendet werden, wenn die benötigten Objekte nicht optional sind, damit sie nicht durch Bedienfehler vergessen werden können. Optionale Abhängigkeiten, wie zum Beispiel ein Logging-System, das nur bei Bedarf instanziiert werden muss, werden über die so genannte Setter Injection gelöst. Das Prinzip ist identisch zu dem der Constructor Injection, nur dass normale Setter-Methoden verwendet werden. Listing 3 ist ein vereinfachtes Beispiel, zeigt aber die Verwendung der Setter Injection und den Umgang mit einem Standardwert. Auf den ersten Blick ist das System komplexer geworden. Die Abhängigkeiten müssen nun bekannt sein und von außen aufgelöst werden. Anstatt einfach nur ein Objekt vom Typ A zu erstellen, muss zusätzlich noch ein Objekt vom Typ Database erstellt werden.

Listing 3

class B
{
  Private $logger;

  public function __construct()
  {
    $this->logger = new NullLogger();
  }

  public function setLogger(Logger $logger)
  {
    $this->logger = $logger;
  }

  ...
}

$b  = new B();
$b->setLogger(new TextLogger('/tmp/di.log'));

Wer öfter Unit Tests schreibt, wird bereits den großen Vorteil erahnen: In einem Test für das Setter-Injection-Beispiel ohne DI würde der Text-Logger hart instanziiert und damit wäre die Ausführung eines Tests auch immer mit dem Zugriff auf das Dateisystem verbunden. Dabei entstehen natürlich keine Lastspitzen, die zu Problemen führen könnten, aber für die Prüfung, ob das Logging funktioniert, muss immer der Inhalt der Logdatei ausgelesen und mit dem Erwarteten verglichen werden. Dieses Vorgehen ist zwar möglich, aber umständlich. Ein zweiter Nachteil bei diesem Aufbau ist, dass die Testergebnisse nicht mehr so aussagekräftig sind. Sollte der Text-Logger einen Fehler beinhalten, so schlagen nun gleich zwei Tests fehl, statt nur einer. Die Fehleranalyse wird komplexer, da nicht mehr auf den ersten Blick zu sehen ist, welche Komponenten zu Problemen führen.

Aus dem Testkontext heraus betrachtet, sieht das Ergebnis mit der Dependency-Injection-Lösung ganz anders aus: Das Mocking, also das Erstellen von Platzhalterobjekten, wird stark vereinfacht, denn statt mit einem Text-Logger zu arbeiten, kann auf eine Alternative ausgewichen werden. Ein Simple Logger, der einfach nur die Events in einer lokalen Variable vorhält, wäre für das Testen völlig ausreichend. Den Inhalt dieser Variable gilt es dann, mit den zu erwartenden Werten zu füllen. Sollte der Text-Logger einen Fehler beinhalten, so ist die Klasse B-Test nicht mehr betroffen, da der Logger autark arbeitet. Als Folge des neuen Vorgehens wird die Interfacedichte in einem Projekt steigen, da das Austauschen von konkreten Implementierungen eine der Hauptideen von Dependency Injection ist.

Weiter mit: Teil 2

Alle Teile: Teil 1, Teil 2, Teil 3

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -