Mehr als Screenshots – Offline-Fähigkeit für existierende Anwendungen

Viele Nutzer ‚alter‘ Anwendungen machen Screenshots oder Ausdrucke, bevor sie das Büro verlassen. Diese Form der Offline-Fähigkeit kann massiv verbessert werden – was wertvoll und notwendig für viele Anwendungsfälle in Vertrieb oder Außendienst ist. Neue Anwendungen können von Beginn an offline-fähig aufgesetzt werden – eine Freiheit, die bestehende Projekte nicht haben. Dennoch stellt sich die Frage, wie und ob es möglich ist, an eine bestehende Anwendung eine neue offline-fähige Oberfläche (im Browser – als progressive Web App) anzubinden, die offline Daten verändern kann. Dieser Artikel zeigt dies am Beispiel einer (simplen) existierenden Anwendung, die einen neuen, offline-fähigen Client bekommt. Dabei soll die bestehende Anwendung nicht verändert werden.

offline_1
Abb. 1: Backend mit Onlineverbindung; die Verbindung zum mobilen Device ist häufig unterbrochen

In einer bestehenden Applikation verwaltet unser Beispielkunde Aufgaben, die durch den Außendienst ausgeführt werden. Bisher erhalten die Mitarbeiter des Außendiensts (erzwungenermaßen) einen Ausdruck oder machen Screenshots. Abends wird der neue Stand zu jeder Aufgabe wieder in die Applikation eingepflegt. Dieses Vorgehen ist unschön, undankbar, aufwendig (zeitlich und finanziell) und außerdem fehleranfällig.

Ziel ist es, hier einen effizienteren und schöneren Weg zu finden, speziell:

  • Schneller Rücklauf der Statusänderung eines Auftrags in das Backend
  • Reduzierung der Aufwände im Backoffice
  • Keine manuelle Überbrückung von Medienbrüchen
  • Reduzierung von Fehlerquellen

Das Standard-Szenario ist dabei:

  • Die Aufträge werden im Backend eingepflegt
  • Der Außendienst soll seine Aufgaben auf dem Smartphone oder Tablet finden
  • Die Aufgabenliste bleibt auch in Funklöchern verfügbar
  • Der Innen- und Außendienst soll jeder Zeit den Status und den Titel einer Aufgabe verändern können (auch in Funklöchern)
  • Es gibt eine Synchronisation in beiden Richtungen zwischen Backend und mobilen Device

Wären wir in der Lage, etwas ganz Neues aufzusetzen, könnten wir auf die Replikation von CouchDB[1] als Datenbank für den Server und PouchDB[2] als Datenbank für den mobilen Client zurückgreifen – einschließlich Konfliktlösung[3].

Nur haben wir hier eine bestehende Anwendung, die es zu integrieren gilt. Daher ist es erfoderlich, Replikation und Konfliktlösung schrittweise zu erarbeiten:

  • Die Replikation soll auf eine Schnittstelle des Servers zugreifen
  • Der Client holt sich Updates vom Server ab
  • Konflikte müssen während der Replikation erkannt werden
  • Konflikte müssen durch den Anwender auf dem Client gelöst werden
  • Änderungen auf dem Client werden an den Server übertragen

Demo-Anwendung

Unsere Demo-Anwendung besteht zunächst aus einer Datenbank und einem reinen Desktop-Online-Client (linker Teil der von Abbildung 1 oben). Die Anwendung ist so konzipiert, dass es eine permanente und relativ stabile Netzwerk-Verbindung zwischen Desktop und Server benötigt. Als Beispiel für die ‚Legacy‘-Datenbank haben wir sqlite verwendet, darauf ein (sehr minimales) nodeJS (mit Express) und (Vanilla-)JavaScript basiertes Frontend. Wir sind uns bewusst, dass der gewählte Technologie-Stack in Realität nicht wirklich als ‚Legacy‘ bezeichnet werden wird – es geht uns in erster Linie um irgendeine bestehende Anwendung, deren Datenbank (insbesondere das Schema) zu 99% oder 100% gesetzt ist. Um das Beispiel einfach zu halten, beschränken wir uns zunächst auf eine Tabelle mit Aufgaben, die eine ID (UUID, möglich wäre genauso eine fortlaufende Sequenz), eine Beschreibung und ein ‚erledigt‘-Ja-Nein-Feld enthält:

offline_2
Abb. 2: Aufgaben-Tabelle in der bestehenden Anwendung

Entsprechend simpel ist auch das zugehörige Frontend:

offline_3
Abb. 3: ‚Bestehendes‘ Frontend der Anwendung

Ziel ist es nun, diese Anwendung mobil und offline zugänglich zu machen (den rechten Teil aus Abbildung 1 umzusetzen). Dazu gilt es, auf die bestehende Anwendung zuzugreifen

  • über eine vorhandene Schnittstelle
  • über eine neue Schnittstelle zur Geschäftslogik der Anwendung
  • (als letzte Möglichkeit) über eine neue Schnittstelle auf die Datenbank der Anwendung

In unserem Fall reicht uns die erste der Möglichkeiten – wir nutzen (zur leichteren Nachvollziehbarkeit) eine vorhandene, REST-basierte Schnittstelle zur Anwendung. Was wir erweitern müssen (und was eine sehr kleine Änderung darstellt) sind CORS-Header in den HTTP-Antworten (alternativ, und völlig ohne Änderung des Bestehenden, hätten wir auch eine Proxy-Funktion in das neue Backend einbauen können).

Die folgende Grafik stellt bestehende und neue Komponenten in der Übersicht dar:

offline_4
Abb. 4: Übersicht über das Beispiel-Szenario

Die neue Anwendung zeigt (analog zur alten) eine Liste von Aufgaben an. Allerdings ruft sie nicht den Server auf, um diese Liste zu erhalten bzw. zu aktualisieren, sondern eine PouchDB[4]-Instanz, die vollständig im Browser läuft. PouchDB ist eine (schemalose) Schicht über der in allen (gängigen) Browsern verfügbaren IndexDB. Damit lassen sich rein Client-seitig (und getrennt je Domäne) Daten dauerhaft speichern – sie bleiben auch bei Neustart des Browsers erhalten.

Folgender Code ruft Aufgaben ab:

var PouchDB=require('pouchdb');
var db=new PouchDB('todos');
db.allDocs({include_docs: true}).then(function(res){
  res.rows.forEach(function(docFromClient){
    // Aktion je Aufgabe

Analog lassen sich neue Aufgaben in die Datenbank einfügen:

db.post({title: title}).then(function(){
  // erfolgreich!

Damit erhält der Client zunächst eine völlige Unabhängigkeit vom Server. Ob ein Server verfügbar ist (und wann) ist unerheblich für das Funktionieren der Anwendung. Es existieren Anwendungen (bspw. minutes.io[5]), die auf Wunsch komplett ohne Backend (und Anmeldung, etc.) funktionieren und Kundennutzen bieten.

Umsetzung im Code

Unser Ziel ist nach wie vor ein Abgleich mit dem Server. Dazu gehen wir im ersten Schritt wie folgt vor:

a.) Wir lesen alle Aufgaben vom Server mit folgendem Code ein:

request("http://localhost:8088/aufgaben")

b.) Pro Aufgabe sehen wir nach, ob diese bereits auf dem Client existiert (anhand gleicher UUID)

db.get(docFromServer.uuid).then(function(docFromClient){ // (...)

c.) Wir vergleichen je Aufgabe je Feld (‚Titel‘ und ‚erledigt‘): den Stand, der zuletzt repliziert wurde mit dem aktuellen Server- und dem aktuellen Client-Stand (in Summe also drei Stände). Damit können wir ermitteln, ob sich das Feld gar nicht (keine Aktion), nur auf dem Server, nur auf dem Client oder auf beiden geändert hat.

Auf der Clientseite haben wir für diesen Vergleich die Datenbank um das Feld titleReplicated erweitert. Darin legen wir bei einer Replikation den aktuell replizierten Titel ab. Bei einer Änderung des Titels einer Aufgabe auf dem Client wird lediglich die Spalte title angepasst. Dadurch können wir erkennen, wo eine Veränderung vorgenommen wurde.

request("http://localhost:8088/aufgaben").then(function(allFromServer){
  var serverPromises=[];
  allFromServer.forEach(function(docFromServer){ // (...)
    var clientTitleChanged=(docFromClient.title!=docFromClient.titleReplicated);
    var serverTitleChanged=(docFromServer.title!=docFromClient.titleReplicated);
    var anyChanged=(clientTitleChanged || serverTitleChanged || (docFromClient.done != (!!docFromServer.done)));

Wenn sich das Feld title und titleReplicated unterscheidet, dann wurde eine Änderung auf dem Client vorgenommen. Die Variable clientTitleChanged wird dann mit true belegt.

Analog: wenn sich das Feld title aus dem gerade abgerufenen Datensatz vom davor replizierten Stand unterscheidet (titleReplicated entspricht nicht dem title vom Server), dann wurde eine Änderung auf dem Server vorgenommen. Wir merken uns das in serverTitleChanged.

Aus clientTitleChanged, serverTitleChanged und evtl. einem unterschiedlichen Wert für done können wir sehen, ob es ein Update gab.

d.) Im Fall der Änderung nur auf dem Server aktualisieren wir den Client

if(serverTitleChanged){
  docFromClient.title=docFromServer.title;
}

e.) Im Fall der Änderung nur auf dem Client unternehmen wir zunächst nichts (aktualisieren später den Server)

if(clientTitleChanged){
  /* in Abgleich zurück (nicht hier)  */
}

f.) Im Fall der Änderung auf Client und Server merken wir uns den Konflikt (speichern den neuen Stand vom Server in ‚Titel-Konflikt‘)

if(clientTitleChanged && serverTitleChanged){
  if(docFromClient.title!=docFromServer.title){
    // markiere als Konflikt (lasse Client stehen und biete an, Server zu übernehmen)
    docFromClient.titleConflict=docFromServer.title;
  }
}

g.) Ist done (‚erledigt‘) unterschiedlich wenden wir eine Heuristik an: ist die Aufgabe auf Server oder Client erledigt, so gilt sie insgesamt als erledigt.

docFromClient.done=(docFromClient.done || (!!docFromServer.done)); // einmal done = immer done (Heuristik)

h.) Wir übernehmen den Titel aus dem Datensatz vom Server in das Feld titleReplicated auf dem Client

docFromClient.titleReplicated=docFromServer.title;

Im zweiten Schritt transferieren wir die Änderungen, die auf dem Client vorgenommen wurden in die Datenbank auf dem Server. Dazu gehen wir so vor:

a.) Wir nehmen alle Dokumente, die sich auf dem Client befinden

db.allDocs({include_docs: true}).then(function(res){

b.) Wir suchen zu jedem Client-Dokument das entsprechende Dokument des Servers (hätten wir in der bestehenden Anwendung keine UUIDs, müssten wir mit einem weiteren Feld mappen)

res.rows.forEach(function(docFromClient){
  docFromClient=docFromClient.doc;
  var docFromServer=allFromServer.filter(function(s){return s.uuid==docFromClient._id;})[0];

c.) Wenn sich der Titel im Client-Dokument vom Feld titleReplicated unterscheidet, so wurde der Titel auf dem Client geändert

var clientTitleChanged=(docFromClient.title!=docFromClient.titleReplicated);

d.) Wenn das Feld titleConflict im Client gefüllt ist, so liegt ein Konflikt vor, der manuell behandelt werden muss

if(docFromClient.titleConflict){ // don't push to server, as we have a conflict detected already

e.) Wenn der Titel auf dem Client geändert wurde, so übertragen wir jetzt den Datensatz zum Server

if(clientTitleChanged){
  clientPromises.push(request("http://localhost:8088/aufgaben/"+docFromClient._id, "PUT", docFromClient).then(function(){
    docFromClient.titleReplicated=docFromClient.title;
    return db.put(docFromClient).then(function(){
      // Replikation gespeichert

f.) Die Client-Dokumente, die wir in den Dokumenten des Servers nicht unter b gefunden haben, sind im Client angelegt worden und werden daher jetzt auf den Server übertragen.

clientPromises.push(request("http://localhost:8088/aufgaben/"+docFromClient._id, "PUT", docFromClient).then(function(){
  docFromClient.titleReplicated=docFromClient.title;
  return db.put(docFromClient).then(function(){
    // Replikation gespeichert

Die bisher dargestellten Codeabschnitte sind in der Quelle client.js[6] enthalten.

Auf dem Client bieten wir dem Nutzer die Option, evtl. Konflikte aufzulösen:

offline_5
Abb. 5: Auflösen des Konflikts (Client)

Der Anwender kann jetzt den Titel nach Belieben anpassen und über ‚Resolve‘ das Feld titleConflict entfernen. Damit ist der Konflikt gelöst.

Soweit ein grober Überblick über die wesentlichen Elemente unseres Beispielcodes. Der vollständige Code ist auf gitlab verfügbar unter https://gitlab.com/akq-replication [7]. Das Beispiel besteht aus zwei npm-Projekten (fontend und backend), die jeweils (nach npm install) über npm run start gestartet werden können.

Die ‚alte‘ / ‚Legacy‘-Anwendung ist dann unter http://localhost:8088 verfügbar; die neue Anwendung über http://localhost:8080. In beiden Oberflächen können Aufgaben angelegt werden. Ein Abgleich wird über ‚Replicate‘ auf dem Client angestoßen

Fazit

In diesem Artikel haben wir ein kleines Bespiel für eine offline-fähige, mobile Applikation beschrieben. Dieses Beispiel zeigt die Prinzipien, die eine solche Anwendung erfüllen muss. Unser Artikel beschreibt den Prozess, wie wir die neue mobile Applikation designed und entwickelt haben. Dabei beleuchten wir eine Lösung, wie man mit Konflikten bei gleichzeitiger Änderungen umgehen kann. Der Artikel zeigt auf, dass es für diese Frage nicht immer triviale Antworten gibt und daher im Design und in der Entwicklung einer solche Applikation das Thema Update-Konflikte im Vorfeld betrachtet werden muss.

Wir möchten darauf hinweisen, dass unser Beispiel bewusst einfach ist und nicht produktive Reife hat. So fehlen Logging, Homescreen Login, etc.

Daneben gilt es, weitere Schritte zu gehen: zwar kann die Anwendung (per se unendlich lange) offline laufen, allerdings noch nicht offline geöffnet werden. Diese Instabilität ist in der Praxis untragbar – und sie ist (z.B. via Service-Worker) lösbar. Daneben kann die Replikation optimiert werden: statt immer den kompletten Datenbestand zu vergleichen können Änderungen seit einem gegebenen Datum herangezogen werden (es Bedarf dazu kleiner Änderungen an der bestehenden und neuen Anwendung). Daneben kann Konfliktlösung optimiert werden (bspw. durch Einbeziehung der Zeit als Heurisitik), die Replikation kann sich auf den relevanten Teil aller Aufgaben (z.B. nach Benutzername) beschränken, alte Aufgaben können vom Client gelöscht werden, und vieles andere.

[1] http://couchdb.apache.org/

[2] https://pouchdb.com/

[3] https://pouchdb.com/guides/conflicts.html

[4] https://pouchdb.com/

[5] https://minutes.io/welcome

[6] https://gitlab.com/akq-replication/frontend/blob/master/client.js

[7] https://gitlab.com/akq-replication

Veröffentlicht in Alle

Kommentar verfassen

Trage deine Daten unten ein oder klicke ein Icon um dich einzuloggen:

WordPress.com-Logo

Du kommentierst mit Deinem WordPress.com-Konto. Abmelden / Ändern )

Twitter-Bild

Du kommentierst mit Deinem Twitter-Konto. Abmelden / Ändern )

Facebook-Foto

Du kommentierst mit Deinem Facebook-Konto. Abmelden / Ändern )

Google+ Foto

Du kommentierst mit Deinem Google+-Konto. Abmelden / Ändern )

Verbinde mit %s