Behave, baby!

Doctrine macht es dem Entwickler leicht, seine Object-Models mit Businesslogic anzureichern. Entsprechende Methoden an der Doctrine_Record-  – oder allgemeiner – an einer entsprechenden Doctrine_Table-Kindklasse zu verdrahten ist ein Kinderspiel. Irgendwann trifft man dann auf einen Anwendungsfall, der eine entsprechende Zusatzfunktionalität erfordert, ohne dass das “Tätigkeitsfeld” dieser Funktionalität auf nur eine Gruppe von Entitäten zu begrenzen wäre. Anstatt nun die immer gleichen Methoden für alle seine Object-Models, die die neue Funktionalität benötigen, zu implementieren und damit ziemlich viel Code zu produzieren, möchte man lieber das ORM-Framework selbst erweitern. Auch hierfür bietet Doctrine die entsprechenden Schnittstellen: Einen Eventdispatcher zusammen mit ziemlich viele Stellen im Code, denen man “zuhören” kann und das Konzept der Behaviours (dt. etwa Verhaltensmuster): Oh behave!

Einige sogenannte Core-Behaviours liefert Doctrine ab Werk bereits mit. Zu nennen sind da bspw. die komplexe Tree-Implementierung zur Verwaltung von Baumstrukturen der zugrundeliegenden (relationalen) Datenbank oder die etwas einfacherer Versionierung von Tupeln bis hin zum wirklich simplen, automatisch eingefügten Zeitstempel.

An der Bandbreite der zur Verfügung stehenden Core Behaviours kann man bereits sehen, dass man damit unterschiedlichste Dinge realisieren kann: Bspw. ist “Timestampable” ein reiner Event-Listener, der die Events onSave bzw. onUpdate überwacht und jeweils automatisch die aktuelle Systemzeit mit dem entsprechenden Datensatz speichert.

Die Tree-Implementierung, als Beispiel sei NestedSets genannt, bietet dafür und im Gegensatz zum Timestampable-Verhaltensmuster einen Adapter, der  es dem Programmierer erlaubt, über Iteratoren und gewohnte Entwurfsmuster zum Zugriff auf hierarchische Datenstrukturen die zugrundeliegenden RecordSets zu bearbeiten.

Ein neues Verhaltensmuster erstellen

Anhand eines Beispiels möchte ich kurz zeigen, wie man ziemlich flott und ohne große Kenntnis der Doctrine-Architektur ein eigenes Verhalten zusammendengeln kann. Ich will dabei einerseits ein paar Automatismen via Event-Listener implementieren, andererseits aber auch zwei Helfermethoden schreiben, die mir häufig wiederkehrende Tasks abnehmen.

Die Anforderung ist, Pfade zu vom Benutzer hochgeladenen Dateien in einer Relation abzulegen und auf Integrität zu prüfen. Vorhanden ist bereits eine Abstraktionsschicht für den Zugriff auf das Dateisystem, nennen wir die zuständige Klasse einmal File:

$file = new File('/pfad/zur/datei.jpg');

Um die Funktionalität dieser Klassen mit unserem Doctrine Entities zu nutzen, entwerfen wir ein neues Verhaltensmuster als Ableitung der Klasse Doctrine_Template:


class Doctrine_Template_File  extends Doctrine_Template
{
public function setFile(File $file)
{
}

public function getFile(File $file)
{
}
}

Jedes Template erhält einen Satz an Standardoptionen, die später in der Klassendefinition des Models selbst bzw. im Yaml-Schema überschrieben werden können:


class Doctrine_Template_File  extends Doctrine_Template
{

protected $_options = array(
'name'=>'pathname',
'alias'=>null,
'options'=>array('notnull'=>true),
'checkExistence' => true
);

public function setTableDefinition()
{
$name = $this->_options['name'];
if($this->_options['alias'])
{
$name . = ' as ' . $this->_options['alias'];
$this->hasColumn($name, 'string', 255, $this->_options['options']);
}
}

// ...

Wir haben die Standardoptionen definiert und die Methode SetTableDefinition implementiert, welche unsere Optionen in eine Tabellendefinition aggregiert, also letztlich dafür sorgt, dass hinten ein DDL-Statement herauskommt.

Nun kann man unser brandneues Verhaltensmuster schonmal in eine unserer Tabellen einbauen (Denkt euch die korrekten Indentations einfach dazu, der Codeformatter fluppt nicht so richtig):


FileTuple:
actAs:
File:
name: filename
checkExistence: true

Um das ganze mit etwas Funktionalität anzureichern, implementieren wir anschließend unsere beiden Methoden getFile() und setFile():


public function setFile(File $file)
{
return new File($this->_invoker[$this->getOption('name')]);
}

public function getFile(File $file)
{
$this->_invoker[$this->getOption('name')] = $file->getPathname();
}

Das ist schon die ganze Magie. Über $this->_invoker können wir auf die Instanz von Doctrine_Record zugreifen und dort all die lustigen Spielereien veranstalten, die uns Doctrine gönnt. Rufen wir nun auf einem unserer ObjectModel-Instanzen von FileTuple eine der neuen Methoden auf, so werden diese via __call() auf das Verhaltensmuster gemappt. Somit können wir mit


Doctrine::getTable('FileTuple')->find(1)->getFile();

unsere File-Instanz zum einfachen Zugriff auf das Dateisystem erzeugen. Andersherum ist es möglich, mittels


$file = new FileTuple();
$file->setFile(new File('/pfad/zur/datei.png'));
$file->save();

ein neues RecordSet in unserer Datenbank zu erzeugen.

Als nächstes möchte ich, dass jede Instanz von Doctrine_Record, die mit unserem neuen Verhaltensmuster erzogen wurde, vor dem speichern prüft, ob der zu speichernde Dateipfad auch wirklich in unserem Dateisystem vorhanden ist. Diese Anforderung lösen wir, indem wir einen Eventlistener erzeugen und an unser Verhaltensmuster binden:


public function setTableDefinition()
{
// ...
// ...

$this->addListener(new Doctrine_Template_Listener_File($this->_options));

}

Die Klasse Doctrine_Template_Listener_File erweitert Doctrine_Record_Listener, die wiederum das Interface Doctrine_Record_Listener_Interface implementiert. Dadurch, dass wir Doctrine_Record_Listener erweitern, müssen wir nicht selbst das komplette Interface herunterwurschteln, sondern brauchen nur die Callback-Methoden zu implementieren, die wir benötigen. In diesem Falle interessiert uns der Sprungpunkt “postValidate”:


class Doctrine_Template_Listener_File extends Doctrine_Record_Listener
{
protected $_options;

public function __construct(array $options)
{
$this->_options = $options;
}

public function postValidate(Doctrine_Event $event)
{
if($this->_options['checkExistence'])
{
$file = $event->_invoker->getFile();
if(!$file->fileExists())
{
$error_stack = $event->getInvoker()->getErrorStack();
$error_stack->add($this->_options['name'], 'The file does not exist.');
}
}
}
}

Wieder holen wir mit $event->_invoker die jeweilige Instanz von Doctrine_Record, besorgen und über unser Behaviour via getFile() unser File-Objekt und können nun testen, ob die Datei tatsächlich im Dateisystem existiert:


$file = new FileTuple();
$file->setFile('/pfad/der/nicht/existiert');

// Exception, Validation error!
$file->save();

Das war nur ein kitzekleiner Einblick in das, was mit Behaviours innerhalb der Doctrine-Welt grundsätzlich machbar ist. Einen weiterführenden Artikel gibt es im Doctrine-Kochbuch unter http://doctrine-project.org/blog/cookbook-recipe-relation-dql-behavior. Ansonsten hilft einem zusätzlich ein längerer Blick auf den Code der Core-Behaviours. In Punkto Code-Style sollte man beachten, dass die Doctrine-Welt den Pear/Zend-Coding Guidelines folgt, so enthält jede Klasse grundsätzlich ihren kompletten Namensraum, und die Datei ist jeweils in einem Unterverzeichnis angesiedelt, das ähnlich wie in der Java-Welt den jeweiligen Namespace bildet. In Hinblick auf Abwärtskompatibilität zu php5.3 sollte man das auch so beibehalten. Auf die Ablage der Behaviour-Klassen im Dateisystem und das Autoloading (um das man sich nur kümmern muss, wenn man sich mit Doctrine außerhalb von Symfony bewegt) gehe ich hier nicht gesondert ein.

Zuletzt ist zu sagen, dass das obige Beispiel durchaus einen konkreten Anwendungsfall beschreibt: Ich habe ein ähnliches Verhaltensmuster mit einem meiner Symfony-Plugins gebundlet, dem sfFilebasePlugin. Hier kann Frau – so sie denn möchte – auch einmal reinschauen.

/**
* Set table definition for Timestampable behavior
*
* @return void
*/
public function setTableDefinition()
{
$name = $this->_options[‘name’];
if ($this->_options[‘alias’])
{
$name .= ‘ as ‘ . $this->_options[‘alias’];
}
$this->hasColumn($name, ‘string’, 255, $this->_options[‘options’]);
$this->addListener(new Doctrine_Template_Listener_File($this->_options));
}