Hallo, ich heiße Matthias Zeis - schön, dass Sie den Weg zu meiner Übersetzung von ZFSTDE gefunden haben!
Falls Sie mehr über Magento 2 oder meine Arbeit als Technical Lead bei LimeSoda erfahren möchten, kommen Sie doch auf meiner Website vorbei.
Inhaltsverzeichnis
In Kapitel 3: Das Model
erläuterte ich einige
Konzepte rund um das Model, welches den Zustand und die Geschäftsregeln
einer Entität repräsentiert (zum Beispiel jene, die Einträge in einem Blog
verwaltet). Wir haben Glück, da unsere Entitäten in diesem Kapitel extrem
simpel sind. Dadurch können wir eine Menge abstrakter Konzepte rund um das
Model ignorieren, die für Webanwendungen mit weit komplexeren Systemen von
Bedeutung sind. Doch selbst unter unseren einfachen Umständen müssen wir
zu Beginn einige Herausforderungen an unser Design lösen.
Lassen Sie mich gleich
zu Anfang erwähnen, dass dieses Kapitel nicht die Verwendung von
Zend_Db
, Zend_Db_Table
oder
Zend_Db_Table_Row
in größerem Umfang lehrt. Wenn
wir im Verlauf des Buches auf diese Klassen stoßen, wird ihr Einsatz
erklärt, doch der Fokus dieses Kapitels liegt darauf,
Zend_Db_Table
als Basis für den Entwurf und die
Implementierung des Models für diesen Blog zu verwenden. Einen Bereich des
Models werden wir uns besonders genau ansehen: die Einträge.
Wenn wir über Models sprechen, gibt es einige Standardbegriffe, derer wir uns bedienen können. Zuallererst muss jedes Model einer Domain zugehörig sein, quasi einem Gesamtsystem, in dem das Model operiert. In unserer Anwendung ist das schlicht "blogging". Innerhalb dieser Domain setzt sich das Model aus einem oder mehreren Domain-Objekten zusammen. Ein Domain-Objekt ist repräsentiert eine Entität, dessen Eigenschaften und die Geschäftsregeln (auch bekannt als Domain-Logik), die auf das Objekt angewendet werden. Innerhalb unserer Domain "blogging" kann es also Domain-Objekte geben, die Entry-Entitäten (= Einträge) repräsentieren. Der Vollständigkeit halber kann eine Entität auch als ein eindeutig identifizierbarer Teil der Domain mit einem Satz von Verhaltensweisen definiert werden. Zum Beispiel sollten alle Einträge einen einzigartigen Titel und Inhalt besitzen, aber die tatsächlich einzigartige Eigenschaft wird die Id sein. Alle Einträge haben zudem einen Satz prozeduraler Verhaltensweisen - sie werden geschrieben, validiert und veröffentlicht.
Wenn Sie sich die Erklärung genauer ansehen, wird Ihnen auffallen: es steht nirgendwo, dass ein Model aus einem einzelnen Objekt besteht. In einem Klimamodell hätten wir tausende interagierende Entitäten, Faktoren, Verhaltensweisen, Randbedingungen etc. Wenn wir von einem Model sprechen, beziehen wir uns also eigentlich auf all die Entitäten, die in dem Model der Domain enthalten sind, und darauf, wie sie sich verhalten und miteinander agieren. Im Umfeld von Zend Framework werden Sie oft feststellen, dass Entitäten als Models bezeichnet werden, z.B. beim Entry-Model, dem Author-Model etc. In fast allen Fällen handelt es sich dabei um Domain-Objekte innerhalb eines einzelnen Domain-Models.
Wir haben die Domain-Logik erwähnt. Es handelt sich dabei um einen allgemeineren Begriff für Geschäftsregeln und Verhalten - doch es geht nicht immer um Geschäftslogik. Oft wird unsere Domain-Logik Bedingungen beschreiben, die auf die Eigenschaften der Domain-Objekte zutreffen, z.B. für die Validierung und das Filtern von Eigenschaften. Ich erwähne das, um zu betonen, dass die Validierung eine Aufgabe des Models ist, nicht des Controllers oder der View. Wenn wir uns in einem späteren Kapitel Formulare ansehen, werden Sie sehen, wie das zum Tragen kommt.
Das Hauptproblem, auf das Entwickler häufig beim Konzeptionieren der Model-Schicht ihrer Anwendung stoßen, ist die Frage, wie nahe an der "Oberfläche" die Speicherschicht des Models liegen soll.
In sehr einfachen
Anwendungen mag es reichen, wenn wir direkt mittels
Zend_Db
SQL-Abfragen absetzen oder Domain-Objekte
erstellen, die - der Zweckmäßigkeit halber -
Zend_Db_Table
erweitern (welches das
Table-Data-Gateway-Pattern von Martin Fowler aus seinem Buch "Patterns Of
Enterprise Application Architecture" (kurz POEAA) implementiert). Das
bringt den Speichermechanismus, eine relationale Datenbank, an die
Oberfläche. Die Anwendung kann somit direkt darauf zugreifen. Dasselbe
kann über Zend_Db_Table_Row
gesagt werden, das
Fowlers Row-Data-Gateway-Pattern implementiert, oder über ActiveRecord von
Ruby On Rails, was (mit einigen Verbesserungen, die es einem Data-Mapper
ähnlicher machen) Fowlers Active-Record-Pattern implementiert. Diese drei
Pattern haben eines gemeinsam: sie kapseln direkt den Datenbankzugriff,
oft indem sie von einer Basis-Klasse erben, die an eine einzelne
Datenbanktabelle oder -Zeile gebunden ist.
In vielen nicht so simplen Anwendungen kann das Domain-Model aber an Komplexität gewinnen. Vielleicht stellt sich heraus, dass das Domain-Model, das in Form von Objekten und Objekt-Eigenschaften dargestellt wird, nicht einfach auf Datenbanktabellen umgelegt werden kann. Ein einfaches Beispiel dafür ist ein Blog-Eintrag. Der Eintrag scheint eine simple Entität zu sein, die einer Entries-Tabelle in einem Datenbankschema zugewiesen werden kann, aber sie enthält auch eine Referenz (z.B. einen Fremdschlüssel) für einen Autor. Aus Sicht unseres Domain-Objekts bedeutet das, dass das Objekt für den Eintrag ein Autoren-Objekt enthält. Warum? Weil das Model aus einer objektorientierten Perspektive entworfen wird und es sich dabei um das offensichtlichste Design handelt.
Aus Sicht des Datenbankschemas würden Autoren in einer eigenen Tabelle abgespeichert werden. Das heißt, dass unser Entry-Objekt nicht auf genau eine einzige Datenbanktabelle abgebildet wird, sondern auf zwei Tabellen. Wir können das wohl lösen, indem wir in SQL einen Join auf die Tabellen anwenden, wenn wir die Daten abfragen. Verwenden wir einen Join, dann müssen wir die Autorendaten in ein Autorenobjekt filtern oder alternativ doch zwei SQL-Abfragen verwenden, um den zwei Objekten zu entsprechen. In beiden Fällen haben wir ein Problem, da unser Domain-Objekt von einer Klasse erbt, die an eine einzelne Datenbanktabelle gebunden ist, obwohl für das Objekt eigentlich zwei Tabellen abgefragt werden müssen.
Das Beispiel demonstriert eine unausweichliche Tatsache der Anwendungsentwicklung - Objekte lassen sich nur in sehr simplen Domain-Models 1:1 in Datenbanken abbilden (im Sinne von "ein Objekt - eine Tabelle"). Letztendlich benötigt jedes komplexere Model komplexere Logik, um diese beiden miteinander zu verbinden. Das führt uns zu einem jener Pattern, die hauptsächlich außerhalb der simplen Szenarios verwendet werden: dem Data Mapper. Übrigens, raten Sie mal, wer es definiert hat? Martin Fowler - und ja, Sie sollten sein Buch wirklich lesen, wenn Sie eine Ausgabe davon finden können...
Worauf will ich also
hinaus? Nun, wenn wir den Datenbankzugriff bis an die Oberfläche gelangen
lassen (zum Beispiel, indem wir von Zend_Db_Table
erweitern, das nur eine einzelne Tabelle repräsentiert), stecken wir bald
in einem inflexiblen Design fest, das nur schwer mit Unterschieden
zwischen der Objekt-Domain und der Datenbanktabellen-Domain umgehen kann.
Wenn zum Beispiel unser Entry-Domainobjekt
Zend_Db_Table
erweitert, ist es direkt an die
Entries-Tabelle gebunden. Woher kommt dann der Autor? Autoren können nicht
geladen werden, indem wir eine Abfrage an die Entries-Tabelle stellen,
also müssen wir zusätzlichen Code mit hinein ziehen oder direkt
SQL-Abfragen ablaufen lassen (damit sind wir wieder bei Joins oder zwei
seperaten Abfragen) etc. All das zeigt uns, dass es einfach nicht
funktionieren kann, wenn wir eine Klasse erweitern, die an nur eine
Tabelle gebunden ist.
Die Alternative ist, unser Domainobjekt frei von jeglichen Datenbank-bezogenen Methoden zu halten und stattdessen alle Datenbankzugriffe in einem Data-Mapper zu verstecken, der sich um die Abbildung der Objekte auf Tabellen kümmert und der mit einer beliebigen Anzahl von Tabellen umgehen kann (lassen Sie einfach drei weitere Mapper auf ihn los). Damit sind unsere Domainobjekte vom Datenbankschema entkoppelt.
Lassen Sie uns einen Blick auf Fowlers Definition eines Data-Mappers werfen.
A layer of Mappers that moves data between objects and a database while keeping them independent of each other and the mapper itself.
Die Definition bestätigt unseren Verdacht: Domainobjekte wissen nicht einmal, dass eine Datenbank existiert, wenn ein Data-Mapper verwendet wird.
Falls Sie von diesem
Abschnitt irgendetwas mitnehmen müssen, dann Folgendes: Klassen für den
direkten Datenbankzugriff wie Zend_Db_Table
zu
erweitern ist für sehr einfache Domain-Models in Ordnung. Es geht leicht
von der Hand, ist leicht zu verstehen und benötigt sehr wenig Code. In
komplexeren Domain-Models jedoch, in dem Domain-Objekte andere
Domain-Objekte enthalten können, wird dieser Weg der Vererbung nicht gut
funktionieren. An diesem Punkt benötigen wir eindeutig eine bessere Lösung
wie einen Data-Mapper und ein etwas komplexeres Klassendesign.
Ich habe mich mit voller
Absicht strikt an Fowlers Patterns gehalten. Je mehr sich die einfache
Vererbung als Problem herausstellt, desto wahrscheinlicher ist es, dass
Entwickler ihr Design anpassen, um die mangelnde Flexibilität
wettzumachen. In der Community des Zend Framework entstand schon öfters
die Debatte, ob Models (oder eher: Domain-Objekte) eine is-a-Beziehung
(über Vererbung) oder eine has-a-Beziehung (über Komposition) mit den
Zend_Db
-Klassen eingehen sollten. Von Zeit zu Zeit
weicht die Diskussion auch ins Reich der Daten-Container und Gateways ab.
Dabei wird eigentlich immer über ein offensichtliches Konzept gesprochen
oder rund um ein solches gecodet - es handelt sich um halb- oder fast
vollständige Lösungen auf dem Weg hin zum Data-Mapper. Die Lösung ist so
offensichtlich, dass viele Entwickler einen kompletten Data-Mapper
implementieren ohne jemals zu realisieren, dass dafür ein formaler Name
und sehr viel Wissen zur Implementierung in der Literatur
existieren!
Mit diesem Lösungsansatz im Repertoire können wir uns nun daran machen, die Anforderungen des tatsächlichen Domain-Objekts zu erheben, das unsere Blog-Einträge repräsentieren soll. Wir wissen aus dem letzten Abschnitt, dass wir zumindest ein Entry-Domainobjekt benötigen. An diesem Punkt beschäftigen wir uns noch nicht mit Datenbankschemen und wir haben auch nur eine vage Vorstellung davon, welche Eigenschaften unsere Einträge besitzen müssen, also konzentrieren wir uns nur auf die Kernanforderungen.
Es ist ebenfalls wichtig, dass wir uns an dieser Stelle die Definition eines Domain-Objekts in Erinnerung rufen. Es repräsentiert eine eindeutig identifizierbare Entität mit einem Satz an Verhaltensweisen. Das Schlüsselwort dabei ist "eindeutig", da jedes Domain-Objekt eine einzelne Entität repräsentiert. Wollen wir eine Vielzahl ähnlicher Entitäten abbilden, benötigen wir eine Klasse, die eine Sammlung von Einträgen vorhält.
Unser Domain-Objekt wird zumindest die folgenden Eigenschaften zur Verfügung stellen:
Das sind nicht alle Eigenschaften, die wir benötigen werden, aber genug für den Anfang. Da wir inkrementelle Entwicklung betreiben, machen wir uns über zusätzliche Funktionalitäten erst Gedanken, wenn sie Teil einer tatsächlichen Anforderung geworden sind.
Wir können diese Eigenschaften beschreiben, indem wir Randbedingungen (constraints) aufstellen. Die Eigenschaften müssen diesen Bedingungen entsprechen, damit unser Domain-Objekt einen gültigen Eintrag repräsentieren kann. Mit der Validierung werden wir uns zwar nicht unmittelbar in diesem Kapitel beschäftigen, doch wir werden sie etwas später integrieren.
Die Id (id) ist eine positive, ganze Zahl (Integer) größer 0 und identifiziert den aktuellen Eintrag eindeutig.
Der Title (title) ist eine Zeichenkette, die nicht leer ist, Klartext oder XHTML enthält und einen eindeutigen Eintragstitel darstellt.
Der Inhalt (content) ist ebenfalls eine nicht leere Zeichenkette, und zwar Klartext oder Code zur Auszeichnung mit Doctype XHTML 1.0 Strict.
Der Titel und der Inhalt dürfen nur eine Teilmenge der XHTML-Tags und -Attribute enthalten. Diese Teilmenge wird über eine Whitelist definiert.
Der Autor (author) ist ein valides Author-Domain-Objekt, das den Autor des Eintrags repräsentiert.
Die Eigenschaft published_date ist ein Datum entsprechend des Standards ISO 8601.
Mittels dieser Randbedingungen kann das Domain-Objekt bestimmen, ob die übermittelten Daten einem gültigen Eintrag entsprechen. Wir wir feststellen, wird unser Eintrag eine Referenz auf einen Autor enthalten. Da all diese Entitäten durch Domain-Objekte dargestellt werden, erstellen wir ein ähnliches Profil für ein Author-Objekt. Dieses Objekt enthält folgende Eigenschaften:
Während das Entry-Domain-Objekt sich mit einem einzelnen Eintrag, dessen Eigenschaften und Gültigkeit beschäftigt, kümmert sich der Data-Mapper um die Persistenz dieser Objekte zwischen den Anfragen. Seine Aufgabe ist es, über eine Datenbankzugriffsschicht Daten der Domain-Objekte in der Datenbank anzulegen, zu lesen, zu aktualisieren und zu löschen (zusammengefasst werden diese oft als CRUD-Operationen bezeichnet: create, read, update, delete). Natürlich umfasst diese Aufgabe auch, die Eigenschaften der Domain-Objekte auf die entsprechenden Tabellen und Spaltennamen umzulegen. Der Mapper muss dies bewerkstelligen, ohne das Datenbankschema, die Zugriffsart oder die Mapping-Logik offenzulegen.
Er ist dafür verantwortlich, die Daten der Entität aus der Datenbank zu holen, um daraus ein Domain-Objekt zu erstellen und zurückzuliefern. Daher liegt es nahe, dass er alle Hilfsmethoden zur Verfügung stellt, die CRUD-Operationen und ihre Auswahlkriterien betreffen (die z.B. dem WHERE-Teil von SQL-Anfragen entsprechen oder dem Teil der Spaltenauswahl, weil eventuell nur bestimmte Informationen für eine Entität benötigt werden). Da hier so viel passiert, sollte verständlich sein, dass Domain-Objekte eher keine Aufrufe an den Data-Mapper absetzen werden. Stattdessen werden wir in unserer Anwendung den Data-Mapper verwenden und ihm die Domain-Objekte übergeben, um damit zu arbeiten. Dieses Design ist nicht unbedingt das am leichtesten verwendbare - wir benötigen in der Controller-Schicht unserer Anwendung aus offensichtlichen Gründen mehr Code, da sich durch die Einführung des Data-Mappers die Anzahl der Objekte verdoppelt.
Eine gängige Methode, um das Problem der großen Anzahl an Objekten einzudämmen ist, das Domain-Objekt von der Existenz ihres Data-Mappers in Kenntnis zu setzen und gleichzeitig abzusichern, dass diese Kenntnis nicht über die Data-Mapper-API hinausgeht. Denken Sie dran, dass wir in der objektorientierten Programmierung Code immer nur auf das Interface hin schreiben sollten, nie auf die Implementierung. Wir werden hier aber keine Abkürzungen nehmen - unser Domain-Model für das Bloggen ist simpel genug, so dass wir mit jeder Verkomplizierung unserer Lösung mehr Zeit für die Entwicklung aufwenden müssten, als es für diesen Data-Mapper angebracht ist.
Nachdem wir unser Model genauer ausgearbeitet haben, können wir nun ermitteln, welche Funktionalitäten des Models wir in existierende Zend-Framework-Komponenten auslagern können.
Für die Implementierung von Domain-Objekten in ihrer Grundform benötigt man nur eines. PHP 5. Alle Domain-Objekte sind einfach nur gute, alte PHP-Objekte ohne Besonderheiten. Ich weiß, das ist enttäuschend! Das Model sollte komplex sein, unmöglich zu verstehen, man sollte einen Doktor-Titel dafür brauchen. Stattdessen haben wir es in ein System von Objekten eingedampft, die für sich genommen nicht besonders kompliziert sind.
Bevor unser Model
gespeichert werden kann, muss sichergestellt sein, dass die Daten des
Models die von uns definierten Randbedingungen erfüllen und Regeln
befolgen, dass sie also valide sind und alle Werte gefiltert wurden, wie
es eben nötig ist. Wir könnten Validatoren in Form von
Zend_Validate
und Filter in Form von
Zend_Filter
verwenden, aber unsere Formulare für
das Model würden dieses Regeln duplizieren. Vervielfältigung ist
schlecht, und daher werden wir in logischer Folge
Zend_Form
-Instanzen als Basis verwenden, um diese
Regeln zu implementieren.
Der Einsatz von
Zend_Form
an dieser Stelle kommt nicht ohne
Fragen aus. Da die Klasse ein Formular repräsentiert, werden wir sie
doch eindeutig häufig in unserer View verwenden. Vermischen wir damit
nicht Model und View, verwenden die Klasse also in unangebrachter Weise?
Das ist die eine Sichtweise. Die andere ist, dass
Zend_Form
-Instanzen eine Doppelrolle als Element
der Präsentation und als Container für vom Model abgeleitete
Validierungs- und Filterregeln wahrnehmen können. Das mag zwar nicht
immer der Fall sein, und bei komplexen Models kann es sogar vorkommen,
dass Formulare nur zu einem kleinen Teil den in einem Model
vorgehaltenen Daten entsprechen. Doch unser Blog ist ziemlich simpel,
also brauchen wir uns darüber kaum Gedanken zu machen. Von einem
technischen Standpunkt aus wäre die ideale thereotische Lösung eine
Formular-Klasse, die zwei unabhängige Teile enthält: einen
Daten-Container mit Validatoren und Filtern (sehr nahe an einem
Domain-Objekt) und einen Satz an Renderern, welche die Container in das
Formular einer View transformieren können. Doch lassen wir das
Wunschdenken beiseite (selbst die theoretisch perfekten Lösungen in
diesem Gebiet sind mindestens so komplex wie
Zend_Form
, wenn nicht schlimmer), geben wir uns
mit dem zufrieden was wir haben und passen wir es an unsere Bedürfnisse
an.
Wir werden uns
Zend_Form
in einem späteren Kapitel ansehen und
uns damit beschäftigen, wie es implementiert wird.
Da es sich hier um ein
Zend-Framework-Buch handelt, werden wir natürlich
Zend_Db
verwenden. Genau genommen
Zend_Db_Table_Abstract
, welches das
Table-Data-Gateway-Pattern aus Martin Fowlers POEAA implementiert.
Dieses Gateway-Pattern kann definiert werden als eine Klasse, die den
Zugriff auf eine Tabelle in einer Datenbank ermöglicht und es uns
erlaubt, Inserts, Selects, Updates und Deletes auf jede Zeile oder auf
Gruppen von Zeilen dieser Tabelle auszuführen. Fowler definiert dieses
Pattern wie folgt::
An object that acts as a Gateway to a database table. One instance handles all the rows in the table.
Zend_Db
bietet zudem eine Implementierung des Row-Data-Gateway-Pattern in Form
von Zend_Db_Table_Row
an. Das Pattern ist dem
Table-Data-Gateway ähnlich mit der Ausnahme, dass es sich mit einer
einzelnen Zeile einer Datenbanktabelle auseinandersetzt. In beiden
Fällen bietet Zend_Db
über seine öffentliche API
einen abstrahierten Zugriff. Sie können SQL-Abfragen konstruieren, indem
Sie Objekt-Methoden hintereinander aufrufen. Das wird bei beiden
Pattern-Implementierungen so gemacht.
Wir werden für unser
Model Data-Mapper stellen, die auf die Datenbank mittels
Zend_Db_Table
zugreifen (also die Option
Table-Data-Gateway). Das stimmt mit dem Konzept eines Data-Mappers
überein, der von vielen Domain-Objekten verwendet werden kann, aber von
ihnen unabhängig bleibt, das heißt: er ist nicht mit einem spezifischen
Domain-Objekt verbunden, kann aber spezifische, konkrete Subklassen für
jedes Domain-Objekt anbieten, um eine für dieses Domain-Objekt
spezifische Mapping-Logik umzusetzen.
Wichtig: unsere Domain-Objekte oder Mapper werden nie Zend_Db_Table erweitern, wie es im Referenzhandbuch vorgeschlagen wird. Dadurch würde unser Model auf eine Implementierung mit Datenbankzugriff festgenagelt. Dadurch müsste unser Model auch Kenntnis vom Speicher-Backend, also dem Objekt zur Persistenzhaltung der Daten, erlangen. Dadurch würden Entwickler ermuntert, Datenbank und Nicht-Datenbank-Code überall miteinander zu vermischen. Solange es nicht unser Ziel ist, wirklich nur Datenbankabstraktion zu verwenden, handelt es sich dabei nur um schlechtes objektorientiertes Design. Die Quintessenz daraus ist, dass wir Wert auf die Regel "composition over inheritance" legen, "Komposition vor Vererbung", eine fundamentale "Best Practice" in der objektorientierten Programmierung. All unsere Domain-Objekte werden eine "has-a" oder "has-many"-Beziehung mit anderen Klassen eingehen, ausgenommen vielleicht abstrakte Elternklassen oder Interfaces, mittels derer wir sichergehen können, dass alle Domain-Objekte in ihrer API zumindest einen ähnlichen Ansatz verfolgen.
Wie ich Sie bereits zu
Beginn des Buches gewarnt habe, entwickle ich Code (abgesehen von kurzen
Artikeln in meinem Blog) immer entsprechend des Test-Driven Design.
Deswegen wird der gesamte folgenden Code Schritt für Schritt mittels
Unit-Tests vorgestellt. Sehen Sie es positiv: immerhin haben Sie etwas,
dass Sie ins Verzeichnis /tests
schreiben können! Um
das Setup für das initiale Testframework vorzunehmen, werfen Sie bitte
einen Blick in Appendix C: Unit-Testing
und Test-Driven Design
(wird bald hinzugefügt).
Die Tests für unser
Model werden in /tests/ZFExt/Model
gespeichert. Ein
Test für unser Entry-Domain-Objekt zum Beispiel wird in der Datei
/tests/ZFExt/Model/EntryTest.php
abgelegt. Damit
der Test ausgeführt wird, müssen wir in demselben Verzeichnis eine Datei
AllTests.php
mit folgendem Inhalt
hinzufügen:
<?php
if (!defined('PHPUnit_MAIN_METHOD')) {
define('PHPUnit_MAIN_METHOD', 'ZFExt_Model_AllTests::main');
}
require_once 'TestHelper.php';
require_once 'ZFExt/Model/EntryTest.php';
class ZFExt_Model_AllTests
{
public static function main()
{
PHPUnit_TextUI_TestRunner::run(self::suite());
}
public static function suite()
{
$suite = new PHPUnit_Framework_TestSuite('ZFSTDE Blog Suite: Models');
$suite->addTestSuite('ZFExt_Model_EntryTest');
return $suite;
}
}
if (PHPUnit_MAIN_METHOD == 'ZFExt_Model_AllTests::main') {
ZFExt_Model_AllTests::main();
}
Während wir unser
Model implementieren, werden Sie weitere Tests in dieser Datei
hinzufügen müssen, damit sie ausgeführt werden. Sie können hinzugefügt
werden, indem Sie dem Muster für die Suite
ZFExt_Model_EntryTest
folgen. Da es sich hierbei
nicht um die Datei AllTests.php
im obersten
Verzeichnis handelt, sollten Sie dies in die "root"-Testdatei
/tests/AllTests.php
einfügen:
<?php
if (!defined('PHPUnit_MAIN_METHOD')) {
define('PHPUnit_MAIN_METHOD', 'AllTests::main');
}
require_once 'TestHelper.php';
require_once 'ZFExt/Model/AllTests.php';
class AllTests
{
public static function main()
{
PHPUnit_TextUI_TestRunner::run(self::suite());
}
public static function suite()
{
$suite = new PHPUnit_Framework_TestSuite('ZFSTDE Blog Suite');
$suite->addTest(ZFExt_Model_AllTests::suite());
return $suite;
}
}
if (PHPUnit_MAIN_METHOD == 'AllTests::main') {
AllTests::main();
}
Wie im Anhang
beschrieben werden Tests gestartet, in dem Sie in der Konsole zu
/tests/ZFExt/Model
navigieren (oder
/tests
, um jeden einzelnen Test der gesamtem
Anwendung ablaufen zu lassen) und folgenden Befehl aufrufen:
phpunit AllTests.php
Da es sich bei unserem
Entry-Domainobjekt nur um ein gewöhnliches Objekt handelt, können wir es
zu Beginn als einen simplen Datencontainer behandeln. Wir sollten unsere
Klassen immer mit einem Namespace versehen (nach der alten Art des
Namespacings vor PHP 5.3). Daher werden wir den
NamespaceZFExt_Model
für alle Model-bezogenen
Klassen verwenden. Das gilt auch für die Test-Dateien. Vorerst speichern
wir alles im Verzeichnis /library
. Lassen Sie uns
mit ersten Tests beginnen, die überprüfen, ob wir Eigenschaften des
Domain-Objekts setzen und eine Instanz eines Entry-Domain-Objekts mit
einem Array von Daten erstellen können. Dies ist zu Beginn der Inhalt
von /tests/ZFExt/Model/EntryTest.php
:
<?php
require_once 'ZFExt/Model/Entry.php';
class ZFExt_Model_EntryTest extends PHPUnit_Framework_TestCase
{
public function testSetsAllowedDomainObjectProperty()
{
$entry = new ZFExt_Model_Entry;
$entry->title = 'My Title';
$this->assertEquals('My Title', $entry->title);
}
public function testConstructorInjectionOfProperties()
{
$data = array(
'title' => 'My Title',
'content' => 'My Content',
'published_date' => '2009-08-17T17:30:00Z',
'author' => new ZFExt_Model_Author
);
$entry = new ZFExt_Model_Entry($data);
$expected = $data;
$expected['id'] = null;
$this->assertEquals($expected, $entry->toArray());
}
}
Das können wir nun in
/library/ZFExt/Model/Entry.php
implementieren (die
Tests schlagen auf jeden Fall fehl, wenn die Klasse nicht geschrieben
wurde!):
<?php
class ZFExt_Model_Entry
{
protected $_data = array(
'id' => null,
'title' => '',
'content' => '',
'published_date' => '',
'author' => null
);
public function __construct(array $data = null)
{
if (!is_null($data)) {
foreach ($data as $name => $value) {
$this->{$name} = $value;
}
}
}
public function toArray()
{
return $this->_data;
}
public function __set($name, $value)
{
$this->_data[$name] = $value;
}
public function __get($name)
{
if (array_key_exists($name, $this->_data)) {
return $this->_data[$name];
}
}
}
Sie wundern sich
vielleicht, warum nicht alle Eigenschaften als public deklariert wurden.
Wenn man ein Array mit der Sichtbarkeit "protected" und PHPs magische
Methoden (wie __set()
) für den Datenzugriff
verwendet, hat man den Vorteil, dass man beim Zugriff immer ein Gateway
passiert. Dadurch können wir beim Setzen eines Wertes nach Belieben
Überprüfungen ablaufen lassen und Ausnahmen werfen, falls Fehler
auftreten.
Unser neues Objekt ist noch sehr einfach gehalten. Lassen Sie uns den Rest der magischen Standardmethoden hinzufügen, damit wir kontrollieren können, ob die Eigenschaften im geschützten Array gesetzt werden und ob sie zurückgesetzt werden, falls wir das benötigen. Unten werden nur die neuen Tests gezeigt.
<?php
require_once 'ZFExt/Model/Entry.php';
class ZFExt_Model_EntryTest extends PHPUnit_Framework_TestCase
{
// ...
public function testReturnsIssetStatusOfProperties()
{
$entry = new ZFExt_Model_Entry;
$entry->title = 'My Title';
$this->assertTrue(isset($entry->title));
}
public function testCanUnsetAnyProperties()
{
$entry = new ZFExt_Model_Entry;
$entry->title = 'My Title';
unset($entry->title);
$this->assertFalse(isset($entry->title));
}
}
<?php
class ZFExt_Model_Entry
{
protected $_data = array(
'id' => null,
'title' => '',
'content' => '',
'published_date' => '',
'author' => null
);
public function __construct(array $data = null)
{
if (!is_null($data)) {
foreach ($data as $name => $value) {
$this->{$name} = $value;
}
}
}
public function toArray()
{
return $this->_data;
}
public function __set($name, $value)
{
$this->_data[$name] = $value;
}
public function __get($name)
{
if (array_key_exists($name, $this->_data)) {
return $this->_data[$name];
}
}
public function __isset($name)
{
return isset($this->_data[$name]);
}
public function __unset($name)
{
if (isset($this->_data[$name])) {
unset($this->_data[$name]);
}
}
}
Unser Domain-Objekt
ist nun besser definiert. Momentan kann man ohne Einschränkung
Eigenschaften setzen, aber unser Domain-Objekt benötigt nur die
Eigenschaften, die im anfänglichen Datenarray als Schlüssel gesetzt
sind. Wir können verhindern, dass nicht benötigte Eigenschaften gesetzt
werden, und eine Ausnahme werfen, falls das passiert, indem wir wie
folgt eine zusätzliche Kontrolle in die Methode
__set()
einbauen.
<?php
require_once 'ZFExt/Model/Entry.php';
class ZFExt_Model_EntryTest extends PHPUnit_Framework_TestCase
{
// ...
public function testCannotSetNewPropertiesUnlessDefinedForDomainObject()
{
$entry = new ZFExt_Model_Entry;
try {
$entry->notdefined = 1;
$this->fail('Setting new property not defined in class should'
. ' have raised an Exception');
} catch (ZFExt_Model_Exception $e) {
}
}
}
<?php
class ZFExt_Model_Entry
{
protected $_data = array(
'id' => null,
'title' => '',
'content' => '',
'published_date' => '',
'author' => null
);
public function __construct(array $data = null)
{
if (!is_null($data)) {
foreach ($data as $name => $value) {
$this->{$name} = $value;
}
}
}
public function toArray()
{
return $this->_data;
}
public function __set($name, $value)
{
if (!array_key_exists($name, $this->_data)) {
throw new ZFExt_Model_Exception('You cannot set new properties'
. 'on this object');
}
$this->_data[$name] = $value;
}
public function __get($name)
{
if (array_key_exists($name, $this->_data)) {
return $this->_data[$name];
}
}
public function __isset($name)
{
return isset($this->_data[$name]);
}
public function __unset($name)
{
if (isset($this->_data[$name])) {
unset($this->_data[$name]);
}
}
}
Als nächstes soll
unser Entry-Domain-Objekt ein Autoren-Objekt enthalten. Da jedes weitere
Domain-Objekt unseren bisher in ZFExt_Model_Entry
geschriebenen Code vermutlich duplizieren würde, sollten wir unsere
Klasse refaktorisieren, damit sie alle möglicherweise wiederverwendbaren
Methoden von einer Parent-Klasse erbt. Wir fügen daher nun eine neue
Parent-Klasse hinzu namens ZFExt_Model_Entity
hinzu, die diese Rolle übernimmt.
<?php
class ZFExt_Model_Entity
{
public function __construct(array $data = null)
{
if (!is_null($data)) {
foreach ($data as $name => $value) {
$this->{$name} = $value;
}
}
}
public function toArray()
{
return $this->_data;
}
public function __set($name, $value)
{
if (!array_key_exists($name, $this->_data)) {
throw new ZFExt_Model_Exception('You cannot set new properties'
. ' on this object');
}
$this->_data[$name] = $value;
}
public function __get($name)
{
if (array_key_exists($name, $this->_data)) {
return $this->_data[$name];
}
}
public function __isset($name)
{
return isset($this->_data[$name]);
}
public function __unset($name)
{
if (isset($this->_data[$name])) {
unset($this->_data[$name]);
}
}
}
<?php
class ZFExt_Model_Entry extends ZFExt_Model_Entity
{
protected $_data = array(
'id' => null,
'title' => '',
'content' => '',
'published_date' => '',
'author' => null
);
}
Ein weiterer Lauf unserer Tests wird bestätigen, dass die Refaktorierung erfolgreich war.
Nun wollen wir eine
ähnliche Klasse für Autoren hinzufügen. Um den neuen Test anzupassen,
bearbeiten Sie die Datei
/tests/ZFExt/Model/AllTests.php
und fügen einen
neuen Test unter /tests/ZFExt/Model/AuthorTest.php
hinzu. Die Tests und die Klassen, die diese enthalten, werden denen des
Entry-Domain-Objekts sehr ähnlich sein. Hier sind die ersten Tests, die
jenen des Entry-Objekts entsprechen, aber die Eigenschaften des
Autoren-Objekts berücksichtigen.
<?php
class ZFExt_Model_AuthorTest extends PHPUnit_Framework_TestCase
{
public function testSetsAllowedDomainObjectProperty()
{
$author = new ZFExt_Model_Author;
$author->fullname = 'Joe';
$this->assertEquals('Joe', $author->fullname);
}
public function testConstructorInjectionOfProperties()
{
$data = array(
'username' => 'joe_bloggs',
'fullname' => 'Joe Bloggs',
'email' => '[email protected]',
'url' => 'http://www.example.com'
);
$author = new ZFExt_Model_Author($data);
$expected = $data;
$expected['id'] = null;
$this->assertEquals($expected, $author->toArray());
}
public function testReturnsIssetStatusOfProperties()
{
$author = new ZFExt_Model_Author;
$author->fullname = 'Joe Bloggs';
$this->assertTrue(isset($author->fullname));
}
public function testCanUnsetAnyProperties()
{
$author = new ZFExt_Model_Author;
$author->fullname = 'Joe Bloggs';
unset($author->fullname);
$this->assertFalse(isset($author->fullname));
}
public function testCannotSetNewPropertiesUnlessDefinedInClass()
{
$author = new ZFExt_Model_Author;
try {
$author->notdefinedinclass = 1;
$this->fail('Setting new property not defined in class should'
. ' have raised an Exception');
} catch (ZFExt_Model_Exception $e) {
}
}
}
Hier ist die Implementierung, die alle neuen Tests erfüllt.
<?php
class ZFExt_Model_Author extends ZFExt_Model_Entity
{
protected $_data = array(
'id' => null,
'username' => '',
'fullname' => '',
'email' => '',
'url' => ''
);
}
Um uns endgültig
abzusichern, dass unser Interface an die Verwendung dieser
Domain-Objekte gebunden ist, kontrollieren wir, dass
ZFExt_Model_Entry
nur ein
ZFExt_Model_Author
-Objekt akzeptiert, wenn die
Eigenschaft author gesetzt wird. Wie üblich schreiben wir zuerst den
Test und dann den Code, mittels dem dieser Test bestanden wird.
<?php
class ZFExt_Model_EntryTest extends PHPUnit_Framework_TestCase
{
// ...
public function testThrowsExceptionIfAuthorNotAnAuthorEntityObject()
{
$entry = new ZFExt_Model_Entry;
try {
$entry->author = 1;
$this->fail('Setting author should have raised an Exception'
. ' since value was not an instance of ZFExt_Model_Author');
} catch (ZFExt_Model_Exception $e) {
}
}
}
<?php
class ZFExt_Model_Entry extends ZFExt_Model_Entity
{
protected $_data = array(
'id' => null,
'title' => '',
'content' => '',
'published_date' => '',
'author' => null
);
public function __set($name, $value)
{
if ($name == 'author' && !$value instanceof ZFExt_Model_Author) {
throw new ZFExt_Model_Exception('Author can only be set using'
. ' an instance of ZFExt_Model_Author');
}
parent::__set($name, $value);
}
}
Alles was wir hier
getan haben ist, die Methode __set()
der
Elternklasse zu überschreiben, um eine neue Kontrolle hinzuzufügen, die
dafür sorgt, dass unser Objekt als Wert für author nur ein Objekt des
Typs ZFExt_Model_Author
enthält. Wird nicht der
Autor gesetzt, übergeben wir der Elternklasse die Kontrolle, um die
Eigenschaft zu setzen.
Wir sind fürs Erste fertig! Wenden wir unsere Aufmerksamkeit nun unserer Implementierung des Data-Mappers zu, damit wir diese Objekte in einer Datenbank abspeichern oder sie von dort beziehen können.
Unsere Data-Mapper
werden im Hintergrund Zend_Db_Table
verwenden,
weswegen ihre Funktion in diesem Design ist, typische CRUD-Operationen
auszuführen. Später werden wir sehen, dass sie ebenfalls Träger von
Methoden mit speziellerem Verwendungszweck (z.B. Abfragen von
Datensätzen, die bestimmte Bedingungen erfüllen) sein können. Für den
Moment konzentrieren wir uns aber darauf, sie erst einmal aufzusetzen.
Im ersten Test, den wir erstellen, wird eine Instanz unseres
Data-Mappers erzeugt und eine konfigurierte Instanz von
Zend_Db_Table_Abstract
erstellt, mit der
gearbeitet werden kann. Wie Sie sehen werden, verwende ich keine echte
Datenbank. Obwohl man am Beginn einiges an Code schreiben muss, verwende
ich statt einem echten
Zend_Db_Table_Abstract
-Objekt ein Mock-Object
(quasi einen Doppelgänger für den Test). Dadurch kann ich genau
kontrollieren, was dieses Objekt macht, welche Rückgabewerte erzeugt
werden, ich kann Erwartungen festlegen, welche Methoden aufgerufen
werden sollten, mit welchen Argumenten das geschehen sollte etc. Der
Hauptgrund warum ich das tue ist, dass eine reale Datenbank hier nichts
Neues bietet - unsere Tests brauchen keine Datenbank. Würden wir eine
Datenbank verwenden, würde es auch funktionieren, aber dann würden wir
zusätzlich zu allem anderen auch noch
Zend_Db_Table_Abstract
testen, da es ja
schließlich von unserem Code in Anspruch genommen wird. Zend Framework
hat aber bereits Tests für diese Komponente.
Fügen Sie wie zuvor
die Datei und Klasse zu
/tests/ZFExt/Model/AllTests.php
hinzu, um den Test
zu Ihrer Suite hinzuzufügen. Die Tests selbst werden in
/tests/ZFExt/Model/EntryMapperTest.php
geschrieben.
<?php
class ZFExt_Model_EntryMapperTest extends PHPUnit_Framework_TestCase
{
protected $_tableGateway = null;
protected $_adapter = null;
protected $_rowset = null;
protected $_mapper = null;
public function setup()
{
$this->_tableGateway = $this->_getCleanMock(
'Zend_Db_Table_Abstract'
);
$this->_adapter = $this->_getCleanMock(
'Zend_Db_Adapter_Abstract'
);
$this->_rowset = $this->_getCleanMock(
'Zend_Db_Table_Rowset_Abstract'
);
$this->_tableGateway->expects($this->any())->method('getAdapter')
->will($this->returnValue($this->_adapter));
$this->_mapper = new ZFExt_Model_EntryMapper($this->_tableGateway);
}
protected function _getCleanMock($className) {
$class = new ReflectionClass($className);
$methods = $class->getMethods();
$stubMethods = array();
foreach ($methods as $method) {
if ($method->isPublic() || ($method->isProtected()
&& $method->isAbstract())) {
$stubMethods[] = $method->getName();
}
}
$mocked = $this->getMock(
$className,
$stubMethods,
array(),
$className . '_EntryMapperTestMock_' . uniqid(),
false
);
return $mocked;
}
}
Alle Data-Mapper-Tests
verwenden ein Mock-Objekt, eine Imitation von
Zend_Db_Table_Abstract
- die Komponente ist
bereits vom Zend-Framework-Team getestet worden, also wäre es sinnlos,
ein Objekt zu verwenden, das tatsächlich mit einer Datenbank verbunden
ist. Wenn wir die Klasse in der Anwendung verwenden, werden wir
normalerweise nicht eine echte Instanz an den Konstruktor übergeben,
sondern wir können uns stattdessen darauf verlassen, dass der
Konstruktor eine passende Instanz erzeugen wird. Das obige Grundgerüst
für den Test ist darauf ausgelegt, eine voll funktionsfähige Imitation
von Zend_Db_Table_Abstract
zu erzeugen.
Auch wenn man die
Information im Handbuch zu PHPUnit nur schwer findet ohne in den
Quelltext einzutauchen, ermöglicht es die geschützte Methode
_getCleanMock()
die ich verwende, ein komplett
"jungfräuliches" Mock-Objekt zu erzeugen, bei der alle herkömmlichen
Methoden durch Imitationen ersetzt wurden.Sie erzeugt für das Imitat bei
jedem Aufruf einen einzigartigen Namen, um sicherzustellen, dass es zu
keinem Konflikt bei den Namen der imitierten Klassen kommt. Momentan ist
nur ein Schritt für uns notwendig: wir müssen darauf achten, dass alle
Zend_Db_Table_Abstract
-Mock-Objekte auch ein
Adapter-Imitat zurückgeben. Ansich gibt es nur einen Grund, ein
Mock-Objekt des Adapters zu erstellen, nämlich die häufig verwendete
Methode quoteInto(), mit der Werte in einer SQL-Expression oder einer
Bedingung maskier werden.
Hier ist unsere
anfängliche (bis jetzt ungetestete) Implementierung, die zeigt, warum es
nicht wert ist, sich die Mühe mit dem Testen der echten Instanz anzutun
- die Implementierung ist extrem einfach, und um es noch einmal zu
betonen, wir würden einfach nur
Zend_Db_Table_Abstract
testen.
<?php
class ZFExt_Model_EntryMapper
{
protected $_tableGateway = null;
protected $_tableName = 'entries';
public function __construct(Zend_Db_Table_Abstract $tableGateway)
{
if (is_null($tableGateway)) {
$this->_tableGateway = new Zend_Db_Table($this->_tableName);
} else {
$this->_tableGateway = $tableGateway;
}
}
protected function _getGateway()
{
return $this->_tableGateway;
}
}
Im obigen Code setzen
wir eine Instanz von Zend_Db_Table
auf, mit der
wir auf die Datenbank zugreifen. Obwohl diese Klasse als Abstract
bezeichnet wird, enthält sie eigentlich keine abstrakten Methoden. Als
einzige Konfiguration, die wir vorerst benötigen, müssen wir dieser
Instanz mitteilen, welche Datenbanktabelle verwendet werden soll. Wir
müssen keine Einstellungen für eine Datenbankverbindung übergeben, da
wir später einen Standarddatenbankadapter von unserer Bootstrap setzen
können.
Lassen Sie uns nun
einige nützliche Methoden hinzufügen. Wir beginnen mit einer Methode, um
ein neues Domain-Objekt zu speichern. Da wir
Zend_Db_Table_Abstract
nachgeahmt haben,
definieren wir fürs Erste keine Zusicherungen (assertions). Die
Zusicherungen ergeben sich in Wirklichkeit daraus, das wir Erwartungen
in den Mock-Objekten definieren und kontrollieren, dass unser Mapper die
erwartete Methode insert()
von
Zend_Db_Table_Abstract
mit dem korrekten
Daten-Array aufruft.
<?php
class ZFExt_Model_EntryMapperTest extends PHPUnit_Framework_TestCase
{
// ...
public function testSavesNewEntryAndSetsEntryIdOnSave() {
$author = new ZFExt_Model_Author(array(
'id' => 2,
'username' => 'joe_bloggs',
'fullname' => 'Joe Bloggs',
'email' => '[email protected]',
'url' => 'http://www.example.com'
));
$entry = new ZFExt_Model_Entry(array(
'title' => 'My Title',
'content' => 'My Content',
'published_date' => '2009-08-17T17:30:00Z',
'author' => $author
));
// Setze Erwartungen für das Mock-Objekt beim Aufruf von Zend_Db_Table::insert()
$insertionData = array(
'title' => 'My Title',
'content' => 'My Content',
'published_date' => '2009-08-17T17:30:00Z',
'author_id' => 2
);
$this->_tableGateway->expects($this->once())
->method('insert')
->with($this->equalTo($insertionData))
->will($this->returnValue(123));
$this->_mapper->save($entry);
$this->assertEquals(123, $entry->id);
}
// ...
}
Hier ist die Implementierung, die diesen Test besteht.
<?php
class ZFExt_Model_EntryMapper
{
protected $_tableGateway = null;
protected $_tableName = 'entries';
public function __construct(Zend_Db_Table_Abstract $tableGateway)
{
if (is_null($tableGateway)) {
$this->_tableGateway = new Zend_Db_Table($this->_tableName);
} else {
$this->_tableGateway = $tableGateway;
}
}
protected function _getGateway()
{
return $this->_tableGateway;
}
public function save(ZFExt_Model_Entry $entry)
{
$data = array(
'title' => $entry->title,
'content' => $entry->content,
'published_date' => $entry->published_date,
'author_id' => $entry->author->id
);
$entry->id = $this->_getGateway()->insert($data);
}
}
Das Speichern eines
neuen Eintrags in die Datenbank beinhaltet den Aufruf von
Zend_Db_Table_Abstract::insert()
mit einem
Array der Spaltennamen und Werte, die in die Datenbanktabelle "entries"
eingefügt werden sollen. Der Name der Tabelle wird im Konstruktor des
Data-Mappers gesetzt. Die id wird weggelassen, da sie über den
Rückgabewert von
Zend_Db_Table_Abstract::insert()
gesetzt
wird.
Wie Sie sehen können,
kennt unser Mapper das Datenbankschema - er bildet die Eigenschaft
id
des Autoren-Objekts auf eine Tabellenspalte namens
author_id
ab. Das Domainobjekt weiß nicht, dass diese
Datenbankspalte existiert. Der Rest der Autorendaten wird ignoriert, da
sie in einer anderen Tabelle gespeichert werden und nicht neu sind.
Eigentlich ist es recht offensichtlich: Sie können einen Autor nicht so
speichern, da Autoren-Objekte nur durch einen zukünftigen
Autoren-Data-Mapper gespeichert werden können.
Wir werden Einträge auch aktualisieren wollen. Sie sollten leicht aufzuspüren sein, da sie bereits einen existierenden Wert für die Id haben. Lassen Sie uns einen Test und die Implementierung für das Verhalten bei einer Aktualisierung hinzufügen. Auch diesmal werden wir Erwartungen in einem Mock-Objekt anstelle von Zusicherungen verwenden. Denken Sie daran, dass Erwartungen des Mock-Objekts natürlich kontrolliert werden. Falls irgendwelche Anforderungen wie die Anzahl der Methodenaufrufe oder ähnliches von unserer Implementierung nicht erfüllt werden, wird der Test also fehlschlagen.
<?php
class ZFExt_Model_EntryMapperTest extends PHPUnit_Framework_TestCase
{
// ...
public function testUpdatesExistingEntry() {
$author = new ZFExt_Model_Author(array(
'id' => 2,
'name' => 'Joe Bloggs',
'email' => '[email protected]',
'url' => 'http://www.example.com'
));
$entry = new ZFExt_Model_Entry(array(
'id' => 1,
'title' => 'My Title',
'content' => 'My Content',
'published_date' => '2009-08-17T17:30:00Z',
'author' => $author
));
// Setze Erwartungen für das Mock-Objekt beim Aufruf von Zend_Db_Table::update()
$updateData = array(
'id' => 1,
'title' => 'My Title',
'content' => 'My Content',
'published_date' => '2009-08-17T17:30:00Z',
'author_id' => 2
);
// quoteInto() wird aufgerufen, um die Parameter des Adapters zu maskieren
$this->_adapter->expects($this->once())
->method('quoteInto')
->will($this->returnValue('id = 1'));
$this->_tableGateway->expects($this->once())
->method('update')
->with($this->equalTo($updateData), $this->equalTo('id = 1'));
$this->_mapper->save($entry);
}
// ...
}
<?php
class ZFExt_Model_EntryMapper
{
protected $_tableGateway = null;
protected $_tableName = 'entries';
public function __construct(Zend_Db_Table_Abstract $tableGateway)
{
if (is_null($tableGateway)) {
$this->_tableGateway = new Zend_Db_Table($this->_tableName);
} else {
$this->_tableGateway = $tableGateway;
}
}
protected function _getGateway()
{
return $this->_tableGateway;
}
public function save(ZFExt_Model_Entry $entry)
{
if (!$entry->id) {
$data = array(
'title' => $entry->title,
'content' => $entry->content,
'published_date' => $entry->published_date,
'author_id' => $entry->author->id
);
$entry->id = $this->getGateway()->insert($data);
} else {
$data = array(
'id' => $entry->id,
'title' => $entry->title,
'content' => $entry->content,
'published_date' => $entry->published_date,
'author_id' => $entry->author->id
);
$where = $this->getGateway()->getAdapter()
->quoteInto('entry_id = ?', $entry->id);
$this->getGateway()->update($data, $where);
}
}
}
Fügen wir eine weitere Methode hinzu, bevor wir Schluss machen - wir werden während der Entwicklung unserer Blogging-Anwendung weitere Methoden hinzufügen. Wir werden nicht nur Einträge speichern und aktualisieren, sondern zumindest auch löschen und abfragen.
Das stellt uns vor
zumindest ein Problem, denn Einträge enthalten Autoren. Damit der
EntryMapper ein Autoren-Objekt beziehen kann, müssen wir zuerst einen
Data-Mapper für Autoren hinzufügen. Hier ist der komplette Satz an Tests
und eine Implementierung für die Klasse
ZFExt_Model_AuthorMapper
(die meisten Tests sind
denen sehr ähnlich, die wir bisher geschrieben haben - und einige werden
wir uns bald für den Entry-Data-Mapper genauer ansehen).
<?php
class ZFExt_Model_AuthorMapperTest extends PHPUnit_Framework_TestCase
{
protected $_tableGateway = null;
protected $_adapter = null;
protected $_rowset = null;
protected $_mapper = null;
public function setup()
{
$this->_tableGateway = $this->_getCleanMock(
'Zend_Db_Table_Abstract'
);
$this->_adapter = $this->_getCleanMock(
'Zend_Db_Adapter_Abstract'
);
$this->_rowset = $this->_getCleanMock(
'Zend_Db_Table_Rowset_Abstract'
);
$this->_tableGateway->expects($this->any())->method('getAdapter')
->will($this->returnValue($this->_adapter));
$this->_mapper = new ZFExt_Model_AuthorMapper($this->_tableGateway);
}
public function testCreatesSuitableTableDataGatewayObjectWhenInstantiated()
{
$mapper = new ZFExt_Model_EntryMapper($this->_tableGateway);
$this->assertTrue($mapper->getGateway()
instanceof Zend_Db_Table_Abstract);
}
public function testSavesNewAuthorAndSetsAuthorIdOnSave() {
$author = new ZFExt_Model_Author(array(
'username' => 'joe_bloggs',
'fullname' => 'Joe Bloggs',
'email' => '[email protected]',
'url' => 'http://www.example.com'
));
// Setze Erwartungen für das Mock-Objekt beim Aufruf von Zend_Db_Table::insert()
$insertionData = array(
'username' => 'joe_bloggs',
'fullname' => 'Joe Bloggs',
'email' => '[email protected]',
'url' => 'http://www.example.com'
);
$this->_tableGateway->expects($this->once())
->method('insert')
->with($this->equalTo($insertionData))
->will($this->returnValue(123));
$this->_mapper->save($author);
$this->assertEquals(123, $author->id);
}
public function testUpdatesExistingAuthor() {
$author = new ZFExt_Model_Author(array(
'id' => 2,
'username' => 'joe_bloggs',
'fullname' => 'Joe Bloggs',
'email' => '[email protected]',
'url' => 'http://www.example.com'
));
// Setze Erwartungen für das Mock-Objekt beim Aufruf von Zend_Db_Table::update()
$updateData = array(
'id' => 2,
'username' => 'joe_bloggs',
'fullname' => 'Joe Bloggs',
'email' => '[email protected]',
'url' => 'http://www.example.com'
);
$this->_adapter->expects($this->once())
->method('quoteInto')
->will($this->returnValue('id = 2'));
$this->_tableGateway->expects($this->once())
->method('update')
->with($this->equalTo($updateData), $this->equalTo('id = 2'));
$this->_mapper->save($author);
}
public function testFindsRecordByIdAndReturnsDomainObject()
{
$author = new ZFExt_Model_Author(array(
'id' => 1,
'username' => 'joe_bloggs',
'fullname' => 'Joe Bloggs',
'email' => '[email protected]',
'url' => 'http://www.example.com'
));
// Erwartetes Rowset-Ergebnis für den gefundenen Eintrag
$dbData = new stdClass;
$dbData->id = 1;
$dbData->fullname = 'Joe Bloggs';
$dbData->username = 'joe_bloggs';
$dbData->email = '[email protected]';
$dbData->url = 'http://www.example.com';
// Setze Erwartungen für das Mock-Objekt beim Aufruf von Zend_Db_Table::find()
$this->_rowset->expects($this->once())
->method('current')
->will($this->returnValue($dbData));
$this->_tableGateway->expects($this->once())
->method('find')
->with($this->equalTo(1))
->will($this->returnValue($this->_rowset));
$entryResult = $this->_mapper->find(1);
$this->assertEquals($author, $entryResult);
}
public function testDeletesAuthorUsingEntryId()
{
$this->_adapter->expects($this->once())
->method('quoteInto')
->with($this->equalTo('id = ?'), $this->equalTo(1))
->will($this->returnValue('author_id = 1'));
$this->_tableGateway->expects($this->once())
->method('delete')
->with($this->equalTo('id = 1'));
$this->_mapper->delete(1);
}
public function testDeletesAuthorUsingEntryObject()
{
$author = new ZFExt_Model_Author(array(
'id' => 1,
'username' => 'joe_bloggs',
'fullname' => 'Joe Bloggs',
'email' => '[email protected]',
'url' => 'http://www.example.com'
));
$this->_adapter->expects($this->once())
->method('quoteInto')
->with($this->equalTo('id = ?'), $this->equalTo(1))
->will($this->returnValue('author_id = 1'));
$this->_tableGateway->expects($this->once())
->method('delete')
->with($this->equalTo('id = 1'));
$this->_mapper->delete($author);
}
protected function _getCleanMock($className) {
$class = new ReflectionClass($className);
$methods = $class->getMethods();
$stubMethods = array();
foreach ($methods as $method) {
if ($method->isPublic() || ($method->isProtected()
&& $method->isAbstract())) {
$stubMethods[] = $method->getName();
}
}
$mocked = $this->getMock(
$className,
$stubMethods,
array(),
$className . '_AuthorMapperTestMock_' . uniqid(),
false
);
return $mocked;
}
}
Die Implementierung
dieser Klasse entspricht jener für den Entry-Mapper und fügt die
Methoden find()
und
delete()
hinzu.
<?php
class ZFExt_Model_AuthorMapper
{
protected $_tableGateway = null;
protected $_tableName = 'authors';
protected $_entityClass = 'ZFExt_Model_Author';
public function __construct(Zend_Db_Table_Abstract $tableGateway)
{
if (is_null($tableGateway)) {
$this->_tableGateway = new Zend_Db_Table($this->_tableName);
} else {
$this->_tableGateway = $tableGateway;
}
}
protected function _getGateway()
{
return $this->_tableGateway;
}
public function save(ZFExt_Model_Author $author)
{
if (!$author->id) {
$data = array(
'fullname' => $author->fullname,
'username' => $author->username,
'email' => $author->email,
'url' => $author->url
);
$author->id = $this->_getGateway()->insert($data);
} else {
$data = array(
'id' => $author->id,
'fullname' => $author->fullname,
'username' => $author->username,
'email' => $author->email,
'url' => $author->url
);
$where = $this->_getGateway()->getAdapter()
->quoteInto('id = ?', $author->id);
$this->_getGateway()->update($data, $where);
}
}
public function find($id)
{
$result = $this->_getGateway()->find($id)->current();
$author = new $this->_entityClass(array(
'id' => $result->id,
'fullname' => $result->fullname,
'username' => $result->username,
'email' => $result->email,
'url' => $result->url
));
return $author;
}
public function delete($author)
{
if ($author instanceof ZFExt_Model_Author) {
$where = $this->_getGateway()->getAdapter()
->quoteInto('id = ?', $author->id);
} else {
$where = $this->_getGateway()->getAdapter()
->quoteInto('id = ?', $author);
}
$this->_getGateway()->delete($where);
}
}
Diesen neuen
Autoren-Data-Mapper können wir in unserem Entry-Data-Mapper verwenden,
um ein Autoren-Objekt zu holen und in das Entry-Objekt einzufügen, das
von der neuen Methode find()
zurückgegeben
wird. Wir werden auch eine ähnliche Methode
delete()
einfügen.
Hier sind die Tests für das Finden eines Eintrags über die Eigenschaft id und für das Löschen eines Eintrags über die Id.
<?php
class ZFExt_Model_EntryMapperTest extends PHPUnit_Framework_TestCase
{
// ...
public function testFindsRecordByIdAndReturnsDomainObject()
{
$author = new ZFExt_Model_Author(array(
'id' => 1,
'username' => 'joe_bloggs',
'fullname' => 'Joe Bloggs',
'email' => '[email protected]',
'url' => 'http://www.example.com'
));
$entry = new ZFExt_Model_Entry(array(
'id' => 1,
'title' => 'My Title',
'content' => 'My Content',
'published_date' => '2009-08-17T17:30:00Z',
'author' => $author
));
// Erwartetes Rowset-Ergebnis für den gefundenen Eintrag
$dbData = new stdClass;
$dbData->id = 1;
$dbData->title = 'My Title';
$dbData->content = 'My Content';
$dbData->published_date = '2009-08-17T17:30:00Z';
$dbData->author_id = 1;
// Setze Erwartungen für das Mock-Objekt beim Aufruf von Zend_Db_Table::find()
$this->_rowset->expects($this->once())
->method('current')
->will($this->returnValue($dbData));
$this->_tableGateway->expects($this->once())
->method('find')
->with($this->equalTo(1))
->will($this->returnValue($this->_rowset));
// Erstelle ein Mock-Objekt von AuthorMapper - es hat eigene Tests
$authorMapper = $this->_getCleanMock('ZFExt_Model_AuthorMapper');
$authorMapper->expects($this->once())
->method('find')->with($this->equalTo(1))
->will($this->returnValue($author));
$this->_mapper->setAuthorMapper($authorMapper);
$entryResult = $this->_mapper->find(1);
$this->assertEquals($entry, $entryResult);
}
public function testDeletesEntryUsingEntryId()
{
$this->_adapter->expects($this->once())
->method('quoteInto')
->with($this->equalTo('id = ?'), $this->equalTo(1))
->will($this->returnValue('entry_id = 1'));
$this->_tableGateway->expects($this->once())
->method('delete')
->with($this->equalTo('id = 1'));
$this->_mapper->delete(1);
}
public function testDeletesEntryUsingEntryObject()
{
$author = new ZFExt_Model_Author(array(
'id' => 2,
'username' => 'joe_bloggs',
'fullname' => 'Joe Bloggs',
'email' => '[email protected]',
'url' => 'http://www.example.com'
));
$entry = new ZFExt_Model_Entry(array(
'id' => 1,
'title' => 'My Title',
'content' => 'My Content',
'published_date' => '2009-08-17T17:30:00Z',
'author' => $author
));
$this->_adapter->expects($this->once())
->method('quoteInto')
->with($this->equalTo('id = ?'), $this->equalTo(1))
->will($this->returnValue('entry_id = 1'));
$this->_tableGateway->expects($this->once())
->method('delete')
->with($this->equalTo('id = 1'));
$this->_mapper->delete($entry);
}
// ...
}
Hier ist unsere Implementierung für diese zwei neuen Methoden. Wie die Tests es vorschreiben, können wir Einträge löschen, indem wir die Id als Integer-Wert oder das Domain-Objekt selbst übergeben.
<?php
class ZFExt_Model_EntryMapper
{
protected $_tableGateway = null;
protected $_tableName = 'entries';
protected $_entityClass = 'ZFExt_Model_Entry';
protected $_authorMapperClass = 'ZFExt_Model_AuthorMapper';
protected $_authorMapper = null;
public function __construct(Zend_Db_Table_Abstract $tableGateway)
{
if (is_null($tableGateway)) {
$this->_tableGateway = new Zend_Db_Table($this->_tableName);
} else {
$this->_tableGateway = $tableGateway;
}
}
protected function _getGateway()
{
return $this->_tableGateway;
}
public function save(ZFExt_Model_Entry $entry)
{
if (!$entry->id) {
$data = array(
'title' => $entry->title,
'content' => $entry->content,
'published_date' => $entry->published_date,
'author_id' => $entry->author->id
);
$entry->id = $this->_getGateway()->insert($data);
} else {
$data = array(
'id' => $entry->id,
'title' => $entry->title,
'content' => $entry->content,
'published_date' => $entry->published_date,
'author_id' => $entry->author->id
);
$where = $this->_getGateway()->getAdapter()
->quoteInto('id = ?', $entry->id);
$this->_getGateway()->update($data, $where);
}
}
public function find($id)
{
$result = $this->_getGateway()->find($id)->current();
if (!$this->_authorMapper) {
$this->_authorMapper = new $this->_authorMapperClass;
}
$author = $this->_authorMapper->find($result->author_id);
$entry = new $this->_entityClass(array(
'id' => $result->id,
'title' => $result->title,
'content' => $result->content,
'published_date' => $result->published_date,
'author' => $author
));
return $entry;
}
public function delete($entry)
{
if ($entry instanceof ZFExt_Model_Entry) {
$where = $this->_getGateway()->getAdapter()
->quoteInto('id = ?', $entry->id);
} else {
$where = $this->_getGateway()->getAdapter()
->quoteInto('id = ?', $entry);
}
$this->_getGateway()->delete($where);
}
public function setAuthorMapper(ZFExt_Model_AuthorMapper $mapper)
{
$this->_authorMapper = $mapper;
}
}
Und endlich...wir haben eine funktionierende Implementierung des Data-Mappers! So sollte die finale Ausgabe von PHPUnit zur Testreihe aussehen.
PHPUnit 3.3.17 by Sebastian Bergmann. ....................... Time: 0 seconds OK (23 tests, 51 assertions)
Unsere Implementierung
des Data-Mappers in ZFExt_Model_EntryMapper
benötigt zwei SQL-Abfragen, um ein vollständiges Domain-Objekt zu
erzeugen: eine Abfrage für den Eintrag selbst und eine weitere für den
referenzierten Autor. Manchmal benötigen wir die Details zum Autor gar
nicht; in diesem Fall ist die zusätzliche Abfrage überflüssig. Es wäre
besser, den Data-Mapper so anzupassen, dass er die Autoren-Daten nur bei
Bedarf lädt und uns so ab und zu den Weg zur Datenbank erspart (auch
bekannt als "lazy loading").
Wir haben bereits
gesehen, wie wir die Methode __set()
überschreiben können, um einen Wert vor dem Setzen der Eigenschaft zu
validieren. Wir können die Methode __get()
verwenden, um eine ähnliche Funktionalität zu erreichen. Wir fangen den
Versuch ab, auf das Autoren-Objekt in unserem Entry-Domain-Objekt
zuzugreifen und starten eine Abfrage über
ZFExt_Model_AuthorMapper
, um das Objekt zu
erhalten.
Da dadurch offensichtlich bereits getestetes Verhalten geändert wird, müssen wir zumindest einen Test für den Entry-Data-Mapper abändern. Wir müssen irgendwie die Id des Autors im Entry-Domain-Objekt speichern, damit wir etwas haben, was wir "lazy-loaden" können, und wir müssen sicher gehen, dass das nachträgliche Laden tatsächlich funktioniert. Hier sind die neuen, überarbeiteten Tests für den Entry-Mapper und das Entry-Domain-Objekt:
<?php
class ZFExt_Model_EntryMapperTest extends PHPUnit_Framework_TestCase
{
// ...
public function testFindsRecordByIdAndReturnsDomainObject()
{
$entry = new ZFExt_Model_Entry(array(
'id' => 1,
'title' => 'My Title',
'content' => 'My Content',
'published_date' => '2009-08-17T17:30:00Z'
));
// Erwartetes Rowset-Ergebnis für den gefundenen Eintrag
$dbData = new stdClass;
$dbData->id = 1;
$dbData->title = 'My Title';
$dbData->content = 'My Content';
$dbData->published_date = '2009-08-17T17:30:00Z';
$dbData->author_id = 1;
// Setze Erwartungen für das Mock-Objekt beim Aufruf von Zend_Db_Table::find()
$this->_rowset->expects($this->once())
->method('current')
->will($this->returnValue($dbData));
$this->_tableGateway->expects($this->once())
->method('find')
->with($this->equalTo(1))
->will($this->returnValue($this->_rowset));
$entryResult = $this->_mapper->find(1);
$this->assertEquals('My Title', $entryResult->title);
}
public function testFoundRecordCausesAuthorReferenceIdToBeSetOnEntryObject()
{
$entry = new ZFExt_Model_Entry(array(
'id' => 1,
'title' => 'My Title',
'content' => 'My Content',
'published_date' => '2009-08-17T17:30:00Z'
));
// Erwartetes Rowset-Ergebnis für den gefundenen Eintrag
$dbData = new stdClass;
$dbData->id = 1;
$dbData->title = 'My Title';
$dbData->content = 'My Content';
$dbData->published_date = '2009-08-17T17:30:00Z';
$dbData->author_id = 5;
// Setze Erwartungen für das Mock-Objekt beim Aufruf von Zend_Db_Table::find()
$this->_rowset->expects($this->once())
->method('current')
->will($this->returnValue($dbData));
$this->_tableGateway->expects($this->once())
->method('find')
->with($this->equalTo(1))
->will($this->returnValue($this->_rowset));
$entryResult = $this->_mapper->find(1);
$this->assertEquals(5, $entryResult->getReferenceId('author'));
}
// ...
}
<?php
class ZFExt_Model_EntryTest extends PHPUnit_Framework_TestCase
{
// ...
public function testAllowsAuthorIdToBeStoredAsAReference()
{
$entry = new ZFExt_Model_Entry;
$entry->setReferenceId('author', 5);
$this->assertEquals(5, $entry->getReferenceId('author'));
}
public function testLazyLoadingAuthorsRetrievesAuthorDomainObject()
{
$author = new ZFExt_Model_Author(array(
'id' => 5,
'username' => 'joe_bloggs',
'fullname' => 'Joe Bloggs',
'email' => '[email protected]',
'url' => 'http://www.example.com'
));
$entry = new ZFExt_Model_Entry;
$entry->setReferenceId('author', 5);
$authorMapper = $this->_getCleanMock('ZFExt_Model_AuthorMapper');
$authorMapper->expects($this->once())
->method('find')
->with($this->equalTo(5))
->will($this->returnValue($author));
$entry->setAuthorMapper($authorMapper);
$this->assertEquals('Joe Bloggs', $entry->author->fullname);
}
protected function _getCleanMock($className) {
$class = new ReflectionClass($className);
$methods = $class->getMethods();
$stubMethods = array();
foreach ($methods as $method) {
if ($method->isPublic() || ($method->isProtected()
&& $method->isAbstract())) {
$stubMethods[] = $method->getName();
}
}
$mocked = $this->getMock(
$className,
$stubMethods,
array(),
$className . '_EntryTestMock_' . uniqid(),
false
);
return $mocked;
}
// ...
}
Als Ausgangslage für
die Implementierung ändern wir die Klasse
ZFExt_Model_Entry
so ab, dass sie die Referenz-Id
des Autors zur späteren Verwendung annimmt. Da das Lazy-Loading
innerhalb dieses Objekts geschieht, müssen wir die Kenntnis von
ZFExt_Model_AuthorMapper
, die ursprünglich
ZFExt_Model_EntryMapper
besitzt, in das
Domain-Objekt selbst transferieren. Technisch gesehen können Referenzen
in jedem Domain-Objekt auftreten, das sie benötigt, weswegen wir dieses
Feature der Elternklasse ZFExt_Model_Entity
hinzufügen können. ZFExt_Model_Entry
kann diese
Methoden der Elternklasse verwenden, um Informationen zu den Referenzen
zu setzen oder auszulesen.
<?php
class ZFExt_Model_Entity
{
protected $_references = array();
// ...
public function setReferenceId($name, $id)
{
$this->_references[$name] = $id;
}
public function getReferenceId($name)
{
if (isset($this->_references[$name])) {
return $this->_references[$name];
}
}
}
<?php
class ZFExt_Model_Entry extends ZFExt_Model_Entity
{
protected $_data = array(
'id' => null,
'title' => '',
'content' => '',
'published_date' => '',
'author' => null
);
protected $_authorMapperClass = 'ZFExt_Model_AuthorMapper';
protected $_authorMapper = null;
public function __set($name, $value)
{
if ($name == 'author' && !$value instanceof ZFExt_Model_Author ) {
throw new ZFExt_Model_Exception('Author can only be set using'
. ' an instance of ZFExt_Model_Author');
}
parent::__set($name, $value);
}
public function __get($name)
{
if ($name == 'author' && $this->getReferenceId('author')
&& !$this->_data['author'] instanceof ZFExt_Model_Author) {
if (!$this->_authorMapper) {
$this->_authorMapper = new $this->_authorMapperClass;
}
$this->_data['author'] = $this->_authorMapper
->find($this->getReferenceId('author'));
}
return parent::__get($name);
}
public function setAuthorMapper(ZFExt_Model_AuthorMapper $mapper)
{
$this->_authorMapper = $mapper;
}
}
Achten Sie auf die
neue Methode __get()
. Sie fängt alle Versuche
ab, auf die Eigenschaft author des Domain-Objekts zuzugreifen. Falls das
Objekt nicht bereits ein Autoren-Objekt enthält, versucht es eines aus
der Datenbank zu laden, aber auch nur dann, wenn eine Referenz-Id (das
heißt: eine Autoren-Id) gesetzt wurde, zum Beispiel als der Eintrag
selbst geladen wurde. Anderenfalls wird der Wert null retourniert, was
auch passieren sollte, wenn es sich um ein neues Objekt ohne einen Autor
handelt.
Hier sehen Sie die
überarbeitete Klasse ZFExt_Model_EntryMapper
. Als
einzige Änderung wurde das automatische Laden des Autoren-Objekts
entfernt und durch das Setzen des Werts von author_id
als
Referenz im resultierenden Eintrag-Objekt ersetzt.
<?php
class ZFExt_Model_EntryMapper
{
protected $_tableGateway = null;
protected $_tableName = 'entries';
protected $_entityClass = 'ZFExt_Model_Entry';
public function __construct(Zend_Db_Table_Abstract $tableGateway)
{
if (is_null($tableGateway)) {
$this->_tableGateway = new Zend_Db_Table($this->_tableName);
} else {
$this->_tableGateway = $tableGateway;
}
}
protected function _getGateway()
{
return $this->_tableGateway;
}
public function save(ZFExt_Model_Entry $entry)
{
if (!$entry->id) {
$data = array(
'title' => $entry->title,
'content' => $entry->content,
'published_date' => $entry->published_date,
'author_id' => $entry->author->id
);
$entry->id = $this->_getGateway()->insert($data);
} else {
$data = array(
'id' => $entry->id,
'title' => $entry->title,
'content' => $entry->content,
'published_date' => $entry->published_date,
'author_id' => $entry->author->id
);
$where = $this->_getGateway()->getAdapter()
->quoteInto('id = ?', $entry->id);
$this->_getGateway()->update($data, $where);
}
}
public function find($id)
{
$result = $this->_getGateway()->find($id)->current();
$entry = new $this->_entityClass(array(
'id' => $result->id,
'title' => $result->title,
'content' => $result->content,
'published_date' => $result->published_date
));
$entry->setReferenceId('author', $result->author_id);
return $entry;
}
public function delete($entry)
{
if ($entry instanceof ZFExt_Model_Entry) {
$where = $this->_getGateway()->getAdapter()
->quoteInto('id = ?', $entry->id);
} else {
$where = $this->_getGateway()->getAdapter()
->quoteInto('id = ?', $entry);
}
$this->_getGateway()->delete($where);
}
}
Et voilá! Wir haben unseren Data-Mapper so modifiziert, dass er an geeigneten Stellen das Lazy-Loading von Objekten unterstützt. Ich gebe dazu, dass es sich hierbei eigentlich um einen Fall von frühzeitiger Optimierung handelt - wir haben keine Ahnung, ob diese Maßnahme die Leistung unserer Anwendung in irgendeiner Hinsicht steigern wird, da wir bisher keine Verbesserung messen können. Da ich das aber bereits bei früheren Gelegenheiten getan habe, kann ich davon ausgehen, dass es der Leistung unserer Anwendung zuträglich sein wird. Datenbankoperationen sind teuer, oft sogar die teuersten Operationen.
Eine weitere Optimierungsmöglichkeit, wenn auch nicht vollständig auf die Leistung bezogen, ist die Verwendung einer Identity-Map. Damit ich erklären kann, was ich damit meine, stellen Sie sich bitte ein Szenario vor, in dem Sie 20 Einträge ausgelesen haben. Jeder Eintrag wurde über unseren Entry-Data-Mapper eingelesen, wobei der Autor bisher nicht gesetzt wurde, damit er - wie wir es gerade implementiert haben - über Lazy-Loading nachgeladen werden kann. Wie werden die Autoren geladen? Indem der Author-Data-Mapper verwendet wird, um sie aus der Datenbank zu beziehen. Für unsere Implementierung bedeutet das, dass mit jedem Eintrag, den wir laden, auch ein Autor geladen werden kann. Das klingt vernünftig, bis Sie sich die Beziehung zwischen Eintrag und Autor ansehen. Ein Autor kann viele Einträge schreiben, weswegen viele Einträge genau denselben Autor gemeinsam haben bzw. teilen werden. Das bedeutet, dass wir denselben Autor viele viele Male aus der Datenbank auslesen werden. Und das ist ganz offensichtlich ein Problem - unsere Domain-Objekte sollten so einzigartig wie möglich sein.
Auf den ersten Blick ergeben sich daraus keine problematischen Nebeneffekte außer dass wir eine Menge unnötiger Datenbankaufrufe absetzen. Doch was passiert, wenn wir die Entität eines Autors verändern? Wir haben ja eine ganze Menge davon! Wenn wir eine Entität ändern, ändern wir dabei nicht die anderen, weswegen Einträge innerhalb desselben Prozesses (= Seitenaufrufes) veraltete Autoreninformationen verwenden werden. Diese Asynchronität müssen wir eliminieren.
Eine einleuchtende
Lösung dafür ist, jede einzigartige Entität für alle gemeinsam benutzbar
zu machen. Wenn wir einen Autor in einem Eintrag laden und ein anderer
Eintrag denselben Autor braucht, kann er irgendwie die
ZFExt_Model_Author
-Instanz des ersten Eintrags
lokalisieren und verwenden. Die gebräuchlichste Lösung in diesem Bereich
ist als Identity-Map-Pattern bekannt. Richtig, es handelt sich um ein
weiteres von Martin Fowler definiertes Entwurfsmuster...das hat Fowler
dazu zu sagen:
Ensures that each object gets loaded only once by keeping every loaded object in a map. Looks up objects using the map when referring to them.
Du bist unser Held, Martin! Selbst wenn es in dieser Definition vielleicht nicht sofort ersichtlich ist, handelt es sich bei der Identity-Map auch um eine Art von Cache. Sobald ein Domain-Objekt mit einer einzigartigen Id zum ersten Mal erzeugt oder geladen wird, wird sie in der Identity-Map registriert, damit andere Domain-Objekte diese Instanz verwenden können, falls sie ein Domain-Objekt mit derselben Id laden wollen. Der Data-Mapper muss dabei nun keine zusätzlichen Datenbankaufrufe mehr absetzen.
Da unsere Data-Mapper bereits das Abrufen und die Erstellung von Domain-Objekten bewerkstelligen, erscheinen sie als der logischste Ort für die Implementierung dieser Funktion. Da es sich dabei um eine allgemeine Map handelt (es gibt keine Implementierung speziell für einen Mapper), fügt man sie optimalerweise natürlich einer gemeinsamen Elternklasse hinzu, um die Duplizierung von Code zu vermeiden. Das macht mich aus noch einem anderen Grund glücklich - es ist die perfekte Ausrede, um alle Data-Mapper von einer gemeinsamen Klasse abzuleiten und jeglichen doppelten Code aus den beiden Data-Mappern in ihre gemeinsame Elternklasse zu verlegen.
Wenn wir schon dabei sind, können wir doppelten Code aus unseren beiden Data-Mappern in diese Elternklasse verschieben. Doch zuerst kommen die neuen Tests dran!
<?php
class ZFExt_Model_EntryMapperTest extends PHPUnit_Framework_TestCase
{
// ...
public function testFindsRecordByIdAndReturnsMappedObjectIfExists()
{
$entry = new ZFExt_Model_Entry(array(
'id' => 1,
'title' => 'My Title',
'content' => 'My Content',
'published_date' => '2009-08-17T17:30:00Z'
));
// Erwartetes Rowset-Ergebnis für den gefundenen Eintrag
$dbData = new stdClass;
$dbData->id = 1;
$dbData->title = 'My Title';
$dbData->content = 'My Content';
$dbData->published_date = '2009-08-17T17:30:00Z';
$dbData->author_id = 1;
// Setze Erwartungen für das Mock-Objekt beim Aufruf von Zend_Db_Table::find()
$this->_rowset->expects($this->once())
->method('current')
->will($this->returnValue($dbData));
$this->_tableGateway->expects($this->once())
->method('find')
->with($this->equalTo(1))
->will($this->returnValue($this->_rowset));
$mapper = new ZFExt_Model_EntryMapper($this->_tableGateway);
$result = $mapper->find(1);
$result2 = $mapper->find(1);
$this->assertSame($result, $result2);
}
public function testSavingNewEntryAddsItToIdentityMap() {
$author = new ZFExt_Model_Author(array(
'id' => 2,
'username' => 'joe_bloggs',
'fullname' => 'Joe Bloggs',
'email' => '[email protected]',
'url' => 'http://www.example.com'
));
$entry = new ZFExt_Model_Entry(array(
'title' => 'My Title',
'content' => 'My Content',
'published_date' => '2009-08-17T17:30:00Z',
'author' => $author
));
// Setze Erwartungen für das Mock-Objekt beim Aufruf von Zend_Db_Table::insert()
$insertionData = array(
'title' => 'My Title',
'content' => 'My Content',
'published_date' => '2009-08-17T17:30:00Z',
'author_id' => 2
);
$this->_tableGateway->expects($this->once())
->method('insert')
->with($this->equalTo($insertionData))
->will($this->returnValue(123));
$mapper = new ZFExt_Model_EntryMapper($this->_tableGateway);
$mapper->save($entry);
$result = $mapper->find(123);
$this->assertSame($result, $entry);
}
// ...
}
Der neue Test ähnelt
dem, mit dem wir die Funktion der Data-Mapper-Methode
find()
getestet haben. Im Unterschied dazu
starten wir diesmal einen zweiten Aufruf (ohne die Erwartung des
Mock-Objekts zu ändern, dass
Zend_Db_Table_Abstract
nur einmal verwendet wird)
und kontrollieren, ob es sich bei den daraus resultierenden Objekten um
dasselbe handelt. PHPUnit geht so weit, dass es die Objekt-Ids
kontrolliert, um sicherzustellen, dass beide Ergebnisse exakt dasselbe
Objekt referenzieren. Wir erstellen zudem für jeden Test ein neues
Mapper-Objekt, anstatt jenes aus der Eigenschaft $_mapper der Testklasse
zu verwenden. Dadurch vermeiden wir, dass Aufrufe in anderen Tests
Objekte in der Identity-Map erzeugen, was zu einem falsch-positiven
Ergebnis führen könnte. Hier der zusätzliche Test, diesmal für
ZFExt_Model_AuthorMapper
.
<?php
class ZFExt_Model_AuthorMapperTest extends PHPUnit_Framework_TestCase
{
// ...
public function testFindsRecordByIdAndReturnsMappedObjectIfExists()
{
$author = new ZFExt_Model_Author(array(
'id' => 1,
'username' => 'joe_bloggs',
'fullname' => 'Joe Bloggs',
'email' => '[email protected]',
'url' => 'http://www.example.com'
));
// Erwartetes Rowset-Ergebnis für den gefundenen Eintrag
$dbData = new stdClass;
$dbData->id = 1;
$dbData->fullname = 'Joe Bloggs';
$dbData->username = 'joe_bloggs';
$dbData->email = '[email protected]';
$dbData->url = 'http://www.example.com';;
// Setze Erwartungen für das Mock-Objekt beim Aufruf von Zend_Db_Table::find()
$this->_rowset->expects($this->once())
->method('current')
->will($this->returnValue($dbData));
$this->_tableGateway->expects($this->once())
->method('find')
->with($this->equalTo(1))
->will($this->returnValue($this->_rowset));
$mapper = new ZFExt_Model_AuthorMapper($this->_tableGateway);
$result = $mapper->find(1);
$result2 = $mapper->find(1);
$this->assertSame($result, $result2);
}
public function testSavingNewAuthorAddsItToIdentityMap() {
$author = new ZFExt_Model_Author(array(
'username' => 'joe_bloggs',
'fullname' => 'Joe Bloggs',
'email' => '[email protected]',
'url' => 'http://www.example.com'
));
// Setze Erwartungen für das Mock-Objekt beim Aufruf von Zend_Db_Table::insert()
$insertionData = array(
'username' => 'joe_bloggs',
'fullname' => 'Joe Bloggs',
'email' => '[email protected]',
'url' => 'http://www.example.com'
);
$this->_tableGateway->expects($this->once())
->method('insert')
->with($this->equalTo($insertionData))
->will($this->returnValue(123));
$mapper = new ZFExt_Model_AuthorMapper($this->_tableGateway);
$mapper->save($author);
$result = $mapper->find(123);
$this->assertSame($result, $author);
}
// ...
}
Wir beginnen unsere
Implementierung mit der Erstellung der gemeinsamen Elternklasse
ZFExt_Model_Mapper
. Sowohl
ZFExt_Model_EntryMapper
als auch
ZFExt_Model_AuthorMapper
werden diese Klasse
erweitern.
<?php
class ZFExt_Model_Mapper
{
protected $_tableGateway = null;
protected $_identityMap = array();
public function __construct(Zend_Db_Table_Abstract $tableGateway)
{
if (is_null($tableGateway)) {
$this->_tableGateway = new Zend_Db_Table($this->_tableName);
} else {
$this->_tableGateway = $tableGateway;
}
}
protected function _getGateway()
{
return $this->_tableGateway;
}
protected function _getIdentity($id)
{
if (array_key_exists($id, $this->_identityMap)) {
return $this->_identityMap[$id];
}
}
protected function _setIdentity($id, $entity)
{
$this->_identityMap[$id] = $entity;
}
}
Nun stehen noch die
Änderungen an, die an den beiden Data-Mappern durchgeführt werden
müssen, damit neu abgefragte Objekte in der Identity-Map abgelegt und
dann auch bevorzugt von dort abgefragt werden, anstatt der Datenbank
einen weiteren Besuch abzustatten. Achten Sie darauf, dass die obigen
Methoden _getGateway
und
__construct()
aus den Data-Mapper-Klassen
entfernt werden sollten, da sie von der neuen Elternklasse übernommen
werden.
<?php
class ZFExt_Model_EntryMapper extends ZFExt_Model_Mapper
{
// ...
public function save(ZFExt_Model_Entry $entry)
{
if (!$entry->id) {
$data = array(
'title' => $entry->title,
'content' => $entry->content,
'published_date' => $entry->published_date,
'author_id' => $entry->author->id
);
$entry->id = $this->_getGateway()->insert($data);
$this->_setIdentity($entry->id, $entry); // add new
} else {
$data = array(
'id' => $entry->id,
'title' => $entry->title,
'content' => $entry->content,
'published_date' => $entry->published_date,
'author_id' => $entry->author->id
);
$where = $this->_getGateway()->getAdapter()
->quoteInto('id = ?', $entry->id);
$this->_getGateway()->update($data, $where);
}
}
public function find($id)
{
if ($this->_getIdentity($id)) {
return $this->_getIdentity($id);
}
$result = $this->_getGateway()->find($id)->current();
$entry = new $this->_entityClass(array(
'id' => $result->id,
'title' => $result->title,
'content' => $result->content,
'published_date' => $result->published_date
));
$entry->setReferenceId('author', $result->author_id);
$this->_setIdentity($id, $entry); // add retrieved
return $entry;
}
// ...
}
<?php
class ZFExt_Model_AuthorMapper extends ZFExt_Model_Mapper
{
// ...
public function save(ZFExt_Model_Author $author)
{
if (!$author->id) {
$data = array(
'fullname' => $author->fullname,
'username' => $author->username,
'email' => $author->email,
'url' => $author->url
);
$author->id = $this->_getGateway()->insert($data);
$this->_setIdentity($author->id, $author);
} else {
$data = array(
'id' => $author->id,
'fullname' => $author->fullname,
'username' => $author->username,
'email' => $author->email,
'url' => $author->url
);
$where = $this->_getGateway()->getAdapter()
->quoteInto('id = ?', $author->id);
$this->_getGateway()->update($data, $where);
}
}
public function find($id)
{
if ($this->_getIdentity($id)) {
return $this->_getIdentity($id);
}
$result = $this->_getGateway()->find($id)->current();
$author = new $this->_entityClass(array(
'id' => $result->id,
'fullname' => $result->fullname,
'username' => $result->username,
'email' => $result->email,
'url' => $result->url
));
$this->_setIdentity($id, $author);
return $author;
}
// ...
}
Damit haben wir die Implementierung unsere Domain-Models für dieses Kapitel weit genug vorangetrieben. Man könnte noch weitere Methoden hinzufügen und andere Probleme lösen. Wenn wir die Anwendung erweitern, werden wir auch das hier erstellte Fundament erweitern.
Das war das erste
Kapitel in diesem Buch, in dem wir uns eingehend mit Code befasst haben.
Wie Sie nun wissen, liegt der Fokus weniger darauf, Wissen über
Zend_Db
und seine Unterklassen zu vermitteln (das
Referenzhandbuch macht das sehr gut) als darüber, wie man diese
Datenbankzugriffsklassen verwendet, wenn man ein Model entwirft. Ich habe
Sie auch in einen weiteren Schwerpunkt dieses Buches eingeführt - die
Verwendung von Tests, um die Entwicklung voranzutreiben. Im Voraus
vorbereitete Code-Beispiele funktionieren im Allgemeinen gut, aber ich
hoffe, dass der etwas längere Weg, den Code in den Kapiteln mittels
Unit-Tests zu entwickeln, Sie dabei unterstützt zu verstehen, warum und
wie wir Designentscheidungen treffen.
Ich hoffe auch, dass Sie ein potentielles Problem erkannt haben. Warum bauen wir uns von Grund auf einen eigenen Data-Mapper?
Ich habe in der
Einleitung des Buches erwähnt, dass ich selten Skrupel habe, Zeter und
Mordio zu schreien, wenn es nötig ist - und hier ist es nötig. PHP bietet
Bibliotheken für diese Art von Problemen an. Es gibt da draußen großartige
Data-Mapper-Bibliotheken, viele ORM-Bibliotheken, und sogar im
Zend-Framework-Inkubator findet sich eine vollständige Data-Mapper-Lösung
in Entwicklung. Wir sollten sie verwenden, solange es keine gewichtigen
Gründe gibt, die dagegen sprechen. Zend_Db
implementiert das Row-Data-Gateway und das Table-Data-Gateway-Pattern,
aber außer bei sehr einfach gehaltenen Anwendungen ist seine
Implementierung sehr zeitaufwändig. Kurz gesagt, wenn Sie Ihren Verstand
behalten, Zeit in der Entwicklung und langfristig Geld bei Projekten
sparen möchten, verwenden Sie bei allem Komplexeren als einem Blog
stattdessen eine externe Bibliothek (oder warten Sie auf die ZF-eigene
Data-Mapper-Lösung). Ich weiß, dass das harsch klingt, und wahrscheinlich
haben Sie nicht erwartet das zu hören, aber es muss gesagt werden, solange
Sie sich noch im seichten Teil des Beckens befinden.
Verliert das Zend Framework dadurch gegenüber seinen Alternativen wie Symfony oder Ruby On Rails an Wert? Nein! Symfony verwendet selbst eine externe ORM-Bibliothek, die einfach direkt mit dem Framework ausgeliefert wird - niemand hält Sie davon ab, eine ähnliche Bibliothek (oder dieselbe - Doctrine ist sehr gut und ich verwende es selbst) in Ihren Anwendungen zu verwenden. Ruby On Rails verwendet eine ActiveRecord-Implementierung, die an die Datenbankschicht gebunden ist, aber das hat die Ruby-Community nicht davon abgehalten, Lösungen wie den Data-Mapper von merb zu entwickeln, damit die Objekte nicht so eng an das Datenbankschema gebunden sind. Es wird interessant sein zu beobachten, wie Rails 3.0 dadurch beeinflusst wird, da merb agnostisch gegenüber allen Lösungen bleibt, die ein System mit Plugin-Fähigkeiten bevorzugen. Wenn Sie sonst nichts hiervon mitnehmen, dann behalten Sie im Gedächtnis, dass ein Framework Ihnen eine Menge an Funktionalität anbietet, aber dass Sie nie dazu verpflichtet sein sollten, alle Funktionen davon zu verwenden, wenn es andere, passendere Bibliothek für diese Funktion existiert. In einem zukünftigen Kapitel werden wir uns noch mehr mit einer dieser Alternativen zu Zend_Db beschäftigen.
Was also ist die Essenz
dieses Kapitels? Nur weil bereits Lösungen existieren, mit denen man
sofort loslegen kann, heißt das nicht, dass wir ihre Funktionsweise nicht
verstehen und sie nicht selbst implementieren können. Ein Projekt kann zu
simpel sein (für ein simples Skript würden Sie keine ORM-Bibliothek
verwenden), zu viele Altlasten mit sich herumschleppen, als dass sich
etwas anderes als eine einfache Abstraktion bezahlt machen würde,
vielleicht schreibt Ihnen jemand von einer oberen Stufe der Nahrungskette
vor, was Sie verwenden müssen, oder Sie wollen aus anderen Gründen eine
eigene Lösung erschaffen. Ein gutes Beispiel dafür sind Systeme, in denen
nicht in eine relationale Datenbank gespeichert wird - etwas, was immer
häufiger vorkommt, seit sich dokumenten- und objektorientierte
Alternativen zu materialisieren begonnen haben. Warum auch immer Sie es
tun wollen, dieses Kapitel sollte Ihnen zeigen, wie Sie eine bessere
Lösung als das einfache Zend_Db
erzeugen
können.