Aus Webformularen an den Server gesandte Benutzerdaten landen meist in Datenbanken oder zunächst in kommaseparierte Dateizeilen, um sie später mit Tabellenkalkulationen weiterzuverarbeiten. Das heutige Skript löst den Allgemeinfall.
Es gibt Aufgaben, die kehren mit schöner Regelmäßigkeit wieder. Vom Benutzer in ein Webformular eingegebene Daten auf dem Server abzuspeichern, ist eine solche. Meist kommt ein schnell zusammengewürfeltes Perl-Skript zum Einsatz -- wie wär's heute mal mit einer allgemeinen Lösung, die man nur noch entsprechend konfigurieren muss, damit sie entweder kommaseparierte Dateizeilen oder Datenbankeinträge erzeugt?
Abbildung 1 zeigt ein typisches Webformular. Neben dem ins Textfeld
eingetragenen String nimmt es die Werte der vom Benutzer ausgewählten
Druck-, Radioknöpfe und Auswahllisten entgegen.
Klickt der Benutzer auf den Absenden-Knopf, schickt der Browser
die Daten an unser heute vorgestelltes CGI-Skript handleform,
welches die Daten entgegennimmt und abspeichert. Gelingt dies,
sendet es einen Redirect zum Browser zurück, der daraufhin
zu einer Dankeschön-Seite verzweigt (/thankyou.html). Geht etwas
schief, hält handleform den Fehler für den Benutzer unsichtbar
in einer Logdatei fest und verzweigt zu einer Fehlerseite
/error.html.
Listing 1 zeigt den zugehörigen HTML-Sourcecode. Soweit nichts neues.
|
| Abbildung 1: Webformular im Browser. Die eingegebenen Benutzerdaten wandern per CGI-Skript in die Datenbank. |
01 <HTML>
02 <FORM ACTION=/cgi-bin/handleform>
03
04 <B>Name:</B>
05 <INPUT TYPE=text NAME=name>
06
07 <BR><B>Hobbies:</B>
08 <INPUT TYPE=checkbox NAME=hobbies
09 VALUE=radeln>
10 Radeln
11 <INPUT TYPE=checkbox NAME=hobbies
12 VALUE=lesen>
13 Lesen
14
15 <BR><B>Glück in</B>
16 <INPUT TYPE=radio NAME=glueck VALUE=spiel>
17 Spiel
18 <INPUT TYPE=radio NAME=glueck VALUE=liebe>
19 Liebe
20
21 <BR><B>Einkommen</B>
22 <SELECT NAME=einkommen>
23 <OPTION VALUE=e1>Unter 100.000</OPTION>
24 <OPTION VALUE=e2>Über 100.000</OPTION>
25 </SELECT>
26
27 <BR><INPUT TYPE=SUBMIT VALUE=Absenden>
28
29 </FORM>
30 </HTML>
Neu hingegen ist, dass das Skript
handleform sich in seiner Konfigurationssektion
ab Zeile 14 auf beliebige Webformulare anpassen lässt.
Die Namen der HTML-Eingabelemente legt der ab Zeile 34 definierte
@FIELDS-Array fest. Ist ein zugehöriger Wert nicht sehr
aussagekräftig (wie zum Beispiel e1 und e2 für die beiden
zulässigen Werte der Auswahlliste einkommen), darf der
%MAP-Hash ab Zeile 38 unter dem Schlüssel des HTML-Elements (einkommen)
jeweils einen Hash ablegen, der den Kürzeln aussagekräftigere
Begriffe zuordnet (z.B. e1 => "Unter 100.000").
Wie abgedruckt schreibt das Skript die Daten jeder Serveranfrage kommasepariert in die nächste Zeile einer Datei, an die stetig angehängt wird. Abbildung 2 zeigt die CSV-Datei, nachdem sich zwei Benutzer eingetragen haben. Werte, die Leerzeichen oder Kommata enthalten, umrandet der CSV-Treiber mit doppelten Anführungszeichen. Käme in einem der Werte ein doppeltes Anführungszeichen vor, würde es aufgedoppelt.
Kommentiert man allerdings die Zeilen 19
und 20 aus, und aktiviert statt dessen 23 bis 25, kontaktiert
handleform eine auf dem aktuellen Rechner laufende MySQL-Datenbank.
Abbildung 3 zeigt die mit zwei Einträgen gefüllte Tabelle
survey der Datenbank webdata. Die schon in [2] besprochene
DBI-Schnittstelle macht's möglich, eine CSV-Datei genau wie eine
'richtige' Datenbank mit SQL-Befehlen anzusteuern (siehe auch [5]).
|
| Abbildung 2: Ergebnis als kommaseparierte Spalten in einer CSV-Datei. |
|
| Abbildung 3: ... oder als Tabellenzeilen in einer MySQL-Datenbank. |
Neben
dem praktischen CGI-Modul zum schnellen Erfassen der Eingabeparameter
und DBI für Datenbank- und CSV-Schnittstelle nutzt handleform
auch Log::Log4perl, ein neuartiges Logmodul, das unter
[3] und [4] ausführlich beschrieben steht. CGI-Skripts sollen
schließlich nur generische Fehlermeldungen bringen und den Benutzer
nicht mit Details belästigen. Intern, in einer nur dem Systemadministrator
zugänglichen Datei, hilft hingegen eine möglichst detaillierte
Spurensicherung.
Der init-Befehl ab Zeile 44 konfiguriert den Logger mittels der
aus der Java-Welt stammenden log4j-Sprache dahingehend,
nur Mitteilungen der Priorität WARN oder höher an eine
Logdatei anzuhängen, deren Pfad in Zeile 49 als /tmp/hf.log
definiert wird. Als Logformat legt Zeile 51
Datum Priorität Source-Datei (Zeile) Nachricht
fest. Im Falle einer nicht beschreibbaren CSV-Datei steht da zum Beispiel
2002/08/25 18:57:24 FATAL eg/handleform.csv (60) \
/tmp/data missing/protected at eg/handleform line 138.
während der Browser nur /error.html anzeigt, wo etwas Unverfängliches
wie ``Wegen Wartungsarbeiten vorübergehend geschlossen'' steht.
Zeile 57 holt eine Logger-Instanz, die unter anderem
in Zeile 60, innerhalb eines Pseudo-Signal-Handlers, der
alle die()-Anweisungen des Skripts abfängt, zum Einsatz kommt.
Die fatal()-Methode setzt dort eine Lognachricht der Priorität FATAL
(höher als WARN) an die Logdatei ab.
Nachdem die Nachricht verstaut ist, sorgt
der print-Befehl in Zeile 61 dafür, dass das CGI-Skript
für den Browser eine Redirekt-Anweisung zur Fehlerseite ausgibt
und sich beendet.
Das ist praktisch, denn wann immer im Skript ein Fehler passiert,
rufen wir einfach die() auf, was wegen des Pseudo-Signal-Handlers
in $SIG{__DIE__})
niemals das unschöne Internal Server Error auslöst, sondern
den Fehler in der Logdatei
protokolliert und dem Benutzer /error.html vorlegt.
Die for-Schleife ab Zeile 66 iteriert über alle zugelassenen
Namen für eingehende Parameter und ruft für jeden einzelnen die
param()-Funktion des CGI-Moduls auf, um herauszufinden,
ob dieser auch tatsächlich vorliegt.
CGI-Parameter können auch multiple Werte führen. Stehen in einem
Formular beispielsweise zwei Checkboxen
mit den Werten radeln bzw. lesen, die beide auf den gleichen Namen
hobbies hören, darf der Benutzer beide gleichzeitig auswählen.
In diesem Fall gibt param('hobbies') keinen Einzelwert, sondern
eine Liste mit den Werten radeln und lesen zurück.
Zeile 77 macht daraus radeln|lesen, was hinterher auch
so in der Datenbank liegt. Falls für den Parameter eine
Transformationsanweisung im Hash %MAP vorliegt, nimmt
Zeile 72 diese vor.
init_db() in Zeile 81 ruft die ab Zeile 115 definierte
gleichnamige Funktion
auf, die feststellt, ob die entsprechende Datenbank schon besteht.
Die data_sources() Methode liefert die Namen bestehender
Datenbanken und falls Zeile 123 den in Zeile 16 konfigurierten
Namen schon findet, kehrt
init_db() zurück.
Falls nicht, werden die nötige Schritte eingeleitet, um entweder
die CSV-Datei anzulegen oder
die MySQL-Datenbank zu initialisieren. Im ersten Fall ist nichts
erforderlich, da der CSV-Treiber das Konzept einer virtuellen
``Datenbank'' nicht
kennt. Im Fall von MySQL sorgt der createdb-Aufruf dafür,
dass eine neue, leere Datenbank angelegt wird.
Zeile 83 nimmt Verbindung mit dem generischen Datenbanktreiber auf, hinter dem, je nach Konfiguration, statt eines MySQL-Hobels auch eine nur simple CSV-Datei hängen kann.
init_table() in Zeile 87 ruft die ab Zeile 133 definierte Funktion
auf, die mittels der tables()-Methode des Datenbankhandles
herauszufinden versucht, ob bereits eine entsprechende Tabelle
(in Wahrheit: wirkliche Tabelle oder CSV-Datei) existiert.
Endet einer der Einträge mit dem in Zeile 16 definierten Tabellennamen,
bricht Zeile 144 ab und kehrt zum Hauptprogramm zurück, da die
Tabelle offensichtlich schon existiert.
Wurde hingegen der CSV-Treiber installiert, prüft Zeile 139,
ob das Datenbankverzeichnis $DB_DIR existiert und für den
Webserver-Benutzer (im allgemeinen nobody) zum
Schreiben und Ausführen offen steht.
Falls nicht, schustert Zeile 151 einen SQL-CREATE-Befehl zusammen,
der, über die DBI-Schnittstelle abgeschickt, je nach
Konfiguration eine Tabelle oder eine CSV-Datei erzeugt.
Zu den in @FIELDS definierten Parameternamen kommt
als erste Spalte noch i_date hinzu, die das mit nicedate()
(ab Zeile 104 definiert)
schön formattierte Einfügedatum jedes Eintrags angibt.
Zeile 148 macht alle Spalten der Einfachheit halber vom
Typ VARCHAR(50), die einfach bis zu 50 Zeichen breite Strings aufnehmen.
Zeile 96 definiert den SQL-Befehl, der den neuen Datensatz abspeichert.
Wie in [2] gezeigt, müssen die Werte gegebenenfalls mit quote()
maskiert werden.
Geht alles gut, dirigiert Zeile 101 den Browser zur ``Dankeschön''-Seite
/thankyou.html.
Existiert Datenbank oder Tabelle noch nicht, legt handleform
sie wie gerade gesehen an.
Dies sollte keinesfalls unter Produktionsbedingungen
geschehen, sondern als Bequemlichkeitsfunktion beim ersten
Testaufruf verstanden werden. Kommen sich dabei nämlich
mehrere parallele Prozesse in die Quere, kann's rappeln.
Ist die Datenbank oder die CSV-Datei hingegen einmal angelegt,
sorgen die DBI-Treiber dafür, dass es zu keinen Überlappungen
kommt.
Da SQL keine Spaltennamen duldet, die mit SQL-Schlüsselworten
gleichlauten, verbieten sich im HTML-Formular Feldnamen wie
select, create, insert, aber auch option. Die
Logdatei zeigt solche Fehler jedoch sofort an.
handleform verarbeitet getreu der Devise ``Never trust the user''
nur die in Array @FIELDS definierten Felder. Sendet ein Böswilliger
neue Feldnamen zum Server, werden diese einfach ignoriert. Erfordert
eine Umfrage, dass der Benutzer bestimmte Felder mit einem Wert
versehen muss, löst man diese Aufgabe am besten mit JavaScript. Das
schließt zwar nicht aus, dass der Mann mit dem schwarzen Hut mittels
eines Skripts die Hürde überspringt, aber das wollen wir mal durchgehen
lassen.
Falls im Webformular neue Felder hinzukommen und die alte Datenbank
weiter genutzt werden soll, muss sie vorher um eine Spalte
erweitert werden. In MySQL geht das mit mysqladmin und dem
Befehl ALTER TABLE. In CSV fügt man einfach einen zusätzliche
Spaltennamen ans Ende der ersten Zeite der CSV-Datei ein.
001 #!/usr/bin/perl
002 ###########################################
003 # handleform -- Send FORM data to databases
004 # Mike Schilli, 2002 (m@perlmeister.com)
005 ###########################################
006 use warnings;
007 use strict;
008
009 use CGI qw(:all);
010 use DBI;
011 use Log::Log4perl qw(get_logger);
012
013 ###########################################
014 my $DB_DIR = "/tmp/data";
015 my $DB_HOST = "localhost";
016 my $DB_NAME = "webdata";
017
018 # CSV-File
019 my $DB_DRIVER = "CSV";
020 my $DB_PAR = "f_dir=$DB_DIR";
021
022 # MySQL database
023 #my $DB_DRIVER = "mysql";
024 #my $DB_PAR = "database=$DB_NAME;" .
025 # "host=$DB_HOST";
026
027 my $DB_USER = "root";
028 my $DB_PASSWD = "";
029 my $DB_TABLE = "survey";
030
031 my $THANK_YOU = "/thankyou.html";
032 my $ERROR = "/error.html";
033
034 my @FIELDS = qw(
035 name hobbies glueck einkommen
036 );
037
038 my %MAP = (
039 einkommen => { e1 => "Unter 100.000",
040 e2 => "Über 100.000" },
041 );
042 ###########################################
043
044 Log::Log4perl::init(\ <<'EOT');
045 Log4perl.logger = WARN, File
046 Log4perl.appender.File= Log::Dispatch::File
047 Log4perl.appender.File.layout=\
048 Log::Log4perl::Layout::PatternLayout
049 Log4perl.appender.File.filename=/tmp/hf.log
050 Log4perl.appender.File.layout.Conversion\
051 Pattern=%d %p %F (%L) %m %n
052 EOT
053
054 my $DB_DSN = "DBI:$DB_DRIVER:$DB_PAR";
055 my $DATE = "i_date";
056
057 my $logger = Log::Log4perl::get_logger();
058
059 $SIG{__DIE__} = sub {
060 $logger->fatal(@_);
061 print redirect($ERROR);
062 exit 0 };
063
064 my %val = ();
065
066 for my $field (@FIELDS) {
067 if(defined param($field)) {
068 my @v;
069 for(param($field)) {
070 if(exists $MAP{$field} and
071 exists $MAP{$field}->{$_}) {
072 push @v, $MAP{$field}->{$_};
073 } else {
074 push @v, $_;
075 }
076 }
077 $val{$field} = join '|', @v;
078 }
079 }
080
081 init_db();
082
083 my $dbh = DBI->connect($DB_DSN, $DB_USER,
084 $DB_PASSWD, { RaiseError => 1 } ) or
085 die "Cannot connect to DB";
086
087 init_table($dbh);
088
089 unshift @FIELDS, $DATE;
090 $val{$DATE} = nicedate();
091
092 my $fieldlist = join(",", @FIELDS);
093 my $valuelist = join(",",
094 map { $dbh->quote($val{$_}) } @FIELDS);
095
096 my $sql = qq[
097 INSERT INTO $DB_TABLE ( $fieldlist )
098 VALUES ( $valuelist ) ];
099 my $sth = $dbh->do($sql);
100
101 print redirect($THANK_YOU);
102
103 ###########################################
104 sub nicedate {
105 ###########################################
106
107 my ($s,$mi,$h,$d,$mo,$y) = localtime();
108
109 return sprintf(
110 "%02d-%02d-%d %02d:%02d:%02d",
111 $mo+1, $d, $y+1900, $h, $mi, $s);
112 }
113
114 ###########################################
115 sub init_db {
116 ###########################################
117
118 my($drh) = DBI->install_driver(
119 $DB_DRIVER);
120 my @dbs = $drh->data_sources(
121 { 'f_dir' => $DB_DIR } );
122 @dbs = () unless defined $dbs[0];
123 return if grep { /\b$DB_NAME/ } @dbs;
124
125 return if $DB_DRIVER eq "CSV";
126
127 $drh->func("createdb", $DB_NAME,
128 $DB_HOST, $DB_USER, $DB_PASSWD,
129 "admin");
130 }
131
132 ###########################################
133 sub init_table {
134 ###########################################
135 my $dbh = shift;
136
137 if($DB_DRIVER eq "CSV") {
138 die "$DB_DIR missing/protected" if
139 !-d $DB_DIR or !-w _ or !-x _;
140 }
141
142 my @tables = $dbh->tables();
143
144 return if grep {
145 $_ =~ /\b$DB_TABLE$/ } $dbh->tables();
146
147 my $defs = join ",", map {
148 "$_ VARCHAR(50)" } $DATE, @FIELDS;
149
150 $dbh->do(qq[
151 CREATE TABLE $DB_TABLE ( $defs ) ]);
152 }
Aus dem verwendeten Webformular sind zunächst die Parameternamen
zu extrahieren und Zeile 35 von handleform entsprechend anzupassen.
Sollen einige Parameter unter unterschiedlichen Namen abgespeichert
werden, legt dies Zeile 39 fest.
Die Wahl zwischen CSV und MySQL erfolgt durch aus- bzw. entkommentieren
der Zeilen 19-20 bzw. 23-25. Die Parameter
$DB_DIR (Verzeichnis der CSV-Datei), $DB_HOST (MySQL-Hostname),
$DB_NAME (Name der MySQL-Datenbank),
$DB_TABLE (Name der MySQL-Tabelle) sind an die lokalen Anforderungen
anzupassen.
Als Zusatzmodule finden Log::Log4perl (das wiederum Log::Dispatch
und Param::Validate braucht)
CGI, DBI, DBD::mysql (MySQL-Treiber),
und DBD:CSV (CSV-Treiber) Einsatz. Eine CPAN-Shell holt nicht nur
die Module vom Netz, sondern löst auch noch die Abhängigkeiten auf:
perl -MCPAN -eshell
cpan> install Log::Log4perl
cpan> install DBD::mysql
cpan> install DBD::CSV
handleform muss ausführbar ins cgi-bin-Verzeichnis des Webservers.
Die in den Zeilen 31 und 32 definierten relativen URLs müssen
auf gültige HTML-Seiten zeigen. Absolute URLs (http://blabla)
funktionieren natürlich auch. Die in Zeile 49 festgelegte Logdatei
muss vom Benutzer des Webservers (meist nobody) beschreibbar sein.
Gleiches gilt für die CSV-Datei und das Verzeichnis, in dem sie liegt.
Ist MySQL im Spiel, muss der Benutzernamen ($DB_USER)
und das Passwort ($DB_PASSWD) stimmen.
Gestaltet massenhaft Webumfragen!
![]() |
Michael Schilliarbeitet 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. |