Web-Frameworks: ActiveRecord, Lazy-Loading
Ein ActiveRecord, oder besser gesagt Object Relation Mapper (ORM) bildet das M aus MVC. Also der Punkt bei dem es um die Speicherung der Daten geht.
Das ActiveRecord Entwurfsmuster gibt dabei einige Basisoperationen vor. Meist einen Konstruktor mit dem ein neues Objekt erstellt wird, eine save Methode um Änderungen in die Datenbank zu speichern, bei einem noch nicht in der Datenbank vorhandenem Objekt wird dies hineingeschrieben. Eine remove Methode löscht das Objekt. Statische Methoden dienen dazu Objekte aus der Datenbank zu lesen.
Ein ActiveRecord selbst zu implementieren ist eigentlich einfacher als man denkt. Zumindest ein einfaches ActiveRecord was keine hasOne, hasAndBelongsToMany Beziehungen hat. Mein dWing hat ein ganz einfaches ActiveRecord. Benutzten kann man es wie folgt:
class Comment extends ActiveItem
{
protected $tableName = 'comments';
protected $definition = array('text' => 'html', 'user_id' => 'user',
'time' => 'time', 'content_id' => 'required', 'content_type' => 'required');
}
Mein allgemeiner __get()ter sorgt dabei für dynamische hasOne und hasMany Beziehungen, ganz ohne das man diese deklarieren muss.
Dabei wird alles über Lazy-Loading realisiert. Es werden die Objekte nur dann erzeugt und aus der Datenbank geholt wenn diese benötigt werden.
Greife ich z.b. auf $news->comments zu, wird zuerst ein CommentIterator erzeugt. Dieser implementiert auch das Countable Interface. Das bedeutet, das wenn ich ein count($news->comments) mache wird lediglich ein SQL COUNT() ausgeführt. Erst wenn ich den Iterator anfange zu iterieren wird das vollständige SELECT gemacht. Dabei liebe ich auch PHPs PDO.
$statement->setFetchMode(PDO::FETCH_CLASS, 'Comment'); $this->elements = $statement->fetchAll();
Hier erzeugt mir PDO direkt meine Klassen ohne das ich dies selbst tun müsste.
Nun zu CakePHP. Was soll ich sagen? Obwohl die Modelle eine save() Methode haben und die besagten hasOne usw. Beziehungen würde ich das Cake ORM keinesfalls als ActiveRecord bezeichnen.
Einerseits sind die Models anscheinend nicht als Globale Klasse verfügbar. Im Controller definiert man über eine $uses Variable welche Modelle man benutzen will. Dann sind diese als $this->Model verfügbar. Und wenn man über eine der find() Methoden Daten haben will werden die nicht als Objekt oder Iterator über Objekte zurückgegeben sondern als ein wild gemischtes Array. Die Cake Hilfe gibt ein Beispiel darüber. Ich muss ehrlich sagen, etwas unintuitiveres habe ich noch nicht gesehen.
Auf der Unteren Ebene kriege ich ein Array, das ist auch das einzig Intuitive. Darunter ein Assoziatives Array, auch bekannt als Hashmap, Dictionary oder einfach Objekt. Dieses hat als Indizes die Modellnamen. Will ich eine News haben ist diese über $news['News'] verfügbar. Redundanz ist ja was schönes oder nicht? Und die hasOne, hasMany usw Beziehungen sind ebenfalls in der Ebene.
Die Kommentare sind also in $news['Comment'][0] usw. An sich ist kein großer Unterschied zwischen $news->comments und $news['Comment']. Aber das die News an sich unter $news['News'] zu finden ist verstehe ich nicht. Selbst wenn man diese Dinge als eine Hashmap ausgibt kann man diese besser gestalten.
Und das was die find Methode liefert keine Objekte sind haben diese keine eigenen save() und remove() Methoden. Diese sind über das Modell erreichbar, was diese Operation immer auf die zuletzt zugewiesene Id anwendet. Absolutes Gegenteil von intuitiv.
Ein weiterer Nachteil ist, das es kein Lazy-Loading gibt. Alle dazugehörigen Beziehungen werden aus der Datenbank ausgelesen, auch wenn man diese gar nicht braucht.
Cake stammt aus der PHP4 Zeit, obwohl es ein Jahr nach dem Erscheinen von PHP5 entwickelt wurde. Leider scheint mir dies der Grund zu sein für dieses schlechte ORM. Ich habe zumindest keinerlei Verständnis dafür.
Für node.js benutze ich Mongoose als ActiveRecord. Ein Schema zu definieren ist darin auch sehr einfach:
var Project = new Schema({
title: {type: String, required: true}
, url: {type: String}
, description: {type: String, required: true}
, image: {type: String}
, _user: {type: ObjectId, required: true}
});
Da MongoDB eine JSON basierte Datenbank ist, kann man direkt in den Objekten Unterobjekte speichern oder Arrays von Objekten. Dennoch ist es in einigen Situationen sinnvoll hasOne Beziehungen zu modellieren. Mongoose hat leider keine eingebaute Fähigkeit dazu. Es existieren einige Projekte dafür aber bisher hat mich keines überzeugt. Ich habe lieber selbst eine einfache hasOne Beziehung modelliert:
var users = [];
var anonymous = new User();
Project.virtual("user").get(function () {
return this.__user;
}).set(function (user) {
this.__user = user;
this._user = user;
});
Project.pre("init", function(next, doc) {
if (!doc._user)
return next();
//if (doc._user && users[doc._user])
// return next();
User.findById(doc._user, function(err, user) {
if (err || !user)
return next(err);
users[user.id] = user;
return next();
});
}).post("init", function() {
this.__user = users[this._user] || anonymous;
delete users[this._user];
});
Leider war dies um einiges schwieriger als ich gedacht habe. Ich definiere mir also zwei Funktionen die vor und nach dem erstellen des Objektes ausgeführt werden. Dabei ist die erstere Asynchron, also wird diese durch einen Callback beendet. In dieser hole ich mir mein verwandtes User Objekt aus der Datenbank und speichere dies in einem globalen Objekt zwischen, da diese neu hinzugefügten Objekte leider nicht übernommen werden wenn das tatsächliche Objekt erzeugt wird. Dies füge ich im zweiten Schritt an und lösche die globale Kopie, das diese nicht für die Laufzeit des Programms den Speicher zumüllt. Dann habe ich noch an meinem Modell eine Virtuelle Eigenschaft mit der ich das zuweisen eines neuen Users richtig behandeln kann.
Ich habe zwar lange für diese Lösung gebraucht, elegant ist sie nicht aber ich kann sie einfach verallgemeinern und auslagern. Zu benutzen ist es auch einfach.
Ein Problem gibt es allerdings: Da ich mit Callbacks arbeiten muss fällt Lazy-Loading leider weg. Eine getUser(callback) Methode zu definieren ist dabei keine Lösung. Die Views in node/Express sind synchron, können also keine asynchronen Methoden benutzen die auf Callbacks basieren. Damit muss ich nicht nur auf das bereits erklärte Pull-Modell verzichten bei dem sich die View selbst die zu darstellenden Daten holt, sondern auch auf das dynamische Laden verwandter Daten bei deren Zugriff.
Ich will die Kopplung von Controller und View niedrig halten. Mein Controller soll nicht wissen ob die View nun auf project.user zugreift oder nicht. Also muss ich zwangsläufig project.user vorher laden, denn die View kann dies nicht nachträglich machen.
Insgesamt hat node.js mit Express einige Dinge die mir aus meiner Erfahrung mit dWing fehlen und auch performancekritisch sein können. Pull-Views sind nicht möglich, ebenso wenig das dynamische laden von hasOne, hasMany und ähnlichen Beziehungen. Wobei es dank MongoDB auch viel weniger von diesen gibt. Diese werden nicht wie bei SQL durch die Normalisierung erzwungen sondern nur wenn sie sinnvoll sind. Daher gibt es auch viel weniger.
Die wirklich exzessive Verwendung von Callbacks in node ist sehr gewöhnungsbedürftig, aber ich habe mich schon daran gewöhnt. Mich nervt nur das was ich bereits angesprochen habe: Das ich im Controller wirklich alles laden muss was die View braucht. Und das ich dies tatsächlich im Vorfeld machen muss, auch wenn ich es in der View möglicherweise nicht brauche. Dies kann mir unter Umständen die Performance zusammenhauen.
Dennoch bin ich voll auf den Node Zug aufgesprungen. JavaScript ist einfach eine viel bequemere Sprache. So langsam kotzt mich PHP echt an mit den $dollar Variablen, den $array['Klammern'] anstatt der viel einfacheren Punkt.Notation wie in JS. Auch das komplizierte ausschreiben von array('eigenschaft' => 'wert') ist viel nerviger als einfach {eigenschaft: 'wert'}. Aber jetzt wo ich wieder mein dWing angesehen habe finde ich die sehr ausgereiften Lazy-Loading Methoden von PHP sinnvoll, und das es tatsächlich Klassen mit Interfaces hat.