Dieser Blogartikel soll zeigen, wie schnell und unproblematisch es sein kann, eine einfache Applikation mit BackboneJs und Zend Framework umzusetzen.
Als konkretes Beispiel wird hier eine Applikation zur Verwaltung von Adressen verwendet. Mit dieser Anwendung soll das Zusammenspiel verschiedener Komponenten wie Datenbank, Service Layer mit Zend Framework und Applikations Layer in BackboneJs veranschaulicht werden.
BackboneJs gibt als Kommunikationsinfrastruktur zum Backend REST vor. Aus diesem Grund soll in einem ersten Schritt Zend Framework so konfiguriert werden, dass es RESTful mit dem Frontend kommunizieren kann. Im nächsten Schritt wird die Applikationslogik seitens PHP umgesetzt und somit die konkrete Schnittstelle zum Frontend definiert. Aufbauend auf dieser Schnittstelle wird das Frontend umgesetzt.
Das Backend
Um dem Backend beizubringen per REST zu kommunizieren, muss die Standardroute des Frameworks gegen Zend_Rest_Route ausgetauscht werden.
<code> $controller = Zend_Controller_Front::getInstance(); $route = new Zend_Rest_Route($controller); $controller->getRouter()->addRoute('default', $route); </code>
Durch die Zend_Rest_Route werden Zugriffe auf Ressourcen mit verschiedenen HTTP Methoden auf entsprechende Actions im Controller gemappt. So wird beispielsweise ein Zugriff auf /address/index per HTTP GET zu Address_IndexController::indexAction gemappt, welche dafür zuständig ist, eine Liste aller Adressen zurückzuliefern.
Die Zugriffe auf einzelne Ressourcen erfolgen über /address/index/:id und werden auf Address_IndexController::getAction gemappt. Gleiches gilt für POST, PUT und DELETE.
Im Backend muss man sich auf diese Weise nicht sonderlich umstellen im Vergleich zu einer standardmäßigen Zend Framework Applikation. Lediglich die Einsprungpunkte unterscheiden sich etwas. Der Controller leitet die Anfragen an die Models weiter. Diese lesen die Daten aus der Datenbank aus und geben die Ergebnisse an den Controller zurück. Die View und Layoutkomponenten von Zend Framework werden in diesem Fall nicht benötigt, da es sich hier empfiehlt, die Ausgabe in JSON erfolgen zu lassen. Dies lässt sich entweder über den ContextSwitch Action Helper erreichen, welcher sich um die Abschaltung des Layouts und des View Renderings kümmert, oder man deaktiviert diese Komponenten selbst und benutzt stattdessen den JSON Action Helper.
Ansonsten gibt es für die Zwecke einer einfachen Testapplikation wie der vorliegenden Adressverwaltung im Backend kaum etwas zu beachten. Das ändert sich natürlich mit einer wachsenden Komplexität der Applikation und eventuellen Problemstellungen wie Authentifizierung.
Das Frontend
Wie bereits erwähnt, soll das Frontend mit BackboneJs umgesetzt werden. Backbone ist ein leichtgewichtiges Strukturframework für Javascript. Um es betreiben zu können, müssen einige Voraussetzungen erfüllt sein: Für DOM-Operationen, Ajax und Ähnliches wird Framework wie beispielsweise jQuery benötigt. Backbone ist des Weiteren abhängig von UnderscoreJs, was eine Sammlung nützlicher Werkzeuge darstellt, wie beispielsweise Collection Funktionen, Array Funktionen, Objekt Funktionen und eine große Zahl weiterer Hilfsmittel.
<code> <script src="/scripts/library/jquery.js"></script> <script src="/scripts/library/underscore.js"></script> <script src="/scripts/library/backbone.js"></script> </code>
Als weiteres Hilfsmittel wird require.js eingebunden. Durch dieses Werkzeug erspart man sich lästiges Einbinden von Quellcode-Dateien im Kopf der Seite. RequireJs übernimmt das dynamische Laden und stellt eine Struktur zur Verfügung, mittels derer Klassen definiert werden können und entsprechend eingebunden werden können. Ein typisches Backbone Model weist beispielsweise damit folgenden Aufbau auf:
<code> define(function() { return Backbone.Model.extend({ }); }); </code>
Den Einstiegspunkt in die Applikation bildet, wie man es zum Beispiel von Java kennt, das Main Script. In unserem Beispiel ist diese Datei kein wirkliches Schwergewicht:
<code> require([ "application/routers/address"], function(router) { new router(); Backbone.history.start(); location.hash = '#list'; } ); </code>
In diesem Skript wird der Router instantiiert. Dieser dient als Einstiegspunkt in die Applikation und wird gleich im Anschluss behandelt. Im nächsten Schritt wird das Backbone History Feature gestartet, das sich um die Historienverwaltung kümmert und so die Probleme unterschiedlicher Implementierungen und Verhaltensweisen verschiedener Browser behebt. Dies ist notwendig, um die Hashnavigation, auf die der Backbone Router setzt, auf allen Browsern zur Verfügung zu stellen. Falls ein Browser kein onhashhange Event zur Verfügung stellt, läuft Backbone im Polling Betrieb und prüft standardmäßig 20 Mal pro Sekunde, ob sich der Hash einer Seite geändert hat.
Im letzten Schritt wird die Standardroute gesetzt und so der Einstieg in die Applikation definiert.
Der Router ist der eigentliche Einstieg in die Applikation. Er dient dazu, die eingehenden Anfragen korrekt umzuleiten und übernimmt im zweiten Schritt die Aufgabe eines Controllers und instantiiert als solcher Collections, Models und Views. Zur korrekten Funktionsweise von Backbone ist es notwendig, dass die View ihr zugehöriges Model kennt, damit es Änderungen in den Daten entsprechend darstellen kann. Zu diesem Zweck wird der View im Router bei der Erzeugung eine Referenz auf das Model übergeben.
<code> define(['application/models/address', 'application/views/address', 'application/models/addresses', 'application/views/addresses'], function(models_address, views_address, models_addresses, views_addresses) { return Backbone.Router.extend({ routes: { 'list': 'login', ... }, list: function() { }, ... }); }); </code>
Zuerst werden alle Skripte, die im Controller direkt benötigt werden, geladen. Diese Aufgabe übernimmt RequireJs für uns. Hierzu werden die Namen der zu ladenden Skripte in einem Array definiert. Die Klassen stehen dann über die Parameter der nachfolgenden Funktion im Router zur Verfügung. Wichtig ist außerdem die Eigenschaft ‚routes‘. Sie definiert die verfügbaren Hashes und stellt zugleich ein Mapping zwischen Hash und aufzurufender Funktion dar. Im Router werden dann sämtliche Funktionen definiert, die über die jeweiligen Hashes aufgerufen werden können.
Liste der Adressen
Im ersten Schritt wollen wir uns um die Auflistung der Adressen kümmern. Dazu bedienen wir uns der Backbone Collection. Die Collection ist eine Sammlung von Models und stellt eine Reihe von Hilfsfunktionen auf diesen zur Verfügung. Hierunter fallen unter Anderem die Möglichkeiten, über die Collection zu iterieren, sie zu sortieren oder bestimmte Elemente zu suchen.
Über den Hash list erreichen wir in unserem Router die Methode list.
<code> list: function() { if (this._collection == null) { this._collection = new models_addresses( {model: models_address} ); this._collection.fetch( {success: _.bind(this._renderList, this)} ); } this._collectionView.show(); } </code>
Innerhalb der list Methode wird die Collection zuerst aufgebaut, falls sie nicht schon durch einen früheren Aufruf existiert. Als Parameter erhält der Konstruktor die Klasse der Models der Collection. Im Anschluss werden durch den Aufruf von fetch alle verfügbaren Models für die Collection vom Server geholt. Dies erfolgt per HTTP GET auf die in der url Property der Collection angegebenen Ressource.
Wurden die Daten erfolgreich vom Server geholt, kann nun die Liste angezeigt werden. Dazu implementieren wir einen Callback, den wir über die bind Funktion von Underscore im korrekten Kontext ausführen.
<code> _renderList: function() { if (null == this._collectionView) { this._collectionView = new views_addresses( {model: this._collection} ); } this._collectionView.render(); this._listViews = []; this._collection.each(function(address) { var listView = new views_list({model: address}); listView.render(); model.unbind('change'); model.bind('change', listView.render, listView); this._listViews.push(listView); }); } </code>
Hier wird wieder zuerst eine Instanz der CollectionView erzeugt, falls diese noch nicht existiert. Diese wird benötigt, um einen Container für die Liste zur Verfügung zu stellen, in dem die Überschriften der Tabelle enthalten sind. Dieser Container wird im nächsten Schritt durch die render Methode in den DOM-Baum eingehängt und damit angezeigt. Um die eigentlichen Daten der Liste anzuzeigen, wird mittels der each Funktion, die von Underscore für die Collection zur Verfügung gestellt wird, über alle Models der Collection iteriert und an jedes Model eine View gebunden, die das Model in der Liste darstellt. Die View wird an das ‚change‘ Event des Models gebunden, welches bei jeder Änderung am Model gefeuert wird, was wiederum eine Aktualisierung des jeweiligen Listenpunktes bewirkt. Der Aufruf von unbind bewirkt, dass sämtliche Callbacks, die zuvor auf das ‚change‘ Event des Models gebunden wurden, entfernt werden. Dies ist eine nicht ganz so elegante Art Probleme zu umgehen, die durch Bindung nicht mehr aktueller Callbacks auf ein Event entstehen. Um dieses Problem sauber zu lösen, müsste geprüft werden, ob für ein Model bereits eine List View existiert und falls dies der Fall ist, keine erneute Bindung zu initiieren.
Erstellung neuer Adressen
Um neue Adressen in der Anwendung zu erstellen, bedienen wir uns eines kleinen Tricks. Wir nutzen die gleiche View, die später auch für das Editieren von Adressen verwendet wird, binden aber kein Model an diese View. Stattdessen übergeben wir der View die Collection und lassen diese den Erstellungsprozess der neuen Adresse verwalten.
<code> create: function() { if (null == this._editView) { this._editView = new views_edit( {collection: this._collection} ); } else { this._editView.collection = this._colleciton; this._editView.model = null; } this._editView.render(); } </code>
Falls im Verlauf der Anwendung noch keine Edit View existiert, erstellen wir eine neue Instanz und übergeben im Konstruktor gleich die Collection. Existiert bereits eine Edit View, übergeben wir lediglich die Collection und setzen ein eventuell bestehendes Model auf null. Danach wird die Edit View mittels Aufruf von render angezeigt. Die View muss sich jetzt lediglich darum kümmern, dass keine Werte angezeigt werden und beim Klick auf Speichern ein neues Model über die create Methode der Collection erzeugt wird.
<code> _create: function(values) { this.collection.create(values, {success: function() {location.hash = '#list'} ); } </code>
Der Aufruf von create verursacht einen HTTP POST Request zum Backend mit den übergebenen Formulardaten. Der PHP Code muss sich lediglich um die Persistierung der Daten kümmern. Die Collection sorgt dafür, dass das Model in die Collection eingebunden wird. Nach der Erstellung wird die Liste mit neuem Model aktualisiert.
Bearbeiten vorhandener Adressen
Die Bearbeitung bestehender Adressen funktioniert analog zur Erstellung, außer, dass der Edit View ein einzelnes Model zugewiesen ist, dessen Werte in das Formular eingetragen werden. Diese Aufgabe übernimmt die render Methode der Edit View.
<code> render: function() { if (model != null) { $('#firstname').val(this.model.get('firstname')); $('#surname').val(this.model.get('surname')); ... } } </code>
Durch den Submit des Formulars werden die Werte in das Model gesetzt und das Model auf dem Server persistiert.
<code> _update: function() { this.model.set(values); this.model.save(); } </code>
Durch den Aufruf von set im Model wird das ‚change‘ Event des Models getriggert, auf das sich die Views des Models binden. Das bedeutet, dass sich durch die Änderung des Models sämtliche abhängigen Views aktualisieren. Es werden also keine weiteren Aktionen nötig, um die Liste zu aktualisieren.
Löschen bestehender Adressen
Es ist mittlerweile möglich über die Applikation Adressen anzulegen, eine Liste von Adressen anzuzeigen und einzelne dieser Adressen zu editieren. Zum Schluss benötigen wir jetzt noch die Möglichkeit die Adressen auch wieder zu löschen.
<code> remove: function() { var id = this._fetchIdFromUrl(location.search); this._collection.get(id).destroy( {success: _.bind(this._renderList, this)} ); } </code>
Die remove Methode des Routers übernimmt diese Aufgabe, neben dem Hash erwartet dieser in location.search den Parameter id. Über die id wird das zu löschende Model in der Collection gefunden und die destroy Methode des Models aufgerufen. Diese übernimmt eigentlich die meiste Arbeit: sie sorgt dafür, dass die Adresse über HTTP DELETE vom Server gelöscht wird und gleichzeitig auch aus der Collection entfernt wird. War der Löschvorgang erfolgreich, sorgt ein Aufruf von _renderList dafür, dass die Liste aktualisiert wird.
Zusammenfassend lässt sich feststellen, dass sich eine relativ einfache Applikation, die lediglich CRUD zur Verfügung stellt, in sehr kurzer Zeit umsetzen lässt. Werden die Anforderungen allerdings komplexer, ist es notwendig, sich zu Beginn umfassende Gedanken über die Architektur zu machen.
Ausblick
Wie das Beispiel zeigt, ist es in wenigen Schritten möglich mit der Kombination aus Zend Framework und BackboneJs eine einfache Applikation zu erstellen, die es einem Nutzer erlaubt, Daten auf einem Server zu manipulieren.
Eine Möglichkeit zur Weiterführung des Beispiels wäre die Integration von Sicherheitsmechanismen wie einer Authentifizierung. Dadurch wird man allerdings mit dem Problem konfrontiert, dass durch eine Authentifizierung stets ein Status mitgeführt wird, was dem Prinzip der Statelessness von REST widerspricht. Um dieses Problem zu umgehen, müsste ein Authentifizierungslayer eingeführt werden, hinter dem der Service Layer per REST angesprochen werden kann und der sich darum kümmert, dass nur berechtigte Nutzer auf die ihnen zur Verfügung stehenden Daten zugreifen.
Eine weitere Option wäre die Einführung einer Realtime Komponente, mit deren Hilfe die Anwendung von mehreren Benutzern gleichzeitig verwendet werden könnte und durch die Änderungen an den Daten allen Benutzern in Echtzeit zur Verfügung stehen. Als Alternativen stehen zu diesem Zweck einerseits das leichtgewichtigere Bayeux Protokoll oder das mächtigere XMPP zur Verfügung.