Der zweite und abschließende Teil des digitalen Schuhkartons bringt die zum Frontend passende Datenbankschnittstelle, die, wie zu befürchten war, wieder auf der neuen Wunderdroge Class::DBI basiert.
Wie schon in [3] gezeigt, legt das Modul Class:DBI eine Objektschicht um traditionelle relationale Datenbanken und erlaubt es dem Programmierer, auf Applikationsebene sorglos zwischen Objekten zu navigieren, während unten im Motorenraum die SQL-Befehle hin- und herflitzen. 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.
Der letzten Monat vorgestellte Fotoarchiv-Frontend idb
nutzte eifrig
CameraStore.pm
, um die Daten der digitalen Fotos und deren Beschriftungen
in die Datenbank zu verlagern und von dort wieder abzuholen. Folgende
Methoden fanden Verwendung:
$tag
an das Bild mit der ID $id
an.
$tag
vom Bild mit der ID $id
ab.
$tag
gesetzten SQL-Regex
(z.B. %Hawaii%) passt und liefert deren IDs als Liste.
Ist $path_flag
gesetzt, kommen statt dessen die Bild-Pfade.
Enthält $id
die ID eines Bildes, wird nur geprüft, ob dieses
Bild ein passendes Tag führt.
list_tags($id)
$id
anhaften.
Die N:N Beziehung zwischen Bildern und Tags (einem Bild können mehrere Tags anhaften, ein Tag kann zu mehreren Bildern gehören) erfordert datenbanktechnisch einen Klimmzug. Hätten wir nur je eine Tabelle für Bilder und Tags, müssten wir sowohl die länglichen Bild-IDs als auch die Tag-Strings redundant speichern. Das verschwendet nicht nur Speicherplatz, sondern führt auch zu Wartungsproblemen.
Statt dessen definieren wir, wie in Abbildung 1 gezeigt, einfach
eine Vermittlertabelle tags
, die zwischen der Bildtabelle images
und der Tag-String-Tabelle categories
steht.
Jede einzelne Reihe in tags
gibt an,
dass ein Bild image
dem Tag category
zugeordnet ist, wobei
image
und category
die als Primärschlüssel gesetzten
und automatisch hochgezählten Sequenznummern der jeweils
ersten Spalte der Tabellen
images
und categories
sind. Die in der Tabelle tags
definierten
Beziehungen führen also lediglich Fremdschlüssel, die in andere
Tabellen verweisen und enthalten (außer der eigentlich irrelevanten
Sequenznummer id
) keine eigenen Daten.
Abbildung 1: |
Diese Tabellenbeziehungen kann nun Class::DBI
modellieren. Es definiert
Klassen für alle beteiligten Tabellen, deren Reihen durch Objekte dieser
Klassen repräsentiert werden. Für die Beziehungen zwischen den Klassen
nutzen wir zwei verschiedene Eigenschaften:
KlasseHeim->has_a("spalte", "KlasseAuswärts");
in einer von Class::DBI abgeleiteten Klasse KlasseHeim, dass zukünftig
KlasseHeim->spalte()
nicht mehr den Wert der Spalte ``spalte'' in KlasseHeim, also
den Fremdschlüssel liefert, sondern ein
Objekt der Klasse KlasseAuswärts
, auf das der Fremdschlüssel zeigt und
das alle Daten der entsprechenden Reihe der Tabelle enthält, die von
KlasseAuswärts repräsentiert wird.
KlasseHeim->has_many("primaer", "KlasseAuswärts" => "fremd");
dazu, dass Class::DBI
$von->fremd()
-Methode definiert, die alle zu einer
Reihe aus KlasseHeim passenden Elemente in KlasseAuswärts
als Liste
zurückliefert
eine Methode KlasseHeim->add_to_fremd()
definiert, die neue Elemente
in KlasseAuswärts einfügt
alle zu einem in KlasseHeim liegenen Objekt passenden Einträge
aus KlasseAuswärts tilgt, falls das KlasseHeim-Objekt gelöscht wird.
Listing CameraStore.pm
zeigt die Implementierung der objektorientierten
Datenbankschnittstelle. Ganz unten in
Zeile 202 angefangen, zeigt sich, dass die Klasse
CameraStore::IDB::Tag
von CameraStore::IDB
abgeleitet wurde, das
wiederum von Class::DBI abstammt und die Beziehungen aller
in CameraStore.pm
verwendeten Tabellen zur Datenbank definiert. Zeile 206 legt fest,
dass die Tabelle, auf die sich CameraStore::IDB::Tag
bezieht,
tags
heisst und die Zeilen 207 und 208 definieren deren Spalten
mit id
, image
, category
, wobei id
so implizit als
Primärschlüssel gilt, da es als erstes genannt wurde.
Die beiden has_a()
-Aufrufe definieren, dass, wie Abbildung 1 zeigt,
die beiden Spalten image
und category
jeweils nur mit
Fremdschlüsseln auf die Primärschlüssel in den Tabellen images
und categories
verweisen.
Wie Abbildung 2 zeigt, resultiert dies in zwei zusätzlichen Methoden
image()
und category()
(beide grün) der Klasse
CameraStore::IDB::Tag
, die einfach den Zugriff auf entsprechende
Objekte in CameraStore::IDB::Image
und CameraStore::IDB::Category
erlauben. Die rechteckigen Kasterln neben jeder kreisrunden Klasse
listen die Methoden auf, mit denen man auf die Attribute (Spaltenwerte)
jedes Objekts dieser Klasse zugreifen darf -- auch eine kostenlose
Serviceleistung von Class::DBI.
Abbildung 2: |
Ab Zeile 191 definiert die Klasse CameraStore::IDB::Category
eine
Abstraktion auf die Tabelle categories
, rechts unten in Abbildung 1.
Da ein Tag-String (nachfolgend auch ``Kategorie'' genannt) mit mehreren
Einträgen in der tags
-Tabelle verknüpft sein kann (wenn
mehrere Bilder das gleiche Tag führen), definiert Zeile 196 die
has_many()
-Beziehung. Diese bewirkt unter anderem, dass
ein CameraStore::IDB::Category
-Objekt eine tags()
-Methode führt,
die alle Tag-Einträge in CameraStore::IDB::Tag
als Liste zurückgibt,
was die 1:N-Beziehung (siehe Abbildung 2) widerspiegelt.
Außerdem bietet jedes CameraStore::IDB::Category
-Objekt die
name()
-Methode an, die den Tag-String, also die Kategorie jedes
Eintrags in categories
angibt. Die Methode id()
gibt hingegen
den Wert der Primärschlüsselspalte id
an.
Die Klasse, die die images
-Tabelle repräsentiert,
CameraStore::IDB::Image
, legt CameraStore.pm ab Zeile 162 fest. Die Tabelle
führt die Spalten id
, stamp
(der Zeitstempel des Fotos) und
path
(Pfad zur Bilddatei) und stellt außerdem eine has_many
-Beziehung
zur tags
-Tabelle zur Schau, die zu einem Bild alle tags
-Einträge
als Liste zurückliefert.
Auch Class::DBI
hat seine Grenzen -- objektorientierte Hüllen um
relationale Datenbanken sausen manchmal in böse Performance-Fallen. Und auch
rein objektorientierte Datenbanken haben so ihre Problemchen, was man
leicht daran sehen kann, dass die Oracle-Türme in Redwood-City,
an denen ich immer auf dem Highway 101 vorbeifahre, immer noch nicht
in Getreidesilos umgewandelt wurden.
Das Schöne an Class::DBI
ist aber, dass es
dem Entwickler durchaus die Möglichkeit einräumt, von zu Hause
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, die den Suchstring und eventuell noch die ID eines Bildes
einfügen, falls die Abfrage nur feststellen soll, ob ein Tag eines
bestimmtes Bildes passt oder nicht. Wegen des DISTINCT-Befehls kommt
jedes Bild nur einmal.
Diese Definition unter dem Namen rawmatch
(Zeile 172)
veranlasst CameraStore::IDB::Image
dazu, eine sql_rawmatch
-Methode
anzubieten, die die ab Zeile 182 definierte Methode match
nutzen
wird, um den SQL-Query zu formulieren und ein Statement-Handle
der darunterliegenden DBI-Schnittstelle
zu erhalten. execute()
schickt anschließend die SQL-Abfrage
an die Datenbank ab.
sth_to_objects()
in Zeile 187 macht aus dem Ergebnis dann wieder
Objekte in der Abstraktionsschicht. Die match
-Methode nimmt als erstes
Argument den (eventuell als SQL-Regex wie Freu%
angegebenen)
Kategorie-Suchbegriff entgegen und als optionales zweites Argument die
ID eines Bildes. Fehlt das zweite Argument, setzt Zeile 184 es auf ``%'',
so dass im SQL-Statement images.stamp LIKE %
steht, was der
SQL-Prozessor der Datenbank hoffentlich wegoptimiert.
Von vorne:
Bevor's an die öffentlich zugänglichen Funktionen von CameraStore.pm
geht, definiert es in den Zeilen 20 und 33 noch zwei nur intern genutzte
Funktionen _img
und _cat
, die zu einer vorgegebenen Image-ID
(Zeitstempel) bzw. zu einem Tag-String die passenden Objekte liefern,
indem sie die Tabellen images
bzw. categories
nach entsprechenden
Einträgen durchforsten und im Erfolgsfall Referenzen auf
Objekte, die die Reiheninformation enthalten, zurückgeben.
Falls jemand nicht-existierende Zeitstempel oder Tag-Strings angibt,
erfolgt jeweils eine kurze Fehlermeldung via Log::Log4perl
s ERROR-Makro,
bevor undef
zurückkommt. Beide Funktionen werden von den nachfolgend
besprochenen API-Methoden genutzt, um Tipparbeit zu sparen, werden
aber nicht nach draussen exportiert.
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 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;
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 eine Liste zugehöriger Tag-Objekte
aus der tags
-Tabelle zu erhalten. Mit
$tag->category->name
navigiert sie dann von jedem
gefundenen
Objekt blitzschnell zum entsprechenden Eintrag in der categories
-Tabelle
(category()
-Methode) und anschließend zu deren name
-Spalte
(name()
-Methode).
add_image
ab Zeile 62 nimmt eine Image-ID (Zeitstempel), den Pfad
zur Bilddatei und eventuell einen Tag-String entgegen. Zunächst bemüht
es die search
-Methode, um einen bereits existierenden Eintrag zu finden
und bricht den Vorgang mit einer Fehlermeldung ab, falls ein Bild
mit gleichem Zeitstempel sich bereits in der Datenbank befindet.
Damit die aufrufende Funktion weiss, was Sache ist, reicht es 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 weiter unten definierte, ebenfalls öffentlich
zugängliche add_tag()
-Methode.
delete_image()
ab Zeile 82 entfernt ein Bild aus der Datenbank und
wurde von dem letzes Mal vorgestellten Frontend idb
gar nicht genutzt,
könnte aber für eine zukünftig erstellte Applikation gelegen kommen.
Nachdem Zeile 86 das entsprechende Image-Objekt geholt hat, muss Zeile
89 nur noch dessen delete()
-Methode auslösen, um nicht nur den
Eintrag in der Tabelle images
zu löschen, sondern (wegen der
unten definierten has_many()
-Beziehung) auch alle Reihen in tags
,
die sich auf das Bild beziehen. Praktisch!
Die Methode add_tag
ab Zeile 93 nutzt die find_or_create()
-Methode
des Category
-Objekts, um die laufende Nummer einer bereits
bestehenden Reihe in der category
-Tabelle herzunehmen, falls schon
ein Eintrag zum angegebenen Tag-String existiert,
oder eine neue anzulegen, falls es die Kategorie bislang noch nicht gab.
Die anschließend aufgerufene search
-Methode des Tags
-Objekts
findet heraus, ob das angegebene 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 implizit wegen der has_many
-Beziehung
definierte add_to_tags
-Methode des Category
-Objekts die
Bild-Tag-Kombination an die tags
-Tabelle an.
delete_tag()
ab Zeile 117 hantiert ähnlich, nur dass es das entsprechende
Tag-Objekt sucht und dann dessen delete()
-Methode ausführt.
Die häufig genutzte Such-Methode search_tag()
ab Zeile 143
gerät wegen des explizit
in der Klassendefinition angegebenen SQL-Befehls ziemlich kurz: Lediglich
die neu definierte match
-Methode der Klasse CameraStore::IDB::Image
wird aufgerufen, 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 entsprechend gewünschten Spalte der images
-Tabelle. Zeile
150 jagt alle als passend gelieferten Image
-Objekte durch einen
map
-Transformator, zu jedem Objekt entweder die stamp()
oder
die path()
-Methode aufruft und alle zusammen als Liste zurückliefert.
Zu beachten ist, dass die Applikation, die CameraStore.pm
nutzt,
Log::Log4perl mindestens mit der Priorität ERROR
initialisieren
sollte (am besten mit Log::Log4perl->init($INFO)
im :easy
-Modus
wie idb
das letztes Mal tat), damit die ausgegebenen Fehlermeldungen
auch den Bildschirm erreichen.
Die von CameraStore benötigten Module DBI
, DBD::mysql
,
Class::DBI
und Log::Log4perl
installieren sich am einfachsten
mittels einer CPAN-Shell.
Um die Datenbank zu initialisieren, kommt auch diesmal wieder ein Shell-Skript
zum Einsatz, das einfach die Client-Programme mysql
und mysqladmin
nutzt, um die Datenbank und die darin liegenden Tabellen zu initialisieren,
nachdem es eine eventuell schon vorhandene Datenbank gnadenlos zubetoniert,
also Vorsicht!
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
Die besten Datenbanktricks stehen übrigens in [2], einem Kochbuch für Praktiker, die schnell Lösungen für Probleme brauchen, wegen denen sich schon Generationen von Datenbankanfängern die Haare gerauft haben. Ein hervorragendes Buch, sofort kaufen! Oder auf ``Safari'' lesen, dem O'Reilly Online-Subscription-Service, den ich hier leider nicht uneingeschränkt empfehlen kann, da er extrem teuer und hundelangsam ist. Oder sollte O'Reilly einfach die klumpat'n 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!
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. |