Schnipselwerk (Linux-Magazin, Dezember 2005)

Ajax-Technologie reichert dröge Webapplikationen mit dynamischen Elementen an, damit der Benutzer sie als desktopartig empfindet. Ein Perl-Skript zeigt eine einfache Implementierung eines dynamischen Textschnipselverwalters.

Abbildung 1: Der Textschnipselverwalter im Browser.

Mit Gmail fing es fast unbemerkt an, spätestens die Maps-Seite traf alle Webentwickler wie ein Paukenschlag: Auf einmal war es möglich, den dargestellten Ausschnitt einer Landkarte dynamisch zu verschieben, ganz so, als liefe da im Browser keine Webapplikation sondern ein lokales GUI. Auf einmal war kein lästiger Client-Server-Rundtrip und anschließendes Nachladen der gerade dargestellten Seite mehr notwendig, um Zustandsänderungen einer Applikation anzuzeigen. Heutzutage schießen die Ajax- Applikationen auf dem Web wie Pilze aus dem Boden. Der Beta-Release der Yahoo! Webmail sieht einer Desktop- Applikation schon verblüffend ähnlich, nur wer genau hinsieht, merkt, dass eigentlich der gute alte Mozilla Firefox läuft.

AJAX (Asynchronous JavaScript and XML) baut auf Dynamic HTML und Client-seitigem JavaScript auf. Das ursprünglich von Microsoft erfundene und lange unter dem Radar geflogene Objekt XMLHttpRequest macht es möglich, dass ein von einer Webseite heruntergeladenes JavaScript-Skript asynchron (also ohne dass die Darstellung der Seite oder die Benutzerführung leidet) mit dem Webserver Daten austauschen kann. Diese schmuggelt es dann dynamisch ins HTML der Seite ein, sodass dort nur kleine Änderungen passieren, ohne dass der Browser die Seite komplett neu aufbauen muss.

Abbildung 1 zeigt eine Beispielapplikation, die oft genutzte Textschnipsel für Emails unter benutzerdefinierten Namen auf dem Webserver verwaltet und zum Cut-and-Paste in einem Textfeld darstellt. Mit Add new topic lässt sich ein neuer Name definieren, Update spielt dem Server den Text im Textfenster zu und Remove löscht den angewählten Eintrag vom Server und eliminiert ihn aus der angezeigten Liste.

Die Darstellung wird nur ein einziges Mal in den Browser geladen. Die unterstrichenen Links sehen zwar wie Hyperlinks aus und laden zum Klicken ein, laden aber kein neues Dokument in den Browser. Lediglich der jeweils im OnClick-Handler angegebenen JavaScript-Code wird ausgeführt.

Das Ganze wird von dem Perl-Modul CGI::Ajax von Brent Pedersen schön verpackt. Es definiert ein Client-Server-Protokoll (in JavaScript und Perl), mit dem der JavaScript-Code Server-Funktionen per Funktionsnamen und Parameterlisten anspricht.

Ein JavaScript-Objekt vom Typ XMLHttpRequest (oder auf IE ein ActiveXObject("Microsoft.XMLHTTP")) sorgt dafür, dass der Browser dem CGI-Skript auf der Serverseite einen GET-Request zuspielt, der vorher ausgehandelte Perl-Funktionen anspricht, die wiederum Werte zurückschicken. Diese schnappt der JavaScript-Code auf und frischt die Felder der GUI auf. Ein mit

    CGI::Ajax->new(
        'display' => \&display);

im CGI-Skript definiertes Objekt vom Typ CGI::Ajax legt fest, dass sich im später ausgesandten HTML eine JavaScript-Sektion befindet, die die Funktion display() definiert. Auf der Perl-Seite auf dem Server wird ebenfalls eine Funktion display() definiert, die der JavaScript-Handler später per HTTP-Request ansprechen wird. Ein in HTML definierter Radiobutton mit dem OnClick-Handler

    OnClick="display(['Faule Ausrede'],
                     ['tarea', 'statusdiv'])"

ruft die JavaScript-Funktion display() auf und übergibt ihr die zwei angegebenen Arrays. Der erste Array enthält das id-Attribut des HTML-Elements der Schnipselüberschrift, deren String der Server-Funktion übergeben wird. Der zweite Array führt die id-Attribute der HTML-Tags, die der Handler nach Abschluss des Requests mit den Rückgabewerten der Serverfunktion auffrischen wird.

Abbildung 2: Kommunikation zwischen Browser, JavaScript Engine und Web Server

Im vorliegenden Fall führt der Radiobutton mit der id ``Faule Ausrede'' auch das Attribut VALUE="Faule Ausrede", und demensprechend erhält die Perl-Funktion display() auf dem Server (in Listing snip in Zeile 10) diesen Textstring als ersten Parameter überreicht. display() macht nichts weiter, als das zu ``Faule Ausrede'' passende Textschnipsel aus dem Cache zu holen und zusammen mit einer Statusmeldung zurück an den Browser zu senden. Dort springt der JavaScript-Eventhandler wieder ein und frischt das große Textfeld (id='tarea') und das Statusfeld ganz unten (id='statusdiv') mit den von display() zurückgegebenen Strings auf. All dies wird sauber von CGI::Ajax abstrahiert, das den dazu notwendigen JavaScript-Code zum Browser sendet und die serverseitigen Handler zum Anspringen der Perl-Funktionen bereitstellt.

Allerdings macht snip Client-seitig mehr als nur Textfelder aufzufrischen. Falls der Benutzer eine der Überschriften mit einem Mausklick auf Remove löscht, verschwindet nicht nur der Radiobutton mit der Überschrift, sondern auch die allererste Überschrift in der verbleibenden Liste wird selektiert und deren Textschnipsel vom Server geladen. Dergleichen kann CGI::Ajax noch nicht, aber mit zusätzlichen JavaScript-Funktionen lässt sich das bewerkstelligen.

Dafür ist CGI::Ajax aber denkbar einfach zu bedienen: Wie Listing snip zeigt, sind lediglich Funktionen für die verschiedenen Client-Aktionen (remove/update/display) zu definieren und eine Funktion show_html bereitzustellen, die beim ersten Laden das HTML der Client-Applikation zurückliefert.

Listing 1: snip

    01 #!/usr/bin/perl -w
    02 use strict;
    03 use CGI;
    04 use CGI::Ajax;
    05 use Cache::FileCache;
    06 use Template;
    07 
    08 my $cache = Cache::FileCache->new();
    09 
    10 ###########################################
    11 sub display {
    12 ###########################################
    13   my($topic) = @_;
    14 
    15   return $cache->get($topic), 
    16          "Retrieved $topic";
    17 }
    18 
    19 ###########################################
    20 sub remove_me {
    21 ###########################################
    22   my($topic) = @_;
    23 
    24   $cache->remove($topic);
    25   return "Deleted $topic";
    26 }
    27 
    28 ###########################################
    29 sub update_me {
    30 ###########################################
    31   my($topic, $text) = @_;
    32 
    33   $cache->set($topic, $text);
    34 
    35   my $disptext = $text;
    36   $disptext = substr($text, 0, 60) . 
    37               "..." if length $text > 60;
    38   return "Topic '$topic' updated " .
    39          "with '$disptext'";
    40 }
    41 
    42 ###########################################
    43 sub show_html {
    44 ###########################################
    45   my $template = Template->new();
    46 
    47   my @keys = sort $cache->get_keys();
    48 
    49   $template->process("snip.tmpl", 
    50     { topics => \@keys }, 
    51     \my $result) or die $template->error();
    52 
    53   return $result;
    54 }
    55 
    56 ###########################################
    57 # main
    58 ###########################################
    59 my $cgi = CGI->new();
    60 $cgi->charset("utf-8");
    61 
    62 my $pjx = CGI::Ajax->new(
    63   'display'   => \&display,
    64   'update_me' => \&update_me,
    65   'remove_me' => \&remove_me
    66 );
    67 print $pjx->build_html($cgi, \&show_html);

HTML und Perl getrennt

Das Perlskript snip bezieht das auszusendende HTML aus dem Template snip.tmpl, das in Abbildung 2 zu sehen ist. Das Template-Toolkit lädt die Vorlage und bietet eine Reihe von Konstrukten, mit denen man einfach for-Schleifen oder Bedingungen ins HTML einbetten kann. Bewusst verzichtet es auf eine komplette Programmiersprache, um die Vermischung von Applikationslogik und Darstellung zu verhindern. So iteriert es mit [% FOREACH topic = topics %] über den vorher via snip hereingereichten Array @topics mit den Schnipselüberschriften und gibt für jede einen Radiobutton in einer Tabelle aus. [% topic %] liefert jedes Mal den Wert der Template-Variablen topic zurück. Das Attribut id jedes Radiobuttons wird jeweils auf den Textstring der Überschrift gesetzt, der OnClick-Handler auf die vorher beschriebene Funktion display(), die sowohl im clientseitigen JavaScript- als auch im Perl-Universum auf dem Server existiert.

select_first() selektiert den ersten Eintrag in der Liste der Radiobuttons und fordert vom Server den Textschnipsel zu der entsprechenden Überschrift an, um ihn im großen Textfeld anzuzeigen. Hierzu sucht es zunächst nach dem HTML-Tag mit der ID form (das HTML-Formular in snip.tmpl) und analysiert dann den Rückgabewert. Ist die Überschriftenliste hingegen leer, selektiert select_first() natürlich nichts und kommuniziert auch nicht mit dem Server.

Es ist wichtig, dass die in snip verwendeten Hyperlinks vom Tagtyp <A> in ihren OnClick-Handlern als letzte Aktion return false stehen haben. So führt der Browser bei einem Klick auf den Link lediglich den zugeordneten JavaScript-Code aus und folgt nicht dem angegebenen Bogus-Attribut HREF.

Abbildung 3: HTML-Template, das snip mit dem Template-Toolkit verarbeitet

Das Template lädt zu Anfang die JavaScript-Bibliothek snip.js, die einige Funktionen bereitstellt, damit die GUI rund läuft. Die JavaScript-Funktion topic_add() in snip.js nimmt den String einer Überschrift entgegen und fügt am Ende der Radiobutton-Tabelle einen neuen Eintrag mit diesem Namen ein. Hierzu sucht es zunächst die Radiobutton-Tabelle, erzeugt zwei neue Tabellenspalten, eine neue Tabellenreihe, einen neuen Radiobutton und hängt diese Einträge dann in die Tabelle ein.

topic_remove() löscht eine Überschrift aus der Liste von Radiobuttons und schickt einen Request an den Server, um das Textschnipsel dort ebenfalls zu löschen.

id_selected() gibt das id-Attribut des selektierten Radiobuttons aus, also den String der Überschrift, die der Benutzer zu sehen wünscht. Ist die Liste jedoch leer, zeigt sich Firefox manchmal verwirrt und liefert dennoch einen Wert zurück. Um diesen Bug zu umschiffen, prüft id_selected() das Ergebnis von id_selected_first_pass() nochmals nach, und liefert undefined zurück, falls Firefox beim Schummeln erwischt wurde.

Sind zwei oder mehr Einträge in der Liste von Radiobuttons, liefert form.r.length die Länge der Liste. Ist nur ein Eintrag präsent, liefert form.r.length einen undefinierten Wert. Ist die Liste leer, ist form.r undefiniert. Das checked-Flag des ersten Eintrag wird dann entweder über das einzigen Elements mit dem NAME-Attribut r (form.r.checked) oder dem i-ten Element der Liste (form.r[i].checked) angesprochen. Anschließend ruf select_first() noch die Funktion display() auf, damit vom Server der Text zur ersten Überschrift eingeholt wird.

Mit Haken und Ösen

Das Skript dient nur der Illustration und müsste für den professionellen Gebrauch noch verfeinert werden. Mit anderen Browsern als Firefox treten Kompatibilitätsprobleme und es gibt professionelle Toolkits (und bald Microsofts ``Atlas''), die sich darum kümmern, dass auch Oma Meumes IE 4.0 eine akzeptable (wenn auch abweichende) Darstellung bekommt. Auch die Fehlerbehandlung wurde weggelassen. Es ist gar nicht so einfach, einzuprogrammieren, was passiert, wenn ein unsichtbarer HTTP-Request schief geht. Hierzu muss man in die Niederungen des JavaScript-Codes hinabsteigen und entsprechende Rückmeldungen für den Benutzer definieren. Der Teufel liegt bei Ajax wie so oft im Detail.

Listing 2: snip.js

    001 // snip.js Mike Schilli, m@perlmeister.com, 2005
    002 
    003 // ##################################################
    004 function topic_add(topic) {
    005 // ##################################################
    006   var itemTable = document.getElementById("topics");
    007   var newRow    = document.createElement("TR");
    008   var newCol1   = document.createElement("TD");
    009   var newCol2   = document.createElement("TD");
    010   var input     = document.createElement("INPUT");
    011 
    012   if(topic.length == 0) {
    013       alert("No topic name specified.");
    014       return false;
    015   }
    016 
    017   input.name    = "r";
    018   input.type    = "radio";
    019   input.id      = topic;
    020   input.value   = topic;
    021   input.onclick = function() {
    022       display([topic], ['tarea', 'statusdiv']);
    023   };
    024   input.checked = 1;
    025   newCol1.appendChild(input);
    026 
    027   var textnode = document.createTextNode(topic);
    028   newCol2.appendChild(textnode);
    029     
    030   itemTable.appendChild(newRow);
    031   newRow.appendChild(newCol1);
    032   newRow.appendChild(newCol2);
    033 
    034   document.getElementById('tarea').value = "";
    035   document.getElementById('new_topic').value = "";
    036 
    037   return false;
    038 }
    039 
    040 // ##################################################
    041 function topic_update() {
    042 // ##################################################
    043   if(!id_selected()) {
    044     alert("Create a new topic first");
    045     return;
    046   }
    047   update_me( [ id_selected(), 'tarea' ], 
    048              'statusdiv');
    049 }
    050 
    051 // ##################################################
    052 function topic_remove() {
    053 // ##################################################
    054   var sel = id_selected();
    055 
    056   if(!sel) { alert("No topic available");
    057     return;
    058   }
    059 
    060   remove_me([sel], 'statusdiv');
    061 
    062   var node = document.getElementById(sel);
    063   var row  = node.parentNode.parentNode;
    064   row.parentNode.removeChild(row);
    065   select_first();
    066 }
    067 
    068 // ##################################################
    069 function select_first() {
    070 // ##################################################
    071   var form = document.getElementById("form");
    072   if(! form.r) { return; }
    073   if(! form.r.length) {
    074     form.r.checked = 1;
    075     if(! document.getElementById(id_selected()) ) {
    076        document.getElementById('tarea').value = "";
    077       return;
    078     }
    079     display([id_selected()], ['tarea', 'statusdiv']);
    080   }
    081 
    082   for(var i = 0; i < form.r.length; i++) {
    083     form.r[i].checked = 1;
    084     break;
    085   }
    086   display([id_selected()], ['tarea', 'statusdiv']);
    087 }
    088 
    089 // ##################################################
    090 function id_selected() {
    091 // ##################################################
    092   sel = id_selected_first_pass();
    093 
    094   if(! document.getElementById(sel) ) {
    095     document.getElementById('tarea').value = "";
    096     return;
    097   }
    098   return sel;
    099 }
    100 
    101 // ##################################################
    102 function id_selected_first_pass() {
    103 // ##################################################
    104   var form = document.getElementById("form");
    105   if(! form.r) { return 0; }
    106   if(! form.r.length) { return form.r.id; }
    107 
    108   for(var i = 0; i < form.r.length; i++) {
    109     if(form.r[i].checked) {
    110       return form.r[i].id;
    111     }
    112   }
    113   alert("Selected ID is unknown");
    114   return 0;
    115 }

Installation

Das Skript benötigt die Module Class::Accessor, CGI::Ajax und Template vom CPAN. Danach kommen das Skript snip (ausführbar!) und das Template snip.tmpl ins Verzeichnis cgi-bin des Webservers und das JavaScript-Skript snip.js direkt unter die Dokumentenwurzel (meist htdocs).

Falls das zum Cut-and-Paste der Textschnipsel verwendete Terminal kein UTF-8 versteht, sollte Zeile 59 in snip auskommentiert werden, damit der Webserver stattdessen charset=iso-8859-1 im CGI-Header sendet. Und wer den Dokumenten-Cache nicht unter /tmp anlegen will, legt das gewünschte Verzeichnis mit

    my $cache = Cache::FileCache->new({
        cache_root => "/pfad"})

in Zeile 7 fest. Anschließend lädt ein auf http://server/cgi-bin/snip gerichteter Browser die Oberfläche und führt, falls JavaScript aktiviert ist, die dort angebotenen Schnipselfunktionen aus, kommuniziert mit dem Server und hält dort den Schnipselspeicher aktuell.

Infos

[1]
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2005/12/Perl

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.