Aus der CGI-Trickkiste (Teil 3) (Linux-Magazin, Mai 98)

Client und Server verständigen sich über das CGI-Protokoll normalerweise ohne daß einer der beiden Partner sich den Zustand des anderen merkt. War derselbe Kunde schon mal da? Keine Ahnung! Aber spätestens falls jemand eine Bestellung in einem Ordersystem abgibt, wäre es vielleicht sinnvoll, den Auftrag einer vorher eingegebenen Kundennummer zuzuordnen - es sei denn, man möchte nichts verkaufen.

Um diese Problematik anzugehen, braucht das CGI-Protokoll ein wenig Unterstützung, ein Zustand muß her: Der Kunde gibt die Kundennummer ein - schnipp! - bestellt etwas - schnipp! - bestellt noch etwas - schnipp, schnipp, schnipp! - alles Zustände, die gespeichert und später wieder abholt werden wollen! Bloß wo?

Hidden Fields
Hidden Fields sind HTML-Formular-Felder, die Key/Value-Paare enthalten und Zustandsinformationen einer solchen CGI-Session dauernd zwischen Client und Server hin- und hertragen. Der Server legt zu sichernde Information in einem versteckten Feld eines Formulars ab, das er sowieso dem Client schickt. Der Browser des Clients schickt es beim nächsten 'Submit' unbewußt wieder mit. Der kleine Online-Shopper im letzten Beitrag zeigte, wie das funktioniert.

Cookies
Ein Cookie enthält eine kleine Informationsmenge, die der Server dem Client über einen Header-Eintrag zusteckt. Einmal geschluckt, bleibt es im Browser, der es beim nächsten Zugriff auf die gleiche URL (oder eine andere URL in derselben Domain, je nach Einstellung) wieder mitschickt, ohne daß der Benutzer sich darum kümmern muß, ja, oftmals sogar ohne daß der Benutzer davon weiß. Der Browser muß hierzu Cookies unterstützen, der Netscape-Navigator fing damit an, der Internet-Explorer zog bald nach, heute ist es Standard. Na, beinahe.

Server-Dateien
Paßt die zu speichernde Information nicht mehr in ein oder mehrere Hidden Fields oder Cookies, kann der Server sich den Zustand einer Session auch in einer Datei (oder auch in einer Datenbank) merken. Anhand eines per Hidden-Field/Cookie übermittelten eindeutigen Schlüssels identifiziert er einen einmal dagewesenen Besucher beim nächsten Mal sofort und kann sich auf der Festplatte z.B. alle Waren merken, die in dessen digitalem Einkaufswagen liegen.

Das Skript cookie.pl identifiziert Kunden anhand einer ID, die es beim ersten Besuch eindeutig aus der aktuellen Uhrzeit und der Nummer des ausführenden Prozesses - modulo 256 - generiert. Vor der Ausgabe des 'Willkommen!'-Textes setzt es den Set-Cookie-Header des übermittelten Dokuments, jubelt so dem Browser das Cookie unter, der es bei allen folgenden Besuchen wieder herausrückt und dem Skript so gedächtnismäßig auf die Sprünge hilft.

Listing cookie.pl

    #!/usr/bin/perl -w
    ######################################################################
    # Michael Schilli, 1998 (mschilli@perlmeister.com)
    ######################################################################
    
    use CGI qw/:standard/;
    
    if(defined ($id=cookie(-name => 'ID'))) { # Cookie gesetzt!
        print header();      
        print b("Nummer $id! Was für eine Freude!");
    
    } else {                            # Neuer Kunde
        $id = unpack ('H*', pack('Nc', time, $$ % 0xff));
    
        $cookie = cookie('-name'    => 'ID',
                         '-value'   => $id,
                         '-expires' => '+1h',
                         '-domain'  => '.gauner.com');
        print header('-cookie' => $cookie);
        print b("Willkommen bei der Cookie-Mafia, Nummer $id!");
    }

Cookies enthalten Name/Value-Paare, im vorgestellten Beispiel steht unter dem Schlüssel ID eine eindeutige Hex-Zahl. Die expire-Option bestimmt ein Verfallsdatum, nach dessen Ablauf der Browser die Information wieder vergißt. In cookie.pl passiert dies mit "+1h" nach einer Stunde, andere mögliche Werte wären z. B. "+1d" für einen Tag oder "+10y" für zehn Jahre. Ist noch eine Domain angegeben, wie .gauner.com im vorliegenden Beispiel, liefert der Browser das Cookie nicht (nur) an die URL aus, hinter der das Skript hängt, sondern fügt es allen Aufrufen aus der gleichen Domain bei. So kommen unter Umständen mehrere Skripts in den Genuß des Wiedererkennungs-Effekts. Stimmt die Domain hingegen nicht, gibt's auch kein Cookie. Ähnlich funktioniert die in cookie.pl nicht verwendete -path-Option, die den Browser das Cookie nur an Skripts unter dem eingestellten Pfad (z. B. cgi-bin/mydir) übermitteln läßt.

Abbildung 1 zeigt den Browser beim ersten Aufruf von cookie.pl, Abbildung 2 bei allen folgenden. Bei gesetzter -expire-Option bleibt das Cookie auch über das Programmende des Browsers hinaus in dessen ``Magen'' und ist bei einem Neustart sofort wieder präsent.

Abb.1: Heute als Fremder ...

Abb.2: ... nächstens als Freund!

Der Server kriegt Zustände

Erinnert sich noch jeder an die erste Folge dieser CGI-Reihe? Dort war davon die Rede, daß das CGI-Modul normalerweise mit CGI-Objekten arbeitet und man sich mit dem :standard-Tag darum drücken kann - aber jetzt trifft's uns: Die nachfolgende save-Methode braucht tatsächlich das doofe Objekt.

<include file=eg/savetest.pl filter=eg/cut2.sed>

Alles klar? Die save-Methode schreibt alle hereinkommenden CGI-Parameter im Format key=value in Richtung des angegebenen File-Handles, im Beispiel in die Standard-Ausgabe. Ruft man das Skript oben von der Kommandozeile auf und tippt nach dem Prompt

    (offline mode: enter name=value pairs on standard input)

die Werte (CTRL-D ist Control und 'D')

    a='Hallo Test'
    b=(Klammer)
    CTRL-D

ein, erscheint

    a=Hallo%20Test
    b=%28Klammer%29
    =

save schreibt also alle Eingabeparameter weg, wobei es Sonderzeichen sauber nach dem URL-Encoding-Verfahren umsetzt und schließt die Folge mit einer Zeile, die nur = enthält, abschließt. Andersherum erzeugt die Konstruktion

   $q = new CGI(FILE);

ein neues CGI-Objekt, dessen Eingabedaten keineswegs über die CGI-Schnittstelle eintrudeln, sondern aus einer Datei stammen, die mit dem File-Handle FILE verbandelt ist. So darf ein Skript durchaus mit mehreren CGI-Objekten jonglieren, die entweder aus dem CGI-Umfeld oder aus der Konserve stammen.

Listing cart.pl zeigt die ultimative Anwendung: Ein simples Shopping-Cart, einen virtuellen Einkaufswagen. Erst trägt der potentielle Kunde seinen Namen mit Adresse in ein Formular ein (Abb. 3) und dann bekommt er aus einem Katalog von (für das Beispiel) durchnumerierten Produkten pro Seite jeweils zehn zur Auswahl dargestellt. Er kann einzelne Artikel auswählen, vor- und zurückblättern (Abb. 4), bis er sich schließlich dazu durchringt, zur Kasse zu fahren und die Bestellung abzuschicken (Abb. 5).

Zeile 5 definiert das Verzeichnis für Dateien, in denen er sich jeweils den Zustand der einzelnen Kunden merkt. Im Beispiel muß das Verzeichnis customers unterhalb von cgi-bin bereits existieren und für den Eigentümer des Web-Servers beschreibbar sein, sonst kracht's.

$items_total ist die Gesamtzahl der zur Verfügung stehenden Artikel, eine Anzahl $items_perpage von Produkten stellt das Skript jeweils auf einer Seite zur Auswahl dar. Für das Testbeispiel generieren die Zeilen 10 bis 12 den Hash %merchandise, der jeder Artikel-Nummer 1 bis 100 den beschreibenden Text Artikel Nummer X zuordnet.

Zeile 14 liest ein eventuell schon übermitteltes Cookie ein, für Neukunden ist $id demnach nicht gesetzt. In diesem Fall erzeugt cart.pl in Zeile 18 eine eindeutige Identifikationsnummer, indem es die aktuelle Uhrzeit in Sekunden und das letzte Byte (% 256) der Nummer des laufenden Prozesses ($$) als Hex-Zahlen hintereinanderhängt.

Diese ID verpackt Zeile 21 in ein Cookie und sendet es dem Browser, der es mangels gesetztem -expire-Datum nur im Rahmen seiner Laufzeit im Gedächtnis behält. Bei einem Neustart ginge der Reigen von vorne los. Die anschließend aufgerufene Funktion print_address_form gibt das Eingangsformular (Abb. 3) aus - Ende der ersten Runde.

Füllt der Kunde das Formular aus und klickt auf den "Submit Query"-Button, kommt die Logik ab Zeile 28 zum Zug, die, falls noch keine Kundendatei existiert, ein CGI-Objekt erzeugt und eine neue Datei, die den Namen der Kunden-ID trägt, anlegt. War der Kunde schon mal da, existiert die Datei schon und Zeile 31 liest den aktuellen Zustand in das CGI-Objekt $q ein. Die Funktionen save_cgi und restore_cgi sind ab den Zeilen 96 bzw. 104 definiert und stützen sich auf die save-Methode und den Konstruktor von CGI.pm. Falls in dem übermittelten Cookie nicht die erwartete Hex-Zahl, sondern irgendwelche Schweinereien stehen, bricht Zeile 106 das Skript ab.

Zeile 34 prüft, ob der Kunde auch alle Felder des Adressformulars ausgefüllt hat. Ist dies nicht der Fall, zeigt chart.pl wieder das Adressformular, diesmal mit einer roten Fehlermeldung.

Der in Zeile 41 ausgelesene CGI-Parameter $offset gibt an, an welcher Stelle des Katalogs der Kunde beim letzten Aufruf schmökerte. Die Zeilen 43 bis 54 korrigieren den Wert des CGI-Parameters items, der eine Liste ausgewählter Artikelnummern enthält. Hierzu legt der grep-Befehl aus Zeile 43 einen Array @selected an, der die Nummern bisher selektierter Artikel kopiert, aber die im vorher dargestellten Fenster ausläßt. Warum? Das Formular aus Abbildung 4 übermittelt im CGI-Parameter items nur gesetzte Artikel. Hat sich der Kunde umentschieden und einen vorselektierten Eintrag wieder deselektiert, kommt für den entsprechenden Knopf aus dem Formular kein CGI-Parameter herein, folglich entfernt cart.pl vorsorglich Werte im @select-Array, die gerade in der Darstellung waren, um anschließend ab Zeile 47 die neuselektierten wieder reinzupumpen. Die Zeilen 51 bis 53 besetzen den CGI-Parameter items, der die Liste der selektierten Einträge enthält und halten den Zustand der Session in der Server-Datei fest. Drückte der Kunde den Button Bestellen, verzweigt Zeile 55 zum Code ab Zeile 56, der die Bestellungs-Bestätigung ausdruckt. Hier müsste ein reales Order-System dann den Auftrag in die Wege leiten, z.B. eine Email an einen Bearbeiter schicken und nach getaner Arbeit die Zustandsdatei auf dem Server löschen.

Falls der Kunde Vorblättern oder Zurückblättern wählte, verschrauben die Zeilen 68 und 71 den Wert der $offset-Variablen um eine Seitenlänge, und 74-75 speichern den neuen Wert in der Zustandsdatei.

Die Zeilen 77 und 78 legen in den Array @subset die Nummern der im aktuellen Durchgang zur Auswahl gestellten Artikel ab, indem sie den %merchandise-Hash nach numerischen Schlüsseln sortieren und mit splice ein Segment herausschneiden.

Die Zeilen 81 bis 92 zeichnen für die Ausgabe des Auswahlformulars verantwortlich.

Der zentrale checkbox_group-Befehl erzeugt den HTML-Code für die aufgereihten Knöppe, die default-Option selektiert die irgendwann vorher ausgewählten praktischerweise gleich vor.

Zu beachten ist die unterschiedliche Notation der Funktionen aus dem CGI-Modul: Da $q vorher mit dem eingefrorenen Zustand aus einer Dateien-Konserve initialisiert wurde, holt $q->param() Werte aus dem in der Serverdatei gesicherten CGI-Objekt, während param() (ohne Objekt) sich auf die aktuell hereinkommenden Parameter bezieht.

Nächstes Mal gibt's als Abschluß Non-parsed-Header-Skripts -- ohne Netz und doppelten Boden. Bleibt am Ball!

Listing cart.pl

    001 #!/usr/bin/perl -w
    002 ######################################################################
    003 # Michael Schilli, 1998 (mschilli@perlmeister.com)
    004 ######################################################################
    005 
    006 use CGI qw/:standard :html3/;      # Standard und Tabellen
    007 
    008 $cudir         = "customers";      # Verzeichnis für temporäre Dateien
    009 $items_total   = 100;              # Gesamtzahl aller Artikel
    010 $items_perpage = 10;               # Dargestellt pro Seite
    011 
    012 for($i=1; $i<=$items_total; $i++) { # Pseudo-Artikel-Hash
    013     $merchandise{$i} = "Artikel Nummer $i";
    014 }
    015 
    016 $id = cookie(-name => 'ID');       # Cookie entgegennehmen
    017 
    018 if(!defined $id) {                 # Kein Cookie - neuer Kunde!
    019                                    # Neue ID: Zeit und Prozeßnummer
    020     $id = unpack ('H*', pack('Nc', time, $$ % 0xff));
    021 
    022     print header('-cookie' => cookie('ID' => $id));
    023     print_address_form();          # Cookie/Adreßformular schicken
    024     exit 0;
    025 }
    026 
    027 print header, start_html;
    028 
    029 if(! -f "$cudir/$id") {
    030     save_cgi($q = new CGI);    # Neue Kundendatei anlegen
    031 } else {                   
    032     $q = restore_cgi();        # Kundendatei einlesen
    033 }
    034                                # Adressinformation komplett?
    035 if(!$q->param('name') || !$q->param('vorname') ||
    036    !$q->param('strasse') || !$q->param('plz') || 
    037    !$q->param('wohnort')) {
    038     print_address_form("Bitte füllen Sie alle Felder aus.");
    039     exit 0;
    040 }
    041 
    042 $offset = ($q->param('offset') || 0);
    043 
    044 @selected = grep { $_ <= $offset || 
    045                    $_ > $offset+$items_perpage } 
    046                  ($q->param('items'));
    047 
    048 foreach $item (param('newitems')) {
    049     push(@selected, $item);
    050 }
    051 
    052 $q->delete('items');       # CGI-Parameter zurücksetzen
    053 $q->param('items', @selected);
    054 save_cgi($q);              # Neu gesetzt und gesichert
    055 
    056 if(param('Bestellen')) {   # Ausgang
    057     print $q->param('vorname'), " ", $q->param('name'), br,
    058           $q->param('strasse'), br, 
    059           $q->param('plz'), " ", $q->param('wohnort'), 
    060           br, br, h1("Bestellung");
    061     foreach $item ($q->param('items')) {
    062         print "1 Stueck $merchandise{$item}", br;
    063     }
    064     print p, b("Ihre Bestellung ist schon unterwegs!"), end_html;
    065 
    066 } else {                   # Warenliste anzeigen
    067 
    068     if($offset >= $items_perpage) {
    069         $offset -= $items_perpage if param("Zurückblättern");
    070     }
    071     if($offset < $items_total - $items_perpage) {
    072         $offset += $items_perpage if param("Vorblättern");
    073     }
    074 
    075     $q->param('offset', $offset);
    076     save_cgi($q);
    077 
    078     @subset = sort {$a <=> $b} keys %merchandise;
    079     @subset = splice(@subset, $offset, $items_perpage);
    080 
    081                            # Neue Warenliste
    082     print h1("Guten Tag, ", $q->param('vorname'), "!"),
    083           p, "Zur Auswahl stehen heute:",
    084           start_form(), 
    085           $q->checkbox_group(
    086               '-name'      => 'newitems',
    087               '-values'    => [@subset],
    088               '-default'   => [$q->param('items')],
    089               '-linebreak' => 'true',
    090               '-labels'    => \%merchandise),
    091           submit('Zurückblättern'), submit('Vorblättern'), 
    092           submit('Bestellen'), 
    093           end_form, end_html;
    094 }
    095 
    096 #############################################################
    097 sub save_cgi {
    098 #############################################################
    099     open(FILE, ">$cudir/$id") || die "Can't open $cudir/$id";
    100     $_[0]->save(FILE);
    101     close(FILE);
    102 }
    103 
    104 #############################################################
    105 sub restore_cgi {
    106 #############################################################
    107     die "Get off, hacker!" if $id !~ /^[0-9a-f]+$/;
    108     open(FILE, "<$cudir/$id") || die "Can't open $cudir/$id";
    109     my $q = new CGI(FILE);
    110     close(FILE);
    111     return $q;
    112 }
    113 
    114 #############################################################
    115 sub print_address_form {
    116 #############################################################
    117     my $msg = (shift || "");
    118     print start_html, 
    119           tt(CGI::font({color => 'red'}, $msg)),
    120           start_form, 
    121           table(
    122             TR(td("Name:"), td(textfield('name'))),
    123             TR(td("Vorname:"), td(textfield('vorname'))),
    124             TR(td("Straße:"), td(textfield('strasse'))),
    125             TR(td("Postleitzahl:"), td(textfield('plz'))),
    126             TR(td("Wohnort:"), td(textfield('wohnort')))),
    127           submit, end_form, end_html;
    128 }

Abb.3: Adresse eintragen ...

Abb.4: ... Produkte auswählen ...

Abb.5: ... und zur Kasse!

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.