Photo-Finish 2 (Linux-Magazin, Mai 2003)

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:

add_image($stamp, $path, [$cname])
füttert Zeitstempel und Dateipfad eines neuen Bildes in die Datenbank ein und taggt es mit dem optional angegebenen Tag-String $cname.

add_tag($tag, $id)
hängt einen Tag-String $tag an das Bild mit der ID $id an.

delete_tag($tag, $id)
koppelt den Tag-String $tag vom Bild mit der ID $id ab.

search_tag($tag, [$path_flag], [$id])
Sucht die Datenbank nach Bildern ab, deren Tag auf den in $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)
Gibt die Tag-Strings als Liste zurück, die dem Bild mit der ID $id anhaften.

Im Motorenraum

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:

Auf der Kommandobrücke

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:

has_a
Enthält eine Tabellenspalte keine eigenen Daten, sondern die Primärschlüssel zu Einträgen einer anderen Tabelle (sog. Fremdschlüssel), definiert der Aufruf
    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.

has_many
Liegen in KlasseAuswärts Records, die mit KlasseHeim in Verbindung stehen, und referenziert KlasseAuswärts diese unter den in KlasseHeim verwendeten Primärschlüsseln, führt der Aufruf
    KlasseHeim->has_many("primaer", 
         "KlasseAuswärts" => "fremd");

dazu, dass Class::DBI

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.

Die API des CameraStore

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::Log4perls 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.

Listing 1: CameraStore.pm

    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.

Installation

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!

Listing 2: sql.sh

    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!

Infos

[1]
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2003/05/Perl

[2]
Paul DuBois, ``MySQL Cookbook'', O'Reilly 2002, ISBN 0596001452

[3]
Michael Schilli, ``Musik aus dem Keller'', Linux-Magazin 03/2003, http://www.linux-magazin.de/Artikel/ausgabe/2003/03/perl/perl.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.