Samstag, 9. Oktober 2010

Tabellenpflege als Webanwendung

Die Situation ist elementar und kommt häufig vor: Irgendwelche tabellenförmigen Daten – zum Beispiel die Buchungen eines Kontos – sollen mit Hilfe eine Webanwendung gepflegt werden. Trotzdem habe ich nach einer ersten Sichtung keine passende Software für diesen allereinfachsten Anwendungsfall gefunden.[1]

Hier zeige ich eine Implementierung für die Pflegeoberfläche mit JavaScript, die die gesamte Änderungsverwaltung auf dem Client übernimmt. Die Applikation besteht aus einer einzigen Webseite, die weder verlassen noch neu geladen wird. Sie kommuniziert intern über Ajax-Requests mit dem Server, der die Daten anliefert und Änderungen fortschreibt.

Im einzelnen muss eine Anwendung zur Tabellenpflege

  • die Tabellendaten vom Server lesen,
  • dem Benutzer zur Ansicht und Pflege anbieten,
  • der Zellen verändern, ganze Zeilen löschen oder neue Zeilen hinzufügen kann
  • und schliesslich diese Änderungen sichern kann,
  • wodurch sie auf den Server zurückgeschrieben werden.

Wie ist eine solche Anwendung zu entwerfen? Sicher einmal benötigt man ein physisches Bild der Tabelle, das irgendwo im Filesystem hinterlegt sein muss. Auf der anderen Seite bedarf es einer Präsentationsschicht (GUI), um die Tabelle dem Benutzer anzuzeigen und ihm Änderungen anzubieten. Für Webanwendungen besteht die Präsentationsschicht in der Regel aus HTML-Seiten.

Zwischen diesen beiden Endpunkten – GUI und Filesystem – liegen im wesentlichen zwei Softwarekomponenten, die bei einer solchen Anwendung zusammenarbeiten:

  • Eine Persistenzkomponente, die dafür zuständig ist, dass die Daten über die Sitzung des Benutzers hinaus existieren.

  • Und eine Änderungsverwaltung, die die Änderungen des Benutzers in seiner aktuellen Sitzung protokolliert und zu einem vom Benutzer gewählten Zeitpunkt an die Persistenzkomponente übergibt.



An den Jargon der Datenbankprogrammierer angelehnt, liegt die Tabelle in der Anwendung in zwei Formen vor: Als Before-Image repräsentiert sie den Stand der Daten, bevor der Benutzer sie zu Gesicht bekommt und manipulieren kann (mit dem Image ist das Pflegebild gemeint, auf dem der Benutzer die Daten ändern kann). Das After-Image modelliert folglich die Änderungen, die der Benutzer an den Daten vorgenommen hat. Aus dem Vergleich des Before-Image mit dem After-Image ist eine Reihe von insert-, update- und delete-Operationen ableitbar, die an der Tabelle vorgenommen werden müssen.

Wenn die Tabelle vom Server kommt, stellt sie das Before-Image dar. Diese Daten muss der Client in einer Form präsentieren, die dem Benutzer leicht Änderungen ermöglicht. Neben der Präsentation verwaltet der Client auch die vom Benutzer getätigten Änderungen. Der Client weiss jederzeit, ob und welche Änderungen vorgenommen wurden. Nur wenn wirklich Änderungen vorgenommen wurden, braucht man dem Benutzer einen Sichern-Button anzubieten, mit dem er die gerade getätigten Änderungen "committen", das heisst in der Persistenzschicht fortschreiben kann.

Hier meine Beispiel-Implementierung [2]:

http://www.ruediger-plantiko.net/konto



Die Anwendung besteht aus einer einzigen HTML-Seite. Clientseitige Logik ist mit JavaScript abgebildet. Die Kommunikation mit dem Server erfolgt über Ajax, wobei als Struktur für den Datenaustausch in beide Richtungen das JavaScript-Datenformat JSON verwendet wird. Die HTML-Seite wird mittels JavaScript dynamisch manipuliert. Auf Serverseite nimmt ein Perl-Programm unter CGI die Anfrage entgegen, wobei ein Query-Parameter names action dem Server mitteilt, was er machen soll. Beispielsweise teilt die URL

/cgi-bin/konto.pl?action=get_all


dem Programm konto.pl mit, dass es alle Buchungszeilen aus der Kontodatei einlesen und dem Client im JSON-Format senden soll. Dagegen teilt eine zweite action namens save

/cgi-bin/konto.pl?action=save


dem Server mit, dass sich im Bauch des HTTP-Requests ein JSON-Hash mit Zeilendaten befindet, die in der Kontodatei aktualisiert werden sollen. Er verbucht diese Änderungen und sendet dann dem Client wie bei get_all das aktualisierte Bild der Datei (wieder im JSON-Format). Nur diese beiden actions werden übrigens benötigt.

Die beiden Schichten kommunizieren nur über diese Schnittstelle und sind ansonsten völlig unabhängig voneinander. Das heisst beispielsweise: Statt mit Perl unter CGI könnte man den Request auch mit einer beliebigen anderen Technologie behandeln. In meiner Implementierung ist der Schnitt zwischen den Systemen zugleich auch der Schnitt zwischen den beiden erwähnten Softwarekomponenten: Der Server verwaltet die Persistenzschicht, während die Verwaltung der Änderungen auf dem Client erfolgt.

Zu den Besonderheiten dieser Implementierung gehört, dass die Daten auf dem Client nur in der dynamischen Tabelle buchungen gehalten werden - das ist genau die Tabelle, die der Benutzer sieht. Es gibt kein Extrabild dieser Daten, etwa in Form eines globalen Arrays. Ähnlich ist es mit der Änderungsverwaltung: Es gibt kein globales Flag dataLoss. Wenn der Benutzer Änderungen vornimmt, werden diese für ihn sichtbar in der Tabelle als geändert markiert. Es gibt kein globales Flag, wohl aber eine Funktion dataLoss(), die einfach schaut, ob Zellen als geändert markiert sind.

Aber fangen wir mit dem Datenformat an. Ich habe mir der Einfachheit ein CSV-artiges Format ausgedacht. Man kann Kommentarzeilen und Leerzeilen verwenden. Diese bleiben bei Updates erhalten und werden im übrigen ignoriert. Kommentarzeilen beginnen wie in Perl mit dem Doppelkreuz (#). Ausserdem soll es neben den Buchungszeilen noch andere Zeilen in der Datei geben, die manuell oder von einem anderen Programm eingefügt werden könnten. Beispielsweise könnte ein Cron-Job regelmässig den Kontostand abfragen und als realen Saldo in die Datei einfügen. Unser Programm soll all diese Zeilen reproduzieren, aber für die eigene Verarbeitung ignorieren.

Das lässt sich so einrichten, dass die erste Spalte im CSV-Format eine ID des Satzes darstellt. Diese ID kann für die allein zu berücksichtigenden Buchungszeilen das Präfix buch haben, während andere Zeilen andere Präfixe haben. Saldozeilen zum Beispiel könnten mit dem Präfix saldo eingeleitete ID's haben. Die Datei kann also etwa folgendermassen aussehen:

# Abhebungen
buch1;1.09.2010;150.00;Norbert;Kursgebühr "Modernes JavaScript"
buch2;10.9.2010;605.00;Petra;Flug nach Florenz, Mietauto, Übernachtung
buch3;23.9.2010;200.00;Norbert;Spende an Wikipedia
saldo1;30.9.2010;15362.00
buch4;3.10.2010;350.00;Petra;Neues Smartphone


Wenn die Tabellenpflege nach Anmeldung im Web aufgerufen wird, erhält die zuständige Perl-Klasse CsvTableMaintainer den Befehl zum Laden aller Buchungszeilen (action=get_all). Die obige Beispieldatei wird dabei in folgendes JSON-Objekt transformiert:
{ buchungen:[
["buch1","1.09.2010","150.00","Norbert","Kursgebühr \"Modernes JavaScript\""],
["buch2","10.9.2010","605.00","Petra","Flug nach Florenz, Mietauto, Übernachtung "],
["buch3","23.9.2010","200.00","Norbert","Spende an Wikipedia"],
["buch4","3.10.2010","350.00","Petra","Neues Smartphone"]
],
user:"petra",
msg:""
}


Dieser Hash wird nun im Browser von der JavaScript-Funktion updatePage() entgegengenommen. Diese überträgt zunächst den User, unter dem die Anmeldung erfolgte, in ein dafür vorgesehenes Feld (wenn es nicht angezeigt werden soll, kann man dieses Feld auf unsichtbar setzen). Für jedes weitere Element des Hashs wird eine Funktion <key>_update(<value>) aufgerufen, wobei <key> den Schlüssel und <value> den Wert zu diesem Eintrag bedeutet — falls eine Funktion dieses Namens existiert. Wenn nicht, wird <key> einfach als ID eines Elements im HTML-DOM betrachtet, dessen Inhalt durch <value> zu ersetzen ist. Schliesslich wird noch die Sanduhr-Graphik auf unsichtbar geschaltet, die dem Benutzer die Server-Aktivität anzeigte:

// --- Nach Rückkehr eines Ajax-Requests: Seitenteile aktualisieren
function updatePage(transport) {
// Parameter transport ist das Ajax-Objekt (letztlich XMLHttpRequest)
var id, newCode;
newCode = transport.responseText.evalJSON();

// Den User zuerst aktualisieren
if (newCode.user) {
user_update( newCode.user );
delete newCode.user;
}

for (id in newCode) {
// Entweder mit spezieller Methode, falls implementiert ...
if (typeof self[id+"_update"] == "function") {
self[id+"_update"](newCode[id]);
}
// ... oder einfach durch Austausch des HTML-Contents
else {
$(id).update( newCode[id] );
}
}
// Ladezustand zurücksetzen
$("loading").hide();
}


Für den Schlüssel buchungen gibt es eine designierte Funktion buchungen_update(), die demnach mit dem Array der Buchungszeilen aufgerufen wird. Sie bekommt einen Array of Arrays übergeben (AoA), kann also auf jede Zelle jeder Zeile zugreifen. Sie durchläuft die anzuzeigenden Zellen in einer geschachtelten each()-Schleife und baut dabei die einzelnen <tr>- und <td>-Elemente der HTML-Tabelle auf. Die vom Anmeldeuser getätigten Buchungen bekommen darüberhinaus noch eine weitere Zelle, die controlCell, mit Ikonen zum Ändern und Löschen von Zeilen. Diese müssen dann noch alle klicksensitiv gemacht werden, indem der doOnClick() für das Event click registriert wird. Schliesslich wird der Button zum Sichern verborgen: Denn immer wenn diese Funktion durchlaufen wird, enthält die Tabelle den reinen Datenbankstand. Ein Sichern ist also unnötig:

// --- Vom Server als Array of Array (AoA) gesendete Buchungszeilen 
// ins HTML übernehmen
function buchungen_update( rows ) {

var tbody = $("buchungen").down("tbody");
var user = $("user").innerHTML;

tbody.update("");

var controlCellCode = controlCell();

rows.each( function(cells) {
var rowid = cells.shift();
var row = new Element( "tr", {id:rowid} );
cells.each( function(cellData, index) {
row.appendChild(
new Element( "td",
{className:"c"+(index+1)})
.update(cellData) );
});
row.appendChild(
new Element( "td",
{className:"c5"} ).update(
(cells[2] == user) ? controlCellCode : "" ) );
tbody.appendChild(row);
});

$("buchungen").show();

// Alle Bilder in der Buchungstabelle sind clicksensitiv
$$("#buchungen img").each( function( img ) {
img.observe("click", doOnClick );
});

// Datenbankstand - Sichern ist unnötig
$("save").hide();

}


Wenn der Benutzer nun "Ändern" oder "Neuer Eintrag" klickt, öffnet sich ein Formularbereich zum Pflegen einer einzelnen Tabellenzeile. Dieses Formular dient wirklich nur zum Editieren, es wird niemals verschickt. Zum Abschliessen drückt der Benutzer im Formularbereich auf den Button "Übernehmen". Dann schliesst sich das Formular, und die geänderten oder neuen Zellinhalte werden in der Tabelle eingetragen und erhalten eine markierung in Form der CSS-Klasse changed. Diese Markierung zeigt dem User ebenso wie dem Programm, dass diese Felder sich vom Datenbankstand unterscheiden.

Da die Funktion dataLoss() nun anspricht, wird auch der Button zum Sichern angeboten. Hierfür ist die Funktion checkDataLoss() zuständig, die nach allen Operationen, die potentiell zu Datenänderungen führen, aufgerufen wird. Diese ruft ihrerseits die schon erwähnte Funktion dataLoss() auf, um den Änderungsstatus durch Inspektion der Tabelle zu ermitteln. Das kann man mit dem Prototype-Framework sehr kompakt formulieren. Die vier Terme der Funktion lesen sich so: Schaue nach, ob es irgendeine echte Datenzeile der Buchungstabelle gibt, die entweder auf Zeilenebene die CSS-Klasse deleted besitzt oder irgendeine Zelle mit der CSS-Klasse changed enthält. Da die verwendeten Funktionen any() und down() kurzschliessen, d.h. die Iteration nach dem ersten Fund abbrechen, ist die Implementierung der Methode dataLoss() nicht nur kurz, sondern auch effizient:

// --- Save-Button nur anbieten, wenn sich Daten geändert haben
function checkDataLoss() {
$("save").style.display = dataLoss() ? "inline" : "none";
}

// --- Feststellen, ob Daten geändert wurden
function dataLoss() {
return $("buchungen").down("tbody").
select("tr").any( function(row) {
return row.hasClassName("deleted") ||
row.down("td[class~=changed]");
});
}


Irgendwann drückt der Benutzer "Sichern". Dann werden die Änderungen in der Funktion extractChanges() ermittelt und in Form eines JSON-Hashs an den Server übergeben. Neu angelegte Zeilen (die dadurch zu erkennen sind, dass ihre (vorläufige) ID mit dem Präfix new beginnt) werden hierbei gleich behandelt wie geänderte Zeilen: Die Zeilendaten werden als Datenteil zur ID in den Hash eingefügt. Für gelöschte Zeilen das spezielle Schlüsselwort deleted als Datenteil übergeben. Wenn die Benutzerin Petra in obigem Beispiel die Zeile buch2 löscht, den Betrag der Zeile buch4 von 350.00 CHF auf 400.00 CHF ändert und eine neue Zeile einfügt, um ihre Hannoversche Hotelrechnung zu erfassen, sieht der an den Server übergebene Hash folgendermassen aus:
{
buch2:"deleted",
buch4:"3.10.2010;400.00;Petra;Neues Smartphone",
new1:"9.10.2010;100.00;Übernachtung Hannover"
}

Der Server erhält diesen JSON-Hash im Body des HTTP-Requests, zusammen mit dem Wert save für den URL-Parameter action. Er wertet den Hash aus und übersetzt ihn in Anweisungen zur Änderung des Datenfiles. Auch wird für neu angelegte Zeilen eine endgültige ID vergeben. Schliesslich gibt er — wie das Kommando get_all — den aktuellen Stand der Buchungszeilen an den Client zurück.[3]

Der Einstiegspunkt für alle internen, von der Webseite mittels Ajax abgesetzten CGI-Anfragen ist das Perl-Programm konto.pl. Es wertet den URL-Parameter action aus. Wie macht es das? Der CGI-Mechanismus ist sehr einfach: Der Query-Teil einer URL wird dem aufgerufenen Programm in Form einer Umgebungsvariablen zur Verfügung gestellt. Der Body des Requests kann aus der Standardeingabe eingelesen werden, und alle Ausgaben an die Standardausgabe bilden den Body der HTTP-Antwort. Das Programm konto.pl extrahiert daher den Wert von action aus dem Query-String der URL und ruft dann dynamisch das Unterprogramm dieses Namens auf. Das geht so:
#!W:/perl/bin/perl.exe

# CGI Perl-Requesthandler, der mit der Kontopflegeseite kommuniziert

use strict;
use warnings;
no strict 'refs';

use CsvTableMaintainer; # Tabellenpflegeklasse
use MiniJSON; # Benötigte Perl-JSON-Konvertierungen

# URL-Querystring
my $queryString = $ENV{"QUERY_STRING"}
|| "action=get_all"; # Für standalone Testausführungen
my ($action) = ($queryString =~ /action=(\w+)/);

# Instanz der Tabellenpflegeklasse bilden
my $tableMaintainer = CsvTableMaintainer::new( file=>"konto.dat" );

# Erste Zeile der HTTP-Antwort
print "Content-Type:text/plain\n\n";

# Im Queryparameter übergebene Aktion ausführen
print &$action() if $action;

sub get_all {
...
}

sub save {
...
}


Wie man sieht, ist konto.pl nur der Dispatcher, der die Anfragen entgegennimmt und die entsprechenden Subroutinen aufruft. konto.pl ist schmal und hat nur wenige Subroutinen — normalerweise nur die den actions entsprechenden Unterprogramme. Bemerkenswert ist, dass für einfache Aufgaben wie diese auch kein use CGI::<irgendwas> notwendig ist: Query-Parameter auslesen und auf Payloads der ein- und ausgehenden HTTP-Nachrichten zuzugreifen, ist unter CGI so einfach, dass keine weiteren Hilfspakete dafür nötig sind.

konto.pl ist natürlich auch deshalb so schmal, weil es die eigentlichen Aufgaben an die Klasse CsvTableMaintainer und zu einem kleineren Teil an das Paket MiniJSON delegiert. Diese Programmteile befinden sich - wie auch alle anderen, für dieses Beispiel benötigten Programmteile - in meinem github-Reposoritory konto. Wer sich die Beispielapplikation daher noch genauer ansehen will, sei auf dieses Repository verwiesen.


[1] Vielleicht habe ich nicht gründlich genug gesucht oder war mit den gefundenen Objekten nicht zufrieden.
[2] Neben dem JavaScript-Framework Prototype von Sam Stephenso, das mich dabei unterstützt, lesbares JavaScript zu schreiben, verwende ich den Datepicker von Hugo Ortega Fernandez. Und - ja, ich mag die SAP-Ikonen!
[3] Um Codeduplizierung zu vermeiden, ruft er dafür nach Ausführung des Sicherns schlicht die Aktion get_all auf.

Keine Kommentare :