24/7 Adressen-Butler (Linux-Magazin, Mai 1999)

Perls generische Datenbankschnittstelle DBI bietet einen Treiber an, der SQL-Abfragen auf Datenbestände ohne eine Datenbank zuläßt -- ordinäre Dateien speichern die Tabellendaten in komma-separierten Einträgen. Ideal für ein kleines CGI-Adreßbuch!

Notizbücher und Palmtops haben den Nachteil, daß man sie immer dann einzustecken vergißt, wenn man sie am nötigsten braucht. Wer wie ich eh den ganzen Tag am Internet hängt, kommt da schon mal auf die Idee, ein Web-basiertes Adreßbuch anzulegen!

Datenbank ohne Datenbank

Nun hat nicht jeder hat eine Datenbank zur Verfügung -- sei es, daß man aus purer Faulheit keine installieren will oder deshalb, weil der Internetprovider einfach keine anbietet. Andererseits sollte jede Applikation, die mit Daten jongliert, Datenbank-tauglich angelegt sein, man weiß schließlich nie, ob die Datenbestände nicht wider Erwarten doch plötzlich sprungartig wachsen und man schnellstens in die Arme von Mama Oracle oder einer Ihrer Kolleginnen springen möchte. Schön, wenn man dann nur eine Zeile im Skript ändern muß, die den richtigen Treiber installiert und die restliche Applikation mitsamt den SQL-Abfragen gleichbleibt.

Perls generische Datenbankschnittstelle DBI kann man seit neuestem auch mit einem Treiber für ordinäre Dateien ansteuern -- statt mit einer Datenbank zu kommunizieren, verwaltet das DBD::File-Modul lesbare Dateien mit Komma-separierten Einträgen, die man mit SQL-Zugriffen traktieren darf. Hinweise zur Installation des Treibers und einiger abhängiger Module, sowie zur Aktivierung des vorgestellten Skripts addr.pl finden sich im Abschnitt ``Installation''.

Abbildung 1 zeigt das Eingangsformular, das das CGI-Skript addr.pl, wenn es einmal in cgi-bin installiert und initialisiert ist, beim ersten Aufruf in den Browser zaubert: Zunächst zeigt addr.pl keinerlei Daten an (auch wenn die Tabelle schon mit Einträgen gefüllt wäre), sondern nur eine Liste der Buchstaben des Alphabets, deren jeder mit einem Link verknüpft ist, der addr.pl nach einem Eintrag suchen läßt, dessen Vorname oder Nachname mit dem betreffenden Buchstaben anfängt. Ein Klick auf den Alle Einträge-Link zeigt das ganze Notizbuch an.

Weiter wird ein Suchfeld angezeigt, das Suchstrings entgegennimmt. Falls Suche starten gedrückt wird, sucht addr.pl ebenfalls in den Vorname/Nachname-Feldern der Tabelle nach Übereinstimmungen und zeigt die Ergebnisse in einer Liste an. Klickt der Benutzer auf den Neuer Eintrag-Knopf, wird ein Formular nach Abbildung 2 angezeigt. Der nach dem Ausfüllen der Felder gedrückter Speichern-Knopf läßt die Daten in die Datenbank wandern.

Die Einträge in der Adreßbuchdatei liegen bei dem im Skript verwendeten DBD::File-Treiber in der Datei addressbook/addressbook unterhalb des CGI-Verzeichnisses, der Inhalt sieht etwa folgendermaßen aus:

    id,fname,lname,phone,email,addr,notes
    9214031581423,Freddy,Holler,089/1234567,holler@aol.com,
    "Bon-Scott-Weg 3, 89834 Zuffenhausen",
    "Alte Email: fredy@aol.com"
    9214038931493,Herbert,Rigatoni,08234/234435,
    herbertR@yahoo.com,"In der Grube 24, 82342 Kutzenbach",
    "Geburtstag: 1.3."

Die erste Zeile legt hierbei die Spaltennamen fest. Sie lauten genauso wie die später verwendeten CGI-Parameternamen, das erspart Kopfschmerzen bei der Programmierung. fname steht dabei für den Vornamen (First Name), lname für den Nachnamen (Last Name) usw.

Einträge in den Datenzeilen werden durch Kommata getrennt. Einträge, die Leerzeichen enthalten, werden in doppelte Anführungszeichen eingeschlossen und eventuelle Sonderzeichen (doppelte Anführungszeichen und Kommata) entsprechend maskiert.

Die erste Spalte (id) jeder Zeile weist dem Eintrag eine eindeutige ID zu. Sie setzt sich zusammen aus der gegenwärtigen Uhrzeit (Rückgabewert des time()-Kommandos) und der PID des aktuellen CGI-Prozesses beim Anlegen des Eintrags.

Suchen und Finden

Eine gefüllte Datenbank liefert Ergebnisse auf Suchabfragen in Tabellenform nach Abbildung 3. Das Namensfeld jedes Eintrags ist mit einem Link unterlegt, klickt man darauf, springt der Browser auf die Editierseite und füllt die Felder dort gleich mit den Daten des ausgewählten Eintrags. Der Speichern-Knopf dort aktualisiert den Datenbank-Eintrag entsprechend den Formularfeldern, ein Druck auf den Delete-Knopf löscht den Eintrag.

Was addr.pl im einzelnen tut, ob es ein Eingabeformular darstellt oder einen neuen Eintrag anlegt oder das Ergebnis einer Suchanfrage anzeigt, bestimmen die CGI-Parameter mit denen es aufgerufen wird. Folgende Szenarien steuert addr.pl:

Datenbanktabelle initialisieren
    addr.pl init=1

Dieser Aufruf erzeugt die Datenbank-Tabelle mit einem CREATE-Kommando aus dem SQL-Fundus.

Suchabfrage mit Ergebnisanzeige
    addr.pl search=A

Die Such-Abfrage fördert Einträge hervor, deren Vorname oder Nachname mit A angehen und zeigt die Ergebnisse in einer Tabelle an. Wird der search-Parameter leergelassen (aber dennoch definiert mit search=), zeigt addr.pl eine vollständige Liste aller bestehenden Einträge in einer Tabelle an.

Formular zur Eingabe eines neuen Eintrags anzeigen
    addr.pl edit=1
Formular zur Aktualisierung eines bestehenden Eintrags anzeigen
    addr.pl edit=1 id=9214031581423

Jeder Tabelleneintrag enthält eine eindeutige ID, sodaß addr.pl einmal gefundene Zeilen beim nächsten Aufruf schnell identifizieren und Manipulationen vornehmen kann (update, delete).

Neuen Eintrag aus den ausgefüllten Formularfeldern generieren
    addr.pl insert=<gesetzt> fname=... lname=...
Eintrag mit den ausgefüllten Formularfeldern aktualisieren
    addr.pl insert=<gesetzt> id=9214031581423 fname=... lname=...
Eintrag löschen
    addr.pl delete=<gesetzt> id=9214031581423

addr.pl im Detail

Listing addr.pl zeigt die Implementierung des Web-Adreßbuches. Zeile 7 holt das CGI-Modul, die angegebenen Tags lassen es die Standard-HTML- und die Tabellen-Funktionen exportieren. Das CGI::Carp-Modul sorgt dafür, daß der Browser bei auftretenden Fehlern nicht das blöde Internal Server Error anzeigt, sondern einen aufschlußreiche Fehlermeldung.

Die Zeilen 11 bis 14 spezifizieren die Parameter für den DBI-Flatfile-Treiber. $DB_DIR gibt das Verzeichnis unterhalb des cgi-bin-Verzeichnisses an, das die Tabellendaten als Datei enthält.

Zeile 20 nimmt die Verbindung mit der 'virtuellen' Datenbank auf, die Zeilen 23 und 24 geben den CGI-Header und die Überschrift aus, die in jedem Fall im Dokument steht und färben den Hintergrund der Seite weiß ein. Dann scheiden sich die Wege: Der if-Block ab Zeile 26 wird angesprungen, falls ein Benutzer die Formularfelder für einen neuen Eintrag ausgefüllt und den Speichern-Knopf gedrückt hat. Der map-Befehl in Zeile 28 übergibt der insert_rec-Funktion, die die eigentliche Datenbank-Aktualisierung vornimmt, die Formulardaten, indem er für alle Elemente in @dbfields die param-Funktion des CGI-Moduls aufruft und so die entsprechenden CGI-Parameter entgegennimmt und weiterreicht.

insert_rec selbst steht ab Zeile 158 in addr.pl, nimmt das hereingereichete DB-Handle und die Formularparameter entgegen und setzt den SQL-Insert/Update-Befehl an die Datenbank ab. Ist der CGI-Parameter id gesetzt, handelt es sich um eine Aktualisierung eines bestehenden Records und Zeile 167 definiert einen SQL-Update-Befehl. Hier wie auch an anderen Stellen leistet der qq-Operator, der mehrzeilige Strings mit doppelten Anführungszeichen umschließt, nützliche Dienste.

Fehlt andererseits id, handelt es sich um einen neuen Eintrag und Zeile 180 kreiert einen SQL-Insert-Befehl.

Zurück zur Hauptschleife: Die page_header-Funktion, die in Zeile 29 aufgerufen wird und ab Zeile 99 implementiert ist, klatscht das kleine Link-Alphabet, das in den Abbildungen 1 und 3 jeweils oben im Fenster zu sehen ist, dorthin und schreibt auch noch das Such-Feld samt den zwei Buttons auf die Seite. Die url()-Funktion aus dem CGI-Modul liefert hierzu den URL des gegenwärtig laufenden Skripts.

Ab Zeile 31 steht der Code zum Löschen eines Eintrags. Jede Zeile in der Datenbanktabelle enthält neben den Adreßbuchdaten auch noch eine eindeutige ID, die als verstecktes (hidden) Feld auf der Seite steht, die die Formularfelder zum Aktualisieren eines Eintrags darstellt. Drückt der Benutzer auf den Knopf Eintrag löschen, sendet der Browser neben den aktualisierten Feldern auch noch die ID mit und addr.pl kann einen DELETE-Befehl losschicken, der mit seinem Tintenkiller genau über die richtige Zeile der Tabelle fährt.

Drückte der Benutzer entweder auf den Knopf Neuer Eintrag oder aber auf einen erleuchteten Namen der der Ergebnisliste, wird der Codeblock ab Zeile 40 angesprungen, da der Parameter edit in diesen Fällen gesetzt ist.

Diese zwei Fälle unterscheiden sich dahingehend, daß ein angeklickter Eintrag der Ergebnisliste den id-Parameter setzt. In diesem Fall muß addr.pl vor dem Darstellen der Felder die Werte aus der Datenbank übernehmen. Hierzu erzeugt es in Zeile 44 einen SQL-Select-Befehl, der die Daten aus der Datenbank holt. Die Zeilen 51 bis 56 holen das Ergebnis des Queries ab, wegen der eindeutigen ID im SELECT ist das Ergebnis stets eine einzelne Zeile. Die fetch-Methode im while-Kopf liefert eine Referenz auf einen Array zurück, dessen Elemente die Spaltenwerte des Tabelleneintrags beinhalten. Da die Tabellenzeile zusätzlich zu den in @dbfields aufgelisteten Spalten als erstes Element die id-Spalte führt, startet der Index $i in Zeile 52 mit dem Wert 1 statt des sonst üblichen 0 .

Der Aufruf der param-Methode in Zeile 54 manipuliert die CGI-Eingangsparameter und gaukelt den nachfolgenden Abfragen vor, der Benutzer hätte die Adreßdaten des selektierten Eintrags selbst eingetragen -- derweil stammen sie aus der Datenbank.

Die Zeilen 59 bis 80 geben eine zweispaltige HTML-Tabelle aus, die das Formular zum anlegen/editieren eines Adreßeintrags nach Abbildung 2 in den Browser zeichnet.

Für den Fall, daß der Benutzer eine Suchanfrage startete oder einen Buchstaben im Reiter-Alphabet des Seitenkopfes anklickte, ist der CGI-Parameter search gesetzt, entsprechend springt addr.pl den Block ab Zeile 82 an. Für den Buchstabenklick enthält search den entsprechenden Buchstaben, wurde etwas ins Suchfeld eingetragen und der Suche starten-Knopf gedrückt, steht in search der Suchbegriff. Die Funktion keyword_search übernimmt in beiden Fällen die Suche, sie ist ab Zeile 118 definiert. Dort holt eine SQL-Abfrage passende Records aus der Datenbank, indem sie mittels des CLIKE-Konstrukts überprüft, ob Vor- oder Nachname eines Eintrags mit dem gegebenen Suchausdruck beginnen, Groß- und Kleinschreibung werden ignoriert. Für einen leeren Suchstring liefert keyword_search großzügigerweise einfach alle Einträge der Tabelle.

Die while-Schleife ab Zeile 145 gibt die Treffer in einer HTML-Tabelle aus, indem sie Vor- und Nachnamen zu einer Tabellenspalte zusammenfaßt und einen HTML-Link darumherum baut, der die CGI-Parameter edit auf 1 und id auf die in der Datenbank gefundene ID des Eintrags setzt, sodaß das Skript bei einem Klick auf den Eintrag sofort den Eintrag in der Datenbank referenzieren kann.

Der Block ab Zeile 86 kommt nur bei der Installation des Skripts kurz zum Einsatz und ruft die Initialisierungsfunktion init_db auf, die ab Zeile 190 definiert ist, und das Unterverzeichnis der Pseudo-Datenbank erzeugt. Weiter setzt sie einen SQL-Create-Befehl ab, der die Pseudo-Tabelle anlegt.

Ist überhaupt kein CGI-Parameter gesetzt (wie beim ersten Aufruf des Skripts), kommt Zeile 90 zum Einsatz und zeichnet lediglich den Seitenkopf mit dem Suchfeld und dem Reiteralphabet.

Abb.1: Eingangsformular des Adreßbuches

Abb.2: Ein neuer Eintrag wandert ins Notizbuch

Abb.3: Auflistung der Suchergebnisse zum Buchstaben 'H'

Listing addr.pl

    001 #!/usr/bin/perl -w
    002 ##################################################
    003 # CGI Address Book
    004 # 1999, mschilli@perlmeister.com
    005 ##################################################
    006 
    007 use CGI qw/:standard :html3/;
    008 use CGI::Carp qw/fatalsToBrowser/;
    009 use DBI;
    010 
    011 my $DB_DIR      = "./addressbook";
    012 my $DB_DSN      = "DBI:CSV:f_dir=$DB_DIR";
    013 my $DB_USER     = "";
    014 my $DB_PASSWD   = "";
    015 
    016 my @dbfields = qw/fname lname phone email addr 
    017                   notes/;
    018 my $dbflist  = join(', ', @dbfields);
    019 
    020 my $dbh = DBI->connect($DB_DSN, $DB_USER, 
    021    $DB_PASSWD) or die "Cannot connect to DB";
    022 
    023 print header(), start_html(-BGCOLOR => 'white'),
    024       h1("Adreßbuch");
    025 
    026 if(param('insert')) {
    027     # Insert/Update record according to form fields
    028     insert_rec($dbh, map { $dbh->quote(param($_)) } @dbfields);
    029     page_header();
    030 
    031 } elsif(param('delete')) {
    032     # Delete a record according to ID field
    033     my $id = $dbh->quote(param('id'));
    034     $dbh->do(<<EOT) or die "Cannot delete data";
    035         DELETE FROM addressbook
    036         WHERE id = $id 
    037 EOT
    038     page_header();
    039 
    040 } elsif(param('edit')) {
    041     # Display fields for inserting/updating a rec
    042     if(param('id')) {
    043         $id = $dbh->quote(param('id'));
    044         # ID exists - Get record and preset fields
    045         my $sql = qq[SELECT id, $dbflist
    046                      FROM addressbook
    047                      WHERE id = $id];
    048         my $cursor = $dbh->prepare($sql) or 
    049                      die "Cannot select ($sql)";
    050         $cursor->execute() or die "SQL failed";
    051 
    052         while(defined($row = $cursor->fetch)) {
    053             my $i = 1;
    054             foreach $field (@dbfields) {
    055                 param($field, $row->[$i++]);
    056             }
    057         }
    058     }
    059 
    060     print start_form(), 
    061       hidden(-name => 'id'),
    062       table({"border" => 1},
    063       TR(td("Vorname:"), 
    064          td(textfield(-name => 'fname'))),
    065       TR(td("Nachname:"), 
    066          td(textfield(-name => 'lname'))),
    067       TR(td("Telefon:"), 
    068          td(textfield(-name => 'phone'))),
    069       TR(td("Email:"), 
    070          td(textfield(-name => 'email'))),
    071       TR(td("Adresse:"), 
    072          td(textarea(-name => 'addr', -rows => 3))),
    073       TR(td("Notizen:"), 
    074          td(textarea(-name => 'notes', -rows => 3))),
    075       );
    076 
    077     print submit(-name  => 'insert', 
    078                  -value => 'Speichern'), 
    079           submit(-name  => 'delete', 
    080                  -value => 'Eintrag löschen'), 
    081           end_form();
    082 
    083 } elsif(defined param('search')) {
    084     page_header();
    085     keyword_search($dbh, param('search'));
    086 
    087 } elsif(param('init')) {
    088     page_header();
    089     init_db($dbh);
    090 
    091 } else {
    092     page_header();
    093 }
    094 
    095 print end_html();
    096 
    097 $dbh->disconnect();  # Datenbankverbindung lösen.
    098 
    099 ##################################################
    100 sub page_header {
    101 ##################################################
    102     print start_form();
    103     foreach $letter ('A'..'Z') {
    104         print a({href => url() . 
    105           "?search=$letter"}, "$letter ");
    106     }
    107     print a({href => url() .  "?search="}, 
    108             "&nbsp;Alle Einträge"),
    109           p("Suchbegriff:", 
    110             textfield(-name => 'search'), 
    111             submit(-name => 'Search', 
    112                    -value => 'Suche starten'),
    113             submit(-name => 'edit', 
    114                    -value => 'Neuer Eintrag'));
    115     print end_form();
    116 }
    117 
    118 ##################################################
    119 sub keyword_search {
    120 ##################################################
    121     my ($dbh, $keyword) = @_;
    122     my $cursor; 
    123     my $where_clause = "";
    124 
    125     if($keyword ne "") {
    126         $keyword = $dbh->quote("$keyword%");
    127         $where_clause = qq[
    128             WHERE fname CLIKE $keyword OR
    129                   lname CLIKE $keyword];
    130     }
    131 
    132     my $sql = qq[ SELECT id, $dbflist
    133                   FROM addressbook
    134                   $where_clause
    135                   ORDER BY lname];
    136 
    137     $cursor = $dbh->prepare($sql) or 
    138               die "Select failed: $sql";
    139 
    140     $cursor->execute() or 
    141         die "Can't execute ($sql): ", $cursor->errstr;
    142 
    143     print "<TABLE BORDER=1>\n";
    144     print TR(map { th($_) } 
    145       qw/Name Telefon Email Adresse Notizen/);
    146 
    147     while(defined(my $row = $cursor->fetch)) {
    148         print TR(td(
    149         a({href => url() . "?id=$row->[0]&edit=1"}, 
    150           "$row->[2], $row->[1]"), 
    151         td("$row->[3]"),
    152         td("$row->[4]"), td("$row->[5]"),
    153         td("$row->[6]"),
    154         )), "\n";
    155     }
    156     print "</TABLE>\n";
    157 }
    158 
    159 ##################################################
    160 sub insert_rec {
    161 ##################################################
    162     my($dbh, $fname, $lname, $phone,
    163        $email, $addr, $notes) = @_;
    164 
    165     if(param('id')) {
    166         # ID there, it's an update!
    167         my $id = $dbh->quote(param('id'));
    168 
    169         my $sql = qq[ 
    170           UPDATE addressbook
    171           SET id=$id, fname=$fname, 
    172               lname=$lname, phone=$phone, 
    173               email=$email, notes=$notes
    174           WHERE id = $id];
    175 
    176         $dbh->do($sql) or die "Update failed ($sql)";
    177 
    178     } else {
    179         # ID not there, it's a new record!
    180         my $id = time . $$;  # Generate ID
    181         $id = $dbh->quote($id);
    182 
    183         my $sql = qq[
    184           INSERT INTO addressbook
    185           (id, $dbflist)
    186           VALUES ($id, $fname, $lname, $phone, 
    187                   $email, $addr, $notes)];
    188         $dbh->do($sql) or die "Insert failed ($sql)";
    189     }
    190 }
    191 
    192 ##################################################
    193 sub init_db {
    194 ##################################################
    195     my $dbh = shift;
    196 
    197     if(! -d $DB_DIR) {
    198         mkdir($DB_DIR, 0755) || 
    199             die "Cannot create dir $DB_DIR";
    200     }
    201 
    202     $dbh->do(<<'EOT') or die "Cannot create table";
    203         CREATE TABLE addressbook (
    204             id     char(20),
    205             fname  char(40),  lname char(40), 
    206             phone  char(20),  email char(40), 
    207             addr   char(100), notes char(100)
    208         )
    209 EOT
    210 }

Installation

Um die Komma-separierten Einträge in der Datenbank-Datei lesen zu können, braucht das DBD::File-Modul, das die Flatfile-Datenbank realisiert, zunächst das Modul Text::CSV_XS. Mit SQL::Statement kommt dann ein kleines SQL-Maschinchen hinzu und mit diesen beiden arbeitet dann DBD::FILE. Das DBI-Modul und das außerdem verwendete Modul CGI liegen standardmäßig Perl 5.005 bei. Alle Module stehen auf dem CPAN zur Verfügung, mit

    perl -MCPAN -eshell
    > install Text::CSV_XS
    > install SQL::Statement
    > install DBD::File

kriegt Perl 5.005 den letzten Schliff und es kann losgehen.

Anschließend muß das vorgestellte Skript addr.pl ausführbar ins cgi-bin-Verzeichnis des Webservers gestellt und

    http://localhost/cgi-bin/addr.pl?init=1

aufgerufen werden, das erzeugt ein Unterverzeichnis und die Datenbankdatei. Klappt das nicht, muß entweder das Unterverzeichnis geändert (Zeile 11) oder manuell angelegt und für den Benutzer, unter dem der Webserver läuft (meist nobody) ausführbar gemacht werden.

Danach sollte ein

    http://localhost/cgi-bin/addr.pl

die Eingangsseite hervorzaubern, ein Klick auf den ``Insert new Record''-Button zeigt das Formular an, das zur Eingabe des ersten Eintrags einlädt.

Da das Format der Datenbankdatei lesbar ist, können natürlich auch alte Datenbestände einfach importiert werden. Eine Einschränkung muß jedoch erwähnt werden: Noch sind keine SQL-Joins möglich, aber das sollte in naher Zukunft auch möglich sein.

Epilog

Gerade habe ich im Duden nachgelesen, daß man Adreßbuch nach den neuen Rechtschreibregeln Adressbuch schreibt. Was ein Elend, ich hoffe es gildet trotzdem!

Literatur

[1]
Official Guide to MiniSQL 2.0, Brian Jepson, David J. Hughes, John Wiley & Sons, Inc. 1998, ISBN 0-471-24535-6

[2]
Gebunkert -- Datenbankbedienung mit Perl und CGI, iX 08/97, Michael Schilli, http://www.heise.de/ix/artikel/1997/08/150/artikel.html

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.