Services – JSON, JavaScript, SPAs, Caching

Freitag, 27. Februar 2015 von  
unter Fachartikel Architektur

Die Motivation

Im vierten Teil der Blog-Reihe zum Thema Single Page Web-Applikationen soll es darum gehen, welche modernen Möglichkeiten es gibt, per JavaScript und HTML5 zur Optimierung der Laufzeit-Performance der im vorigen Blog hier genannten Beispiel Web-Applikation (‘Einkaufszettel‘) die bereits erläuterten Caching-Mechanismen (Application Cache, Indexed Database) einzusetzen.

Die Praxis

Die Grundlagen der verschiedenen Speichermöglichkeiten und ihre Einsatzmöglichkeiten wurden bereits in diesem Blog hier erklärt. Anhand der simplen Beispiel-Applikation wird nun auf das Zusammenspiel von HTML5 mit knockout.js zwecks Data Binding des implementierten Models an die HTML-Elemente eingegangen und auf das Caching der in die SinglePageWebApplikation (SPA) über die Benutzerschnittstelle eingegebenen Daten.

Die eigentliche Funktionalität der Beispiel-Applikation ‘Einkaufszettel‘ mit dem Anlegen bzw. Eintragen von Artikeln findet ja im Frontend statt, weshalb der Application Cache, wie beschrieben genutzt werden kann. Nun zur Verwendung von knockout.js, welches neben jquery.js und der appli.js in der index.html am Ende eingebunden wurde.

a) knockout.js

Die Anweisungen für knockout.js beginnen bereits im header Element h1:

<h1>Einkaufszettel (<span data-bind=“text: items().length“></span>Eintr&auml;ge)</h1>

Hier erfolgt das Data Binding der Anzahl von im Einkaufszettel bereits vorhandenen Artikeln an das <span>-Element mittels „data-bind“-Attribut im öffnenden <span>-Tag. Dies ist eine knockout.js-Anweisung, die den Inhalt (Tag-Body) des <span>-Elements an die Anzahl der Elemente des Objekts ‘items‘ im JavaScript-Code bindet (“Data Binding“). Der Text-Inhalt dieses DOM ‘span‘-Knotens wird somit an die Länge des JavaScript ‘items‘-Arrays gebunden. Dadurch ändert sich mit jedem hinzugefügten oder entfernten Artikel des ‘Einkaufszettels‘ auch die im <span>-Element angezeigte Anzahl.

Das JavaScript-Objekt ‘einkaufModel‘ in der appli.js enthält Datenspeicher und Methoden, die vom Frontend verwendet werden. knockput.js hält das HTML synchron mit den Elementen des ‘items‘-Arrays, dessen Element-Attribute im DOM der SPA index.html angezeigt werden. Damit das items-Array beim 1.Aufruf der index.html bereits mit den Daten aus dem Cache der Applikation befüllt werden kann, wird das items-Array über den Rückgabewert einer selbstausführenden Funktion definiert und ist als knockout.js ‘observable‘ Array deklariert:

var items = ko.observableArray([]);

Hier deshalb auch der Hinweis auf das dabei angewandte “Observer“-Design Pattern, was bewirkt dass knockout.js als Observer mitbekommt, wenn sich etwas am Inhalt des Arrays ändert durch Hinzufügen oder Entfernen von Elementen. Dadurch kann knockout.js das Array mit dem DOM synchronisieren, was nicht nur für die Anzahl der Elemente des Arrays funktioniert, sondern auch für die Liste der HTML-Elemente auf dem ‘Einkaufszettel‘. Auch im <ul>-Tag gibt es ein „data-bind“-Attribut, was folgende Funktionalitäten hat:

  • Iterieren über die Elemente des items-Arrays per foreach-Schleife, für die jeweils beim Eintragen ein <li>-Element angelegt wurde
  • Data Binding des Inhalt des HTML-Elements an die item-Attribute eines Elements des items-Arrays
  • Einen Link bereitstellen für jedes Element des items-Arrays und beim Click darauf dieses Element aus dem items-Array entfernen

<ul data-bind=“foreach: items“>
       <li>
                <span data-bind=“text: text“></span>
                (<span data-bind=“text: priority“></span>)
                [ <a data-bind=“click: $parent.removeItem.bind($parent)“>Entfernen</a> ]
       </li>
</ul>

D.h. dass jeder Eintrag im items-Array durch ein <li>-Element im DOM repräsentiert wird, wobei die Inhalte text und priority durch das <span>-Element des jeweiligen <li>-Elements angezeigt werden. Auch die Funktionalität der ‘click‘-Event function wird durch die knockout.js-Library implementiert und über das „data-bind“-Attribut im <a> Link-Element einfach nur aufgerufen, wobei $parent das JavaScript-Objekt ‘einkaufModel‘ referenziert, auf dem die implementierte ‚removeItem‘-function aufgerufen wird:

removeItem: function(item) {
        var model = this;
        var trans = db.transaction([‘items’], ‘readwrite’);
        var store = trans.objectStore(‘items’);  
        var request = store.delete(item.key);
        request.onsuccess = function(e) {
               model.items.remove(item);
        };
        request.onerror = errorHandler;
}

Ähnlich simpel geschieht auch das Anlegen neuer Artikel, wobei die folgende function ‚addItem‘ per „data-bind“-Attribut an den submit-Event der Form zum Anlegen neuer Artikel gebunden wird. Dabei wird aus den Formulardaten (Aufruf der jQuery-function val() auf den Elementen der Ids ‘text1‘ und ‘prio1‘) ein Eintrag item (in ‚addItem‘ definiertes JavaScript-Object) im items-Array generiert und dieser per put-Methode transaktional im ObjectStore gespeichert. Nach erfolgreichem Speichern wird der erzeugte Key des neuen Elements dem neuen item-Element als Attribut hinzugefügt und das item Element mittels Aufruf der push-Methode dem items-Arrays:

// Datensatz speichern
addItem: function() {
        var model = this;        

       var item = {
               text: $(‘#text1’).val(),
               priority: $(‘#prio1’).val()
        };

        var trans = db.transaction([‘items’], ‘readwrite’);
        var store = trans.objectStore(‘items’);
        var request = store.put(item);
        request.onsuccess = function(evt) {

               if (evt.type == ‘success’) {
                        item.key = evt.target.result;
                        model.items.push(item);
                       $(‘#text1’).val(”); // Textfeld leeren
                }
        };
        request.onerror = errorHandler; // Generischer Error-Handler
}

<h2>Neuen Artikel notieren</h2>
<form data-bind=“submit: addItem“>
       <p class=“form-inline“>
                <input id=“text1″ name=“text“ type=“text“ placeholder=“Artikel“ required>
                <select id=“prio1″ name=“priority“ required>
                               <option value=“Normale Priorit&auml;t“>Nice-to-have</option>
                               <option value=“Ben&ouml;tigt“>Ben&ouml;tigt</option>
                               <option value=“Unbedingt erforderlich“>Unbedingt erforderlich</option>
                </select>
                <button type=“submit“ class=“btn btn-primary“>
                               <span class=“icon-plus-sign icon-none“></span> Speichern
                </button>
       </p>
</form>

Um die Funktionalität der knockout.js-Library auf das implementierte einkaufModel anzuwenden, genügt dann abschließend der folgende Aufruf:

ko.applyBindings(einkaufModel);

Auch die Offline-Funktionalität der ‚Einkaufszettel‘ Beispiel-Applikation ist unter Verwendung der Indexed Datenbank auf dem ‘einkaufModel‘ bereits implementiert. Die Aktualisierung und Offline-Speicherung der heruntergeladenen Dateien der Applikation kann ebenfalls mittels Application Cache und Manifest erfolgen.

b) Indexed Datenbank:

Zum Speichern bzw. Entfernen der Artikel wird die Indexed DB verwendet, die transaktionalen Zugriffe wurden über die Funktionen ‚addItem‘, ‚removeItem‘ ja bereits auf dem einkaufModel implementiert. Zusätzlich wird, wie bereits erwähnt, die Indexed DB über eine selbstausführende function bereits beim ersten Aufruf der index.html aufgerufen, um den ObjectStore anzulegen, erforderlichenfalls upzugraden und bereits vorhandene Artikel aus der Indexed DB zu lesen, damit diese im Einkaufszettel gelistet werden können. Hierfür ruft die selbstausführende function die allgemeine Funktion ‚openDb‘ auf. Diese funktioniert asynchron, da die gesamte Indexed Database asynchron ist, weshalb sie wiederum mit einer Callback-Funktion aufgerufen wird, die als Closure implementiert ist.

// Datenbank ‘offlineApp’ öffnen und konfigurieren
var openDb = function(callback) {
       var request = indexedDB.open(‘offlineApp’, 1);
       request.onupgradeneeded = function() {
          var db = this.result;      // ist ‘indexedDB.open(‘offlineApp’, 1).result‘
          if (!db.objectStoreNames.contains(‘items’)) {
              var store = db.createObjectStore(‘items’, {
                      keyPath: ‘key’, // sollte auch ‘null’ sein dürfen, geht nicht im IE10
                      autoIncrement: true
              });
          }
       };
       request.onsuccess = function() {
              callback(this.result); // ‘this.result’ ist die geöffnete Datenbank
       };
       request.onerror = errorHandler; // Error-Handler
};

openDb(function(db) { // Aufruf mit implementierter Closure-function
    var einkaufModel = {
           // […]
   };
   ko.applyBindings(einkaufModel)
});

Für die Funktionsweise der Indexed Database und das Anlegen des ObjectStores, sowie den beschriebenen Callbacks wird auf den bereits vorhandenen Blog-Eintrag hier verwiesen.

Das Speichern eines items geschieht mit der Indexed Database dann folgendermaßen:

  • Transaktion zum Schreiben auf der Datenbank für den benannten ObjectStore holen
  • von der Transaktion den genannten ObjektStore erhalten
  • Schreib-Operation per ‘put-request‘ ausführen
  • erhaltenes Ergebnis (success oder error) asynchron behandeln

Der richtige Zeitpunkt hierfür ist, nachdem das item-Object mit den Daten aus der Form angelegt wurde, bevor es in das von knockout.js verwendete observableArray eingetragen wurde. Dadurch wird der neue Artikel in der Einkaufsliste erst dann gelistet, wenn er in der Indexed Datenbank gespeichert wurde:

var trans = db.transaction([‘items’], ‘readwrite’);
var store = trans.objectStore(‘items’);
var request = store.put(item);
request.onsuccess = function(evt) {

        if (evt.type == ‘success’) {
             item.key = evt.target.result;
             model.items.push(item);
             $(‘#text1’).val(”);
        }
};

Das Entfernen eines items geschieht mit der Indexed Database dann folgendermaßen:

  • Transaktion zum Schreiben auf der Datenbank für den benannten ObjectStore holen
  • von der Transaktion den genannten ObjektStore erhalten
  • Schreib-Operation per ‘delete-request‘ mit dem ‘item.key‘ ausführen
  • erhaltenes Ergebnis (success oder error) asynchron behandeln

Der richtige Zeitpunkt hierfür ist, bevor das item-Object aus dem observableArray entfernt wird:

var trans = db.transaction([‘items’], ‘readwrite’);
var store = trans.objectStore(‘items’);
var request = store.delete(item.key);
request.onsuccess = function(evt) {

        if (evt.type == ‘success’) {
             item.key = evt.target.result;
             model.items.remove(item);
             $(‘#text1’).val(”);
        }
};

Das initiale Befüllen des observableArrays mit bereits in der Datenbank vorhandenen Artikeln für den Einkaufszettel würde normalerweise mittels synchroner Transaktion beim Start der Applikation erfolgen, d.h.

innerhalb des ‘einkaufModel‘ müsste das Attribut

items : ko.observableArray([]);  

synchron befüllt werden.

  • Transaktion zum Lesen auf der Datenbank für den benannten ObjectStore holen
  • von der Transaktion den genannten ObjektStore erhalten
  • mittels Cursor einen Artikel-Datensatz des Auswahl-Bereichs nach dem anderen abfragen (asynchron).

Da der Cursor asynchron ist, die function ko.observableArray([])jedoch synchron ist, bedeutet dies, dass das observableArray bereits synchron leer erzeugt wird und asynchron vom Cursor gelieferte Daten nicht mehr zum initialen Befüllen verwenden kann. Zum korrekten initialen Befüllen des observableArrays müssen die Artikel-Daten also zum Zeitpunkt des Aufrufs von ko.observableArray([]) zur Übergabe bereits vorliegen, was jedoch mittels Cursor nicht geht, da die gesamte Datenbank-Operation asynchron verläuft.

Abhilfe schafft auch hier eine selbstausführende Funktion, innerhalb der asynchrone Operationen möglich sind und an deren Ende die ausgelesenen Artikel-Daten im observableArray namens items zurückgegeben werden:

items: (function() {
        // Das Array ist erst leer und wird wegen der Asychronität der DB-Operationen
        // nach und nach mit den Einträgen aus der Datenbank befüllt:
        var items = ko.observableArray([]);
        // Transaktion mit ObjectStore ‘items‘ und Schreib-/Leseberechtigung öffnen
        var trans = db.transaction([‘items’], ‘readwrite’);
        var store = trans.objectStore(‘items’);
        var range = IDBKeyRange.lowerBound(0);
        var cursorRequest = store.openCursor(range);
        cursorRequest.onsuccess = function(evt) {
               var result = evt.target.result;
               if (result) {
                   // Zusammen mit dem `key` bilden die Infos aus dem Datensatz
                   // einen Eintrag für das Einträge-Array
                   items.push({
                       key: result.key,
                       priority: result.value.priority,
                       text: result.value.text
                   });

                   // Curser zum nächsten Element bewegen
                    result.continue();

               }
        };
        cursorRequest.onerror = errorHandler;
        // Rückgabe des ‘ko.observableArray’, das mit den vorhanden
        // Datensätzen aus der DB befüllt wird.
        return items;
})()

c) Application Cache:

Die ‘Einkaufszettel‘ Beispiel-Applikation kann auch Offline funktionieren, wenn die erforderlichen, aktualisierten Dateien für Styles, JavaScript, HTML, Images vorhanden sind. Dies wird durch Aktivierung des bereits im Blog-Eintrag hier genauer erklärten Application Caches mittels der aktualisierten MANIFEST- Datei zur Verwendung des Application Caches ‘einkauf.appcache‘:

CACHE MANIFEST
# Version 1
about.html
app.css
banana.css
appli.js
jquery.js
knockout.js

# index mit manifest attribut
index.html
# Polyfill für Indexed DB
indexeddbpoly.js

Eingebunden wird das Manifest namens ‘einkauf.appcache‘ über das „manifest“-Attribut in der index.html:

<!doctype html>
<html manifest=“einkauf.appcache“>

Dann funktioniert die Beispiel-Applikation auch ohne Server, d.h. als Offline Applikation unter Verwendung des Application Caches und nicht etwa des Browser Caches. Dabei ist es unerheblich, ob die Offline aufgerufene Datei, z.B. die about.html zuvor Online bereits einmal aufgerufen, also in den Browser Cache geladen wurde oder nicht. Sie muss nur in der Manifest-Datei ‘einkauf.appcache‘ gelistet sein, damit Sie auch Offline verfügbar ist, nachdem die Applikation einmal Online gestartet wurde und die index.html mit dem „manifest“-Attribut einmal Online aufgerufen wurde.

Beim Weiterentwickeln einer solchen Online und Offline funktionierenden HTML5-JavaScript Single Page WebApplikationen (SPA) ist nur immer die Datenbank-Versionierung (Indexed Database) und die Manifest-Version bzw. wann welche der im Manifest gelisteten Dateien woher geladen werden (Online, Browser Cache, Application Cache), wie es in diesem Blog-Eintrag hier bereits erklärt wurde.

Wenn also eine Änderung an der Haupt-Skriptdatei appli.js der Applikation vorgenommen wird, muss darauf geachtet werden, dass, wenn sich die Datenbank ändert, auch ein Migrationspfad angeboten wird, was folgendermaßen geht. Beim Erstellen der Indexed Database gibt es, wie bereits erwähnt, die Möglichkeit, eine Versionsnummer anzugeben. Wenn also an der Datenbank-Struktur etwas geändert werden soll, z.B. ein neuer ObjectStore eingefügt werden soll, muss der geschriebene JavaScript-Code es dem Browser ermöglichen, seine Datenbank-Version der verwendeten Indexed Database auf die neuste Version zu bringen. Wenn also aus der Version ‘1‘ die Version ‘2‘ wird, muss dies im upgradeneeded-Callback dahingehend beachtet werden, dass der Code prüft, welche ObjectStores fehlen und welche eingefügt werden müssen, sodass die Datenbank-Struktur der Indexed Database auch vom Browser her immer auf dem neusten Stand ist.

// Datenbank ‘offlineApp’ öffnen und konfigurieren
var openDb = function(callback) {
       var request = indexedDB.open(‘offlineApp’, 2); // war vorher ‘1‘
       request.onupgradeneeded = function() {
          var db = this.result;

          if (!db.objectStoreNames.contains(‘items’)) {
              var store = db.createObjectStore(‘items’, {
                      keyPath: ‘key’,
                      autoIncrement: true
              });
          }

           // hier weitere Object Stores der Datenbank Version ‘2’ anlegen
       };
       request.onsuccess = function() {
              callback(this.result); // ‘this.result’ ist die geöffnete Datenbank
       };
       request.onerror = errorHandler; // Error-Handler
};

Da die geänderte Haupt-Skriptdatei appli.js in der Manifest-Datei ‘einkauf.appcache‘ gelistet ist, sollte diese Datei bisher nur auf dem Webserver aktualisierte Datei auch im Application Cache aktualisiert werden, was durch eine Änderung der Manifest-Datei geschieht (z.B. durch Änderung des Versionsnummern-Kommentars), denn dann lädt der Browser das Manifest und alle in der Manifest-Datei gelisteten Dateien erneut herunter. Es genügt also einen Kommentar in der Manifest-Datei ‘einkauf.appcache‘ zu ändern, was in der Tat, wenn man es weiß, sehr hilfreich ist.

Web-Applikationen ohne Verwendung des Application Caches aktualisieren sich beim Aufruf vom Webserver ja üblicherweise selbst, spätestens wenn man den Browser Cache einmal geleert hat. HTML5-JavaScript Single Page WebApplikationen (SPAs), die den Application Cache verwenden, verhalten sich dabei aber eher wie native Applikationen, die nicht kontinuierlich automatische Updates erhalten, sondern hin und wieder in (Release-)Versionen durch ein großes Update aktualisiert werden. Es handelt sich also eher um herunterladbare, installierbare Browser-Applikationen. Die Browser-Unterstützung der genannten Features (WebStorage, Indexed Database, Application Cache) ist jedoch noch ziemlich unterschiedlich, wie auf www.caniuse.com leicht überprüft werden kann.

WebStorage:

www.caniuse.com/namevalue-storage

Polyfill (Ersatz für Cookies max. 128 kByte Speicherkapazität): www.jstorage.info

Indexed DB:

www.caniuse.com/indexeddb

Ersatz (Web SQL Database – API für SQL-Datenbank im Browser, z.B.sqlite): www.w3.org/TR/webdatabase

www.caniuse.com/sql-storage

Polyfill (für Indexed DB): www.nparashuram.com/IndexedDBShim/

Application Cache:

www.caniuse.com/offline-apps

Allen interessierten Leserinnen und Lesern weiterhin viel Freude mit der Entwicklung von SPAs.

Hier noch zwei Links zu den verwendeten Design Patterns.

Im nächsten Blog-Eintrag geht es dann wieder um agiles TDD mit Java/JEE, WebServices und Application Server-Themen, z. B. mit JBoss Application Servern. Deshalb hier schonmal

der Hinweis auf das sehr empfehlenswerte Seminar „TDD mit Java“ von Binaris

 hier  und  hier.

Kommentare

Ein Kommentar zu “Services – JSON, JavaScript, SPAs, Caching”

  1. Von Services – TDD mit Selenium, QUnit JS, JEE6 : Softwareentwicklung, Projektmanagement & Schulung | binaris informatik GmbH am Donnerstag, 10. Dezember 2015 09:20

    […] von Web- und Mobile Applikationsdaten wurden am Beispiel der Indexed DB in den Blog-Einträgen hier und hier bereits […]

Einen Kommentar hinzufügen...

Sie müssen registriert und angemeldet sein um einen Kommentar zu schreiben.