Der zweite und abschließende Teil über das digitale Bildarchiv beschreibt die zum Frontend aus dem ersten Teil passende Datenbankschnittstelle. Sie benutzt die neue Perl-Wunderdroge Class::DBI.
Das Fotoarchiv-Frontend aus dem letzten Perl-Snapshot[1] archiviert digitale Fotos in speziellen Verzeichnissen auf der Festplatte und pflegt eine Datenbank mit Informationen zu den Bildern. Das Frontend benutzt dazu ein ebenfalls in Perl geschriebenes Backend, das auf die relationale Datenbank zugreift.
Für diesen Zugriff kommt das Perl-Modul Class::DBI zum Einsatz – es legt eine Objektschicht um traditionelle relationale Datenbanken. Der Programmierer navigiert damit auf Applikationsebene unbeschwert zwischen Objekten, während unten im Motorraum die SQL-Befehle hin und her flitzen. Dabei gibt sich Class::DBI offen für neue Ideen. Für den Fall, dass optimiertes SQL doch effizienter läuft als das vorgegebene Objekt-Mapping, bietet es eine Schnittstelle an, über die man problemlos SQL-Code injizieren kann.
Das Fotoarchiv-Frontend »idb« vom letzten Monat nutzt das im Folgenden vorgestellte Perl-Modul »CameraStore.pm«, um die Daten der digitalen Fotos und ihre Beschriftungen in die Datenbank zu verlagern und von dort wieder abzuholen. Es benutzt dazu die in Tabelle 1 genannten Methoden.
Im Motorraum
Zwischen Bildern und Tags besteht eine n:n-Beziehung: Einem Bild können mehrere Tags anhaften, ein Tag kann zu mehreren Bildern gehören. Das erfordert datenbanktechnisch einen Klimmzug. Gäbe es nur je eine Tabelle für Bilder und Tags, müsste die Datenbank bei jedem Tag die zugehörigen langen Bild-IDs speichern und zusätzlich bei jedem Bild die passenden Tag-Strings ablegen. Diese Redundanz verschwendet nicht nur Speicherplatz, sie führt auch zu Wartungsproblemen.
Die bessere Lösung benutzt eine Vermittlertabelle »tags«, die zwischen der Bildtabelle »images« und der Tag-String-Tabelle »categories« steht (Abbildung 1). Jede einzelne Reihe in »tags« gibt an, dass dem in der Spalte »image« eingetragenen Bild das in »category« referenzierte Tag zugeordnet ist.
Die Spalten »image« und »category« der Tabelle »tags« enthalten nur so genannte Fremdschlüssel mit den passenden Werten aus den ersten Spalten der Tabellen »images« und »categories«. Letztere sind jeweils als Primärschlüssel gesetzt und enthalten automatisch hochgezählte Sequenznummern. Die in der Tabelle »tags« definierten Felder führen also nur Fremdschlüssel, die in andere Tabellen verweisen. Sie enthalten – bis auf die eigentlich irrelevante Sequenznummer »id« – keine eigenen Daten.

Abbildung 1: Die Vermittlertabelle »tags« verbindet die Bildtabelle »images« und die Tag-String-Tabelle »categories«. Sie stellt damit eine n:n-Beziehung her: Jede Zeile in »tags« verbindet ein Bild mit einer Kategorie.
Auf der Kommandobrücke
Diese Tabellenbeziehungen lassen sich mit Class::DBI einfach modellieren. Der Programmierer muss dann nicht mehr selbst die Relationen auflösen – die Objekte verhalten sich so, als ob sie statt des Fremdschlüssels direkt die referenzierten Daten enthielten. Durch Vererbung definiert das Camera-Store-Programm dazu drei eigene Klassen für die Reihen der beteiligten Tabellen. Für die Beziehungen zwischen den Klassen nutzt es die beiden Eigenschaften »has _a()« und »has_many()« der Vaterklasse »Class::DBI«.
Enthält eine Tabellenspalte keine eigenen Daten, sondern einen Fremdschlüssel, der auf Einträge in einer anderen Tabelle verweist, dann lässt sich diese Relation über eine Class::DBI-Methode fest im Objekt verankern:
KlasseTags->has_a("category",
"KlasseCategories");
Ab diesem Zeitpunkt liefert die folgende Methode nicht mehr den Wert der Spalte »category« in » KlasseTags«, also nicht mehr den Fremdschlüssel, sondern ein Objekt der Klasse » KlasseCategories«:
KlasseTags->category();
Die Methode wählt die Zeile der Tabelle »categories«, deren Primärschlüssel zum Fremdschlüssel in der Tabelle »tags« passt. Das zurückgelieferte Objekt ist vom Typ » KlasseCategories« und enthält alle Daten der entsprechenden Zeile.
Diese Methode beschreibt die Relation in einer Richtung: von der Tabelle mit dem Fremdschlüssel zu der Tabelle, auf die sich der Fremdschlüssel bezieht. Häufig benötigt man aber die andere Richtung: Welche Einträge in einer anderen Tabelle verweisen auf eine bestimmte Zeile in der aktuellen Tabelle? Diese Beziehung lässt sich mit »has_many()« modellieren.
Beziehungskisten
In der Tabelle »tags« liegen Felder, die mit » KlasseImages« in Verbindung stehen. Das Feld »image« enthält einen Fremdschlüssel, der sich auf den Primärschlüssel in der Tabelle »images« bezieht. Davon weiß » KlasseImages« noch nichts; folgende Methode ändert das:
KlasseImages->has_many("tags",
"KlasseTags" => "image");
Damit sorgt die Vaterklasse »Class::DBI« für einige Vereinfachungen:
- Jedes » KlasseImages«-Objekt erhält eine
Methode »tags()«. Diese sucht alle Zeilen in der
Tabelle »tags«, deren
»image«-Fremdschlüssel zum
Primärschlüssel in »images« passt. Die
Treffer liefert sie als Liste von
» KlasseTags«-Objekten zurück. - Die neue Methode
» KlasseImages<$>->add_to_tags()« fügt
einen Verweis auf das aktuelle Bild als neues Element in
»KlasseTags<$>« ein. - Wenn das Programm ein » KlasseImage«-Objekt
löscht, tilgt Class::DBI automatisch auch die passenden
Einträge aus » KlasseTags«.
Listing 1 zeigt die Implementierung der objektorientierten Datenbankschnittstelle »CameraStore.pm«. Ganz unten in Zeile 202 angefangen zeigt sich, dass die Klasse »CameraStore::IDB::Tag« von »CameraStore::IDB« erbt, die wiederum von »Class::DBI« abstammt und die Beziehungen aller verwendeten Tabellen zur Datenbank definiert.
|
Listing 1: |
|---|
001 ###########################################
002 package CameraStore;
003 ###########################################
004 # Mike Schilli, 2003 (m@perlmeister.com)
005 ###########################################
006 use warnings;
007 use strict;
008
009 use Class::DBI;
010 use Log::Log4perl qw(:easy);
011
012 ###########################################
013 sub new { # Constructor
014 ###########################################
015 my($class) = @_;
016 bless {}, $class;
017 }
018
019 ###########################################
020 sub _img { # INTERNAL: Get image by ID
021 ###########################################
022 my($stamp) = @_;
023
024 my($img) = CameraStore::IDB::Image->
025 search( stamp => $stamp );
026
027 ERROR "No such ID: $stamp" unless
028 defined $img;
029 return $img;
030 }
031
032 ###########################################
033 sub _cat { # INTERNAL: Get category by ID
034 ###########################################
035 my($cname) = @_;
036
037 my($cat) = CameraStore::IDB::Category->
038 search( name => $cname );
039
040 ERROR "No such tag: $cname" unless
041 defined $cat;
042 return $cat;
043 }
044
045 ###########################################
046 sub list_tags { # Get all tags of one img
047 ###########################################
048 my($self, $stamp) = @_;
049
050 my @found = ();
051
052 (my $img = _img($stamp)) or return();
053
054 for my $tag ($img->tags) {
055 push @found, $tag->category->name;
056 }
057
058 return @found;
059 }
060
061 ###########################################
062 sub add_image { # Add new image (plus tag)
063 ###########################################
064 my($self, $stamp, $path, $cname) = @_;
065
066 if(CameraStore::IDB::Image->search(
067 stamp => $stamp )) {
068 ERROR "ID $stamp already exists";
069 return undef;
070 }
071
072 CameraStore::IDB::Image->create({
073 stamp => $stamp,
074 path => $path});
075
076 return 1 unless $cname; # No tag but ok
077
078 return $self->add_tag($cname, $stamp);
079 }
080
081 ###########################################
082 sub delete_image {# Remove image (and tags)
083 ###########################################
084 my($self, $stamp) = @_;
085
086 (my $img = _img($stamp)) or
087 return undef;
088
089 $img->delete();
090 }
091
092 ###########################################
093 sub add_tag { # Add a new tag name
094 ###########################################
095 my($self, $cname, $stamp) = @_;
096
097 INFO "Adding tag $cname/$stamp";
098
099 (my $img = _img($stamp)) or
100 return undef;
101
102 # Add category by name
103 my $cat = CameraStore::IDB::Category->
104 find_or_create({name => $cname});
105 E
106 if(CameraStore::IDB::Tag->search(
107 image => $img->id,
108 category => $cat->id)) {
109 ERROR "$stamp already has $cname";
110 return undef;
111 }
112 # Add image/cat link to tags table
113 $cat->add_to_tags({image => $img->id});
114 }
115
116 ###########################################
117 sub delete_tag { # Take tag off image
118 ###########################################
119 my($self, $cname, $stamp) = @_;
120
121 INFO "Strip $cname from $stamp";
122
123 (my $img = _img($stamp)) or
124 return undef;
125
126 (my $cat = _cat($cname)) or
127 return undef;
128
129 my($tag)=CameraStore::IDB::Tag->search(
130 image => $img->id,
131 category => $cat->id,
132 );
133
134 unless($tag) {
135 ERROR "No $cname on $stamp";
136 return undef;
137 }
138
139 $tag->delete();
140 }
141
142 ###########################################
143 sub search_tag {
144 ###########################################
145 my($self, $tag, $paths, $stamp) = @_;
146
147 my @matches = CameraStore::IDB::Image->
148 match($tag, $stamp);
149 my $field = $paths ? "path" : "stamp";
150 return map { $_->$field } @matches;
151 }
152
153 ###########################################
154 package CameraStore::IDB;
155 ###########################################
156 use base q(Class::DBI);
157 __PACKAGE__->set_db('Main',
158 'dbi:mysql:idb',
159 'root', '');
160
161 ###########################################
162 package CameraStore::IDB::Image;
163 ###########################################
164 use base q(CameraStore::IDB);
165
166 __PACKAGE__->table('images');
167 __PACKAGE__->columns(
168 All => qw(id stamp path));
169 __PACKAGE__->has_many('tags',
170 'CameraStore::IDB::Tag' => 'image');
171
172 __PACKAGE__->set_sql(rawmatch => q{
173 SELECT DISTINCT images.stamp, images.path
174 FROM categories, tags, images
175 WHERE
176 categories.name LIKE ? AND
177 categories.id = tags.category AND
178 images.id = tags.image AND
179 images.stamp LIKE ?
180 });
181
182 sub match {
183 my ($class, $rex, $stamp) = @_;
184 $stamp = "%" unless defined $stamp;
185 my $sth = $class->sql_rawmatch;
186 $sth->execute($rex, $stamp);
187 return $class->sth_to_objects($sth);
188 }
189
190 ###########################################
191 package CameraStore::IDB::Category;
192 ###########################################
193 use base q(CameraStore::IDB);
194
195 __PACKAGE__->table('categories');
196 __PACKAGE__->has_many('tags',
197 'CameraStore::IDB::Tag' => 'category');
198 __PACKAGE__->columns(
199 All => qw(id name));
200
201 ###########################################
202 package CameraStore::IDB::Tag;
203 ###########################################
204 use base q(CameraStore::IDB);
205
206 __PACKAGE__->table('tags');
207 __PACKAGE__->columns(
208 All => qw(id image category));
209 __PACKAGE__->has_a('category',
210 'CameraStore::IDB::Category');
211 __PACKAGE__->has_a('image',
212 'CameraStore::IDB::Image');
213 1;
|
Praktische Bildersammlung
Zeile 206 legt fest, dass die Tabelle, auf die sich die Klasse »CameraStore::IDB::Tag« bezieht, »tags« heißt. Die Zeilen 207 und 208 definieren deren Spalten mit »id«, »image« und »category«. Dabei gilt »id« implizit als Primärschlüssel, da es an erster Stelle steht. Die beiden »has_a()«-Aufrufe definieren, dass die beiden Spalten »image« und »category« jeweils nur mit Fremdschlüsseln auf die Primärschlüssel in den Tabellen »images« und »categories« verweisen (Abbildung 1).
Wie Abbildung 2 darstellt, resultiert dies in zwei zusätzlichen Methoden »image()« und »category()« (blau gezeichnet) in der Klasse »CameraStore::IDB::Tag«. Die Methoden liefern direkt die entsprechenden Objekte in »CameraStore::IDB::Image« und »CameraStore::IDB::Category«. Die rechteckigen Kästen neben den abgerundeten Klassen in Abbildung 2 enthalten Methoden, die auf die Attribute (Spaltenwerte) jedes Objekts dieser Klasse zugreifen – eine kostenlose Serviceleistung von Class::DBI.
Ab Zeile 191 definiert die Klasse »CameraStore::IDB::Category« eine Abstraktion der Tabelle »categories« (rechts unten in Abbildung 1 zu sehen). Ein Tag-String (nachfolgend auch Kategorie genannt) kann mit mehreren Einträgen in der »tags«-Tabelle verknüpft sein, wenn mehrere Bilder zur selben Kategorie gehören. Daher definiert Zeile 196 die »has_many()«-Beziehung. Sie bewirkt unter anderem, dass ein »CameraStore::IDB::Category«-Objekt eine »tags()«-Methode erhält, die alle Tag-Einträge in »CameraStore::IDB::Tag« als Liste zurückgibt. Das spiegelt die 1:n-Beziehung wider (siehe Abbildung 2).
Class::DBI sorgt automatisch dafür, dass jedes »CameraStore::IDB::Category«-Objekt eine »name()«-Methode enthält, die den Tag-String (die Kategorie) des jeweiligen Eintrags in der »categories«-Tabelle liefert. Die Methode »id()« gibt den Wert der Primärschlüsselspalte »id« an.
Die »images«-Tabelle wird von der Klasse »CameraStore::IDB::Image« – ab Zeile 162 definiert – repräsentiert. Die Tabelle besitzt die Spalten »id«, »stamp« (der Zeitstempel des Fotos) und »path« (der Pfad zur Bilddatei). Sie stellt außerdem ihre »has_many«-Beziehung zur »tags«-Tabelle zur Schau, die alle zu einem Bild gehörigen »tags«-Einträge als Liste zurückliefert.

Abbildung 2: Das Perl-Modul Class::DBI kapselt die Datenbanktabellen in Klassen. An die Stelle aufwändiger SQL-Statements treten einfache Methodenaufrufe, die auch die Relationen berücksichtigen.
Performance-Fallen vermeiden
Auch Class::DBI hat Grenzen – objektorientierte Hüllen um relationale Datenbanken sausen manchmal in böse Performance-Fallen. Auch konnten objektorientierte Datenbanken ihre relationalen Verwandten noch nicht ablösen – die Oracle-Türme in Redwood-City, an denen ich immer auf dem Highway 101 vorbeifahre, wurden noch nicht in Getreidesilos umgewandelt.
Schön ist an Class::DBI aber, dass es dem Entwickler durchaus die Möglichkeit einräumt, auch seinen von daheim mitgebrachten SQL-Code zu verzehren: Die ab Zeile 172 aufgerufene »set_sql()«-Methode definiert eine komplizierte Abfrage über alle drei Tabellen, die alle Bilder zurückliefert, deren Tag-String einem vorgegebenen Suchmuster entspricht. Die beiden Fragezeichen sind Platzhalter für die Abfrage. Sie fügen den Suchstring und eventuell noch die ID eines Bildes ein, falls die Abfrage nur feststellen soll, ob ein Tag eines bestimmten Bildes passt oder nicht. Durch den »DISTINCT«-Befehl landet jedes Bild maximal einmal im Ergebnis.
Diese Definition unter dem Namen »rawmatch« (Zeile 172) bringt »CameraStore::IDB::Image« dazu, eine »sql_rawmatch()«-Methode anzubieten. Die ab Zeile 182 definierte Methode »match()« nutzt »sql_rawmatch()«, um die SQL-Abfrage zu formulieren und ein Statement-Handle der darunter liegenden DBI-Schnittstelle zu erhalten. Danach schickt »execute()« die SQL-Abfrage an die Datenbank. »sth_to_objects()« wandelt das Ergebnis wieder in Objekte der Abstraktionsschicht um.
Die »match()«-Methode nimmt als erstes Argument den (eventuell als Suchmuster wie »Freu%« angegebenen) Kategorie-Suchbegriff entgegen und als optionales zweites Argument die ID eines Bildes. Fehlt das zweite Argument, wird es in Zeile 184 auf »%« gesetzt, sodass im SQL-Statement »images.stamp LIKE %« steht. Der SQL-Prozessor der Datenbank wird diese immer zutreffende Bedingung hoffentlich wegoptimieren.
Das API des Camera Store
Bevor »CameraStore.pm« öffentlich zugängliche Methoden definiert, legt es in den Zeilen 20 und 33 die beiden nur intern benutzten Funktionen »_img()« und »_cat()« an. Sie liefern zu einer vorgegebenen Image-ID (Zeitstempel) oder zu einem Tag-String (Kategorie) die passenden Objekte, indem sie die Tabellen »images« oder »categories« nach entsprechenden Einträgen durchforsten und im Erfolgsfall Referenzen auf Objekte zurückgeben, die die Reiheninformation enthalten.
Sollte jemand nicht existierende Zeitstempel oder Kategorien angeben, benutzen die Methoden das »ERROR«-Makro von »Log::Log4perl«, um eine kurze Fehlermeldung auszugeben, bevor sie ein »undef«-Ergebnis zurückliefern. Beide Funktionen werden von den öffentlichen API-Methoden genutzt (um Tipparbeit zu sparen), sie werden aber nicht nach draußen exportiert.
Die ab Zeile 46 definierte Methode »list _tags()« holt, wie gerade erläutert, mit »_img()« das zu einer vorgegebenen Image-ID gehörende Objekt und führt dessen »tags()«-Methode aus, um die Liste der passenden Tag-Objekte aus der »tags«-Tabelle zu erhalten. Mit »$tag ->category->name()« navigiert sie von jedem gefundenen Objekt blitzschnell zum entsprechenden Eintrag in der »categories«-Tabelle (»category()«-Methode) und dann zu deren »name«-Spalte (»name()«-Methode).
Die Methode »add_image()« (Zeile 62) nimmt eine Image-ID (Zeitstempel), den Pfad zur Bilddatei und eventuell einen Tag-String entgegen. Zunächst bemüht sie die »search()«-Methode, um einen bereits existierenden Eintrag zu finden, und bricht den Vorgang mit einer Fehlermeldung ab, falls sie ein Bild mit gleichem Zeitstempel in der Datenbank findet. Damit die aufrufende Funktion weiß, was Sache ist, gibt sie in diesem Fall »undef« zurück. Falls die Suche erfolglos verlief, bemüht Zeile 72 die »create()«-Methode, um ein neues Bild in die Tabelle »images« einzufügen. Falls kein Tag-String angegeben wurde, kehrt Zeile 76 vorzeitig zurück, falls doch, delegiert Zeile 78 das Einfügen des Tags an die Methode »add_tag()«.
Bilder einfügen und löschen
Das beim letzten Mal vorgestellte Frontend »idb« hat eine »CameraStore.pm«-Methode gar nicht genutzt, die für künftige Applikation aber gelegen kommen könnte: »delete_image()« (ab Zeile 82) entfernt ein Bild aus der Datenbank. Nachdem Zeile 86 das Image-Objekt geholt hat, muss Zeile 89 nur noch dessen »delete()«-Methode auslösen. Damit löscht sie nicht nur den Eintrag in der Tabelle »images«, sondern auch alle Reihen in »tags«, die sich auf das Bild beziehen. Die weiter unten definierte Beziehung »has_many()« sorgt für diese Automatik – sehr praktisch!
Die ab Zeile 93 definierte Methode »add _tag()« nutzt die »find_or_create()«-Methode des »Category«-Objekts, um die laufende Nummer einer bereits bestehenden Reihe in der »categories«-Tabelle herauszufinden. Falls noch kein Eintrag zum angegebenen Tag-String existiert, legt »find_or_create()« eine neue Kategorie an und gibt deren Nummer zurück.
Neue Tags für alte Bilder
Die danach aufgerufene »search()«-Methode des »Tag«-Objekts findet heraus, ob das Bild bereits dem hereingereichten Tag zugeordnet ist: Falls dies der Fall ist, spuckt »add_tag()« eine Fehlermeldung aus, bricht den Vorgang ab und gibt »undef« zurück. Falls nicht, hängt die »add_to_tags()«-Methode des »Category«-Objekts die Bild-Kategorie-Kombination an die »tags«-Tabelle an. »delete _tag()« ab Zeile 117 hantiert ähnlich – sie sucht erst das entsprechende Tag-Objekt und führt anschließend dessen »delete()«-Methode aus.
Die oft genutzte Suchmethode »search _tag()« (Zeile 143) gerät ziemlich kurz: Grund ist der explizit in der Klassendefinition angegebene SQL-Befehl. Sie muss in Zeile 147 lediglich die neu definierte »match()«-Methode der Klasse »CameraStore::IDB::Image« aufrufen, um eine Liste mit passenden Image-Objekten zu erhalten.
Der Eingabeparameter »$paths« gibt an, ob der Benutzer IDs oder Pfadnamen zurückhaben will. Zeile 149 setzt die Variable »$field« auf den Namen der gewünschten Spalte der »images«-Tabelle. Zeile 150 jagt alle als passend gelieferten »Image«-Objekte durch einen »map«-Transformator, der zu jedem Objekt entweder die »stamp()«- oder die »path()«-Methode aufruft und alle zusammen als Liste zurückliefert.
Installation
Die von »CameraStore.pm« benötigten Module DBI, DBD::mysql, Class::DBI und Log::Log4perl lassen sich am einfachsten mit einer CPAN-Shell installieren. Zu beachten ist aber, dass die Applikation, die das Camera-Store-Modul nutzt, »Log::Log4perl« mindestens mit der Priorität »ERROR« initialisieren sollte. Am besten ruft sie »Log::Log4perl ->init($INFO)« im »:easy«-Modus auf, wie es »idb« letztes Mal tat. So erreichen die ausgegebenen Fehlermeldungen auch den Bildschirm.
Um die Datenbank zu initialisieren, kommt auch dieses Mal ein Shell-Skript zum Einsatz. Es nutzt die Client-Programme »mysql« und »mysqladmin«, um die Datenbank und die darin liegenden Tabellen zu initialisieren. Eine eventuell schon vorhandene Datenbank gleichen Namens wird dabei gnadenlos zubetoniert, also Vorsicht!
Die besten Datenbanktricks stehen übrigens in[2]. Dieses Kochbuch für Praktiker liefert schnelle Lösungen für Probleme, bei denen sich schon Generationen von Datenbankanfängern die Haare gerauft haben. Wer das hervorragende Buch nicht kaufen will, könnte es auch auf Safari lesen – den Online-Subscription-Service von O\’Reilly kann ich hier aber nicht uneingeschränkt empfehlen, da er teuer und langsam ist.
Vielleicht sollte O\’Reilly einfach die Microsoft-Webserver rauswerfen und professionelle, skalierbare Technologie einsetzen? Can you spell A-p-a-c-h-e? Alles nur eine Frage der Zeit. Bis nächsten Monat. (fjl)
|
Listing 2: |
|---|
01 DB=idb 02 03 mysqladmin -f --user=root drop $DB 04 mysqladmin --user=root create $DB 05 06 mysql --user=root --database=$DB <<EOT 07 CREATE TABLE images ( 08 id INT AUTO_INCREMENT, 09 stamp VARCHAR(32), 10 path VARCHAR(255), 11 PRIMARY KEY (id) 12 ) 13 EOT 14 15 mysql --user=root --database=$DB <<EOT 16 CREATE TABLE categories ( 17 id INT AUTO_INCREMENT, 18 name VARCHAR(128) UNIQUE, 19 PRIMARY KEY (id) 20 ) 21 EOT 22 23 mysql --user=root --database=$DB <<EOT 24 CREATE TABLE tags ( 25 id INT AUTO_INCREMENT, 26 image INT, 27 category INT, 28 PRIMARY KEY (id) 29 ) 30 EOT |
|
Infos |
|---|
|
[1] Michael Schilli, “Digitale Plattenkamera”: Linux-Magazin 04/03: [https://www.linux-magazin.de/Artikel/ausgabe/2003/04/perl/perl.html] [2] Paul DuBois, “MySQL Cookbook”: O\’Reilly 2002, ISBN 0596001452 [3] Listings zum Artikel: [ftp://www.linux-magazin.de/pub/listings/magazin/2003/05/Perl] |
|
Der |
|---|
|
|






