Warum ein Data Abstraction (Access) Layer

Object Relational Mapping, aus Feldern werden Attribute und umgekehrt

Viele Anwendungen benötigen persistente Daten und in vielen Fällen sind die Daten für namentlich benannte Einträge als Schlüssel-Werte-Paare definiert. Nennen wir einen namentlich benannten Eintrag address, so hätten wir beispielsweise eine Datenstruktur wie folgt:

    my %address =(
        name  => '',
        vname => '',
        ort   => '',
        plz   => '',
        str   => '',
    );

Gut zu sehen, das Datenmodell ist nach dem Muster Entity, Attribute, Value (EAV) aufgebaut, Entity ist der Name des Eintrags, im Beispiel address. Eine solche Datenstruktur können wir auch als Objekt auffassen, ein Objekt nicht als Instanz einer Klasse sondern einfach nur als eine Ansammlung von Daten.

Nun können derartige Objekte verschiedene Attribute haben, sowohl namentlich als auch in deren Anzahl. Das Datenobjekt für den hier vorliegenden Artikel hat z.B. unter anderem die Attribute title, descr, body und der Name des Eintrags, Entity ist /dal.html.

Konventionell: Feldnamen gleich Eigenschaften

Betrachte untenstehendes Insert-Statement:

my %book = ( author => 'Martin Selber', title => 'Mit Spule Draht und Morsetaste' );
@fields = keys %book;
$dbh->do(qq(
    insert into books (@{[join ',', map{$dbh->quote_identifier($_)}@fields]})
    values(@{[join ',', map{"?"}1..scalar @fields]}
)),{}, @book{@fields});

Herzstück ist ein sogenannter Hash-Slice. Bekanntlich ist ein Hash ein assoziatives Array, welches zu einem besimmten Schlüssel einen bestimmten Wert liefert. Ein Hash-Slice nun, liefert zu einer Liste von Schlüsseln eine Liste mit den dazugehörigen Werten in derselben Reihenfolge. Wichtig ist in diesem Zusammenhang, daß für fehlende Eigenschaften im Tabellenentwurf ein Default-Wert definiert ist:

$dbh->do(q(
    create table if not exists books(
        id int(10) not null auto_increment,
        isbn varchar(255) not null default '',
        author varchar(255) not null default '',
        title varchar(255) not null default '',
        primary key (id)
    )
)) or die $@;

So wird jeder gegebene Wert in das der Eigenschaft entsprechende (gleichnamige) Feld eingefügt. Über diesen Mechanismus lässt sich eine allgemeine Insertfunktion definieren:

# Eine Methode für die Factory
sub insert{
    my $self = shift;
    my $dbh  = shift;
    my $tabn = shift;
    my %hunt = @_;
    my @fields = keys %hunt;
    $dbh->do(qq(
        INSERT INTO @{[$dbh->quote_identifier($tabn)]} (@{[join ',', map{$dbh->quote_identifier($_)}@fields]})
        VALUES( @{[join ',', map{$dbh->quote($_)}@hunt{@fields}]} )
    ));
}

# Aufruf mit einer beliebigen Instanz
$mock->insert( $dbh, "books",
    title => 'Zwei Freunde und hundert Ideen',
    author => 'A. Golovin, Vladimir'
);

Die Abstraktion vom Speicherort für persistente Daten

Definition Data-Abstraction-Layer: Der DAL vermittelt zwischen dem Speicherort und der darin abgelegten Datenstruktur.

In Perl ist die Datenstruktur ein gewöhnlicher Hash mit Schlüssel-Werte-Paaren für eine bestimmte Entity. Aus Perl-Sicht erfolgt der Zugriff auf die Daten nicht etwa über einen Zugriff auf eine Datei oder über ein SQL-Statement, vielmehr erfolgt der Zugriff über die vom DAL gelieferte Datenstruktur.

Beispiel zum Einbinden verschiedener DALs, untenstehender Code verdeutlicht die Abstraktion:

    # Speicherort Datei
    my $dal = FastEAV->new( file => '/path/personen.data');

    # Speicherort MySQL
    my $dal = MySQLEAV->new( tabn => 'personen', base => 'webdaten' );

Der DAL stellt, nach der Bildung der Instanz in $dal, Methoden zur Verfügung für den Zugriff auf die Daten, Erstellen, Einfügen, Ändern, Speichern, Löschen, ein paar Beispiele untenstehend:

    $dal->checkin('addr_1', %address);
    $dal->update('addr_1', ort => 'Henneberg');
    $dal->delete('addr_1');
    $dal->write();

Allein diese Methoden, bzw. deren Anwendung, zeigen, dass im Programmcode keinerlei FileHandle oder DataBaseHandle zum Einsatz kommen müssen. Für Perl ist der DAL transparent, das heißt: Der Speicherort ist für das Programm uninteressant, es wird einfach nur auf die Daten zugegriffen und es werden Methoden angewandt.

Merke: Wenn die verschiedenen Layer allesamt die Methoden namentlich gleich zur Verfügung stellen, ist der DAL austauschbar mit einem Minimum an Programmieraufwand.

Vergleich der Datenspeicherung in Dateien oder MySQL

Natürlich gibt es Unterschiede, was die Performance, den RAM- und CPU-Bedarf betrifft. Der größte Overhead entsteht in dem Moment, wenn die Verbindung zu MySQL aufgebaut wird, dass kann in einer Webanwendung zu einer merklichen, jedoch nicht wesentlichen Verzögerung führen. Demgegenüber deutlich schneller ist das Erstellen eines DateiHandles. Eine Datei muss jedoch komplett deserialisiert werden, bevor die Datenstruktur im RAM zur Verfügung steht. Mit einem zweckmäßigen Serializer, kurzum als Richtwert: Das Einlesen einer Datei mit einer Größe bis zu 5MB einschließlich der Erstellung der Datenstruktur als Hash braucht auf einer heutigen Hardware weniger Zeit, als der Aufbau einer Verbindung zu MySQL.

Nachdem die Verbindung zu MySQL jedoch einmal aufgebaut ist, das DataBaseHandle liegt vor, ist der Zugriff auf die Daten sehr schnell, die Zeiten liegen im Millisekundenbereich, auch dann, wenn in einer Tabelle bspw. 4 Millionen Einträge vorliegen. Das Geheimnis für diese Performance ist die Wirksamkeit eines Index, der in der WHERE-Klause dem Feld entsprechend greift.

Im Folgenden beschreibe ich die Vorgehensweise zum Entwickeln eines einfachen Data-Abstraktion-Layer zum Speichern von Hashes nach dem EAV-Muster in MySQL. Auch hier werden die Daten nach einem bestimmten Algorithmus serialisiert, denn aufgrunddessen, dass die Namen der Attribute nicht vorhersehbar sind, ist es nicht möglich, den Attributen entsprechend gleichnamige Tabellenfelder anzulegen.

Die Tabelle für einen einfachen Data-Abstraction-Layer, den wir auch als ORM (Object Relational Mapping) bezeichnen können, hat lediglich drei Felder für Entity, Attribute, Value. Diese Tabelle ist nicht normalisiert, verfügt jedoch über einen Index, welcher für performante Abfragen sorgt. Sie ist so aufgebaut, dass sowohl UTF-8-Kodierte Zeichenketten als auch reine Binaries nebeneinander darin gespeichert werden können.

Entwicklung eines DAL für MySQL

Create Statement

    # Tabelle wird ggf. neu erstellt
    sub _create{
        my $self = shift;
        my $q = qq(
            CREATE TABLE IF NOT EXISTS $self->{TABN}(
            `ent` varchar(128) COLLATE utf8_bin NOT NULL DEFAULT '',
            `att` varchar(128) COLLATE utf8_bin NOT NULL DEFAULT '',
            `val` text COLLATE utf8_bin,
            PRIMARY KEY (`ent`,`att`),
            KEY ent(ent),
            KEY att(att)
            ) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_bin
        );
        $self->{DBH}->do($q);
    }

Die Tabelle mit den drei Spalten ent, att, val (EAV) wird angelegt. Die Collation wird auf binary gesetzt, damit wird auch in den Schlüsseln zwischen Groß- und Kleinschreibung unterschieden. So ist beispielsweise eine ent = 'ADDR' neben ent = 'addr' möglich, das sind also unterschiedliche Einträge. Dasselbe Verhalten gilt sinngemäß auch für die anderen beiden Datenfelder.

Die festgelegte Collation passt zur Zeichenkodierung UTF-8. Perl kann damit vollständig zeichenorientiert mit der Datenbank kommunizieren und z.B. die MySQL-Funktionen UPPER(), LOWER() nutzen. Sofern Perl jedoch nur byteorientiert mit der Datenbank kommuniziert, ist es auch möglich, reine Binaries in das Textfeld einzufügen (GIF, JPEG, PDF...).

Constructor

    sub new{
        my $class = shift;
        my %cfg = (
            host => 'localhost',
            port => 3306,
            utf8 => 0,   # Zeichenorientierung ein/aus
            user => '',  # Benutzername
            pass => '',  # Passwort
            base => '',  # Name der DB
            tabn => '',  # Name der Tabelle
            create => 0, # Die Tabelle wird bei Bedarf angelegt
        @_);

        return eval{
            croak "Argument 'tabn' is required" unless $cfg{tabn};
            croak "Argument 'base' is required" unless $cfg{base};
            my $self = bless{}, $class;
            $self->{DBH} = $self->_dbh(%cfg) or croak $@;

            # Die .pm wird gelockt damit DB-Zugriffe atomar sind
            my $file = $INC{"$class.pm"};
            if($file && -e $file){
                my $fh = IO::File->new;
                $fh->open( $file, 'r');
                flock($fh, LOCK_EX) or carp "Your system does not support flock, all procs are not atomar.";
            }

            # maskiere den Namen der Tabelle
            $self->{TABN} = $self->{DBH}->quote_identifier($cfg{tabn});
            if($cfg{create}){ $self->_create; }

            $self;
        };
    }


    # Data Base Handle
    sub _dbh{
        my $self = shift;
        my %cfg = @_;

        my @utf8en = $cfg{utf8} ? (mysql_enable_utf8 => 1) : ();


        return
        DBI->connect_cached(
            "DBI:mysql:$cfg{base}:$cfg{host}:$cfg{port}",
            $cfg{user}, $cfg{pass},
            {RaiseError => 1, PrintError => 0, @utf8en}
        );

    }

Der Konstruktor erstellt die Instanz der Klasse, dabei wird die Verbindung zur DB hergestellt und der Name der Tabelle wird MySQL-gerecht maskiert. RaiseError wird auf 1 gesetzt, damit werden alle möglichen Fehler in den Status einer Exception erhoben. Das hat den Vorteil einer relativen Code-Vereinfachung, es erübrigen sich explizite Abfragen, ob etwa ein Statement fehlgeschlagen ist und es müssen keine Texte für eigene Fehlermeldungen erstellt werden.

Public Methods

    ### PUBLIC METHODS ########################################################
    # Alle Einträge werden gelöscht und neu eingetragen
    # Multiple Insert
    sub checkin{
        my $self = shift;
        my $ent = shift;
        my %av = @_;
        return eval{
            $self->delete($ent);
            my @vals = ();
            foreach my $att(keys %av){
                push @vals, sprintf("(%s,%s,%s)", $self->{DBH}->quote($ent), $self->{DBH}->quote($att), $self->{DBH}->quote($av{$att}));
            }
            my $q = qq(INSERT INTO $self->{TABN} (ent,att,val) VALUES @{[join ',', @vals]} );

            $self->{DBH}->do($q);
        };
    }

    # Attribute, Value für einen Eintrag als hashref
    sub checkout{
        my $self = shift;
        my $ent  = shift;
        return eval{
            if(! exists $self->{STH}{CHECKOUT}){
                $self->{STH}{CHECKOUT} = $self->{DBH}->prepare("SELECT att, val FROM $self->{TABN} WHERE ent=?");
            }
            $self->{STH}{CHECKOUT}->execute($ent);
            my $aref = $self->{STH}{CHECKOUT}->fetchall_arrayref({});
            my %hunt = map{ $_->{att} => $_->{val}   } @$aref;

            scalar keys %hunt ? \%hunt : undef;
        };
    }

    # Anzahl der Entities oder Liste der Entities
    sub count{
        my $self = shift;
        return eval{
            my $q = qq(SELECT DISTINCT ent FROM $self->{TABN});
            my @ents = map{ $_->[0]  } @{$self->{DBH}->selectall_arrayref($q)};
            wantarray ? @ents : scalar @ents;
        };
    }

    sub delete{
        my $self = shift;
        my $ent = shift;
        return eval{
            if(! exists $self->{STH}{DELETE}){
                $self->{STH}{DELETE} = $self->{DBH}->prepare("DELETE FROM $self->{TABN} WHERE ent=?");
            }
            $self->{STH}{DELETE}->execute($ent);
        };
    }

    # vorhandende Keys überschreiben
    # neue Keys eintragen
    sub update{
        my $self = shift;
        my $ent = shift;
        my %av = @_;
        my $legacy = $self->checkout($ent) || return;
        my %res = (%$legacy, %av);
        $self->checkin($ent, %res);
    }

    # DUMMY
    # Macht diesen DAL kompatibel, zu anderen DALs welche die Methode
    # write implementieren
    sub write{1}

Die Methoden arbeiten mit Prepared Statements. Das jeweilige Statement wird beim ersten Aufruf der jeweiligen Methode erstellt und als Attribut im der Instanz gespeichert. Das erhöht die Performance bei mehreren aufeinanderfolgenden Methodenaufrufen und die übergebenen Argumente werden MySQL-gerecht maskiert, so dass SQL-Injektionen nicht möglich sind.

Object Relational Mapping

Die EAV-Tabelle ist nicht normalisiert denn das ist nicht das eigentliche Ziel eines DAL, was darin besteht, namentlich benannte Datenobjekte (Entity) mit beliebigen Attributen in beliebiger Anzahl persistent zu machen. Wobei: Die Struktur der Datenobjekte wird einzig und allein vom Programmcode bestimmt.

Dennoch ist es möglich, die Mächtigkeit von MySQL für gezielte Abfragen zu nutzen, nämlich dann, wenn alle Datenobjekte stets dieselbe Struktur, sprich, dieselben Attribute haben. Als Beispiel die Attribute name, vname, ort, parent sozusagen als Quasi-Datenfelder. Betrachte untenstehendes SQL-Statement:

    SELECT

    a.ent       as entity,

    `name`.val  as `name`,
    `vname`.val as `vname`,
    `ort`.val   as `ort`


    FROM eav a

    JOIN `eav` `name`  USING(ent)
    JOIN `eav` `vname` USING(ent)
    JOIN `eav` `ort`   USING(ent)

    WHERE a.val = 'foo'     # WERT 'foo' für WHERE parent => 'foo'

    AND `name`.att = 'name' AND `vname`.att = 'vname'
    AND `ort`.att = 'ort'
    AND a.att = 'parent'    # FELD 'parent' für WHERE parent => 'foo'

Über SELF-JOINs wird eine Relation rekonstruiert und obenstehende Abfrage liefert ein auf Felder ausgerichtetes Ergebnis, wenn die in der Tabelle enthaltenen Objekte die Attribute name, vname, ort, parent besitzen und diese mit Werten bestückt sind.

Der SELF-JOIN ist einfach zu verstehen, die Tabelle wird sooft mit sich selbst gejoint, wie Quasi-Felder auf Attribute gemappt werden. Quasi-Felder sind name, vname, ort. Das Quasi(WHERE parent = 'foo') wird erzeugt durch zwei Bedingungen in WHERE, oben gekennzeichnet. Als Beschleuniger für die Abfrage dienen die Indizies KEY ent(ent), KEY att(att). Dass diese Keys greifen, zeigt ein dem Statement vorangestelltes explain.

idselect_typetabletypepossible_keyskeykey_lenrefrowsExtra
1SIMPLEarefPRIMARY,ent,attatt386const1Using where
1SIMPLEnameeq_refPRIMARY,ent,attPRIMARY772myweb.a.ent,const1Using where
1SIMPLEvnameeq_refPRIMARY,ent,attPRIMARY772myweb.a.ent,const1Using where
1SIMPLEorteq_refPRIMARY,ent,attPRIMARY772myweb.vname.ent,const1Using where

Die EAV-Tabelle für diese Abfrage hat 8_719_848 Einträge, das sind fast 9 Millionen Zeilen, die Ergebnismenge jedoch hat nur 4 Zeilen, siehe Spalte rows.

Erweiteren wir unseren DAL um die folgende Methode, welche den JOIN zusammenbaut:

    # Diese Methode erwartet als erstes Argument
    # eine Array-Ref mit den gewünschten Feldern
    # Das zweite und dritte Argument ist ein Schlüssel und ein Wert
    # für die Abfrage-Bedingung, welcher Wert für das
    # gemeinsame Attribut gegeben sein soll
    sub entities_for_fields{
        my $self = shift;
        my $fields = shift;
        my $attname = shift;
        my $attvalue = shift;
        return eval{
            my @select = ();
            my @join   = ();
            my @where  = ();
            foreach my $field(@$fields){
                my $unfield = $field;
                $field = $self->{DBH}->quote_identifier($field);
                push @select, sprintf("%s.val as %s", $field, $field);
                push @join, sprintf("JOIN %s %s USING(ent)", $self->{TABN}, $field);
                push @where, sprintf("%s.att = %s", $field, $self->{DBH}->quote($unfield));
            }
            push @where, sprintf("a.att = %s", $self->{DBH}->quote($attname));

            my $statement = qq(
                SELECT
                a.ent as entity,

            ).join(",\n", @select).qq(
                FROM $self->{TABN} as a

            ).join("\n", @join)."\n".sprintf("WHERE a.val = %s\n", $self->{DBH}->quote($attvalue))."AND ".join(' AND ', @where);

            $self->{DBH}->selectall_arrayref($statement, {Slice => {}});
        };
    }

Der Aufruf dieser Methode liefert untenstehendes Ergebnis:

    my $e = MySQLEAV->new(%cfg) or die $@;

    $e->checkin('d1', parent => 'foo', name => 'Rost', vname => 'Rolf', ort => 'Oppenheim');
    $e->checkin('d2', parent => 'foo', name => 'Lottermann', vname => 'Erwin', ort => 'Henneberg');
    $e->checkin('addr', parent => 'foo', name => 'Hammel', vname => 'Horst', ort => 'Hammelburg');

    # Selectiere alle Einträge mit dem parent => 'foo'
    # Und erfasse die Felder name, vname, ort
    print Dumper $e->entities_for_fields(['name','vname','ort'], parent => 'foo');

    # Ergebnis
    $VAR1 = [
              {
                'entity' => 'd1',
                'ort' => '',
                'name' => '',
                'vname' => ''
              },
              {
                'entity' => 'd2',
                'ort' => '',
                'name' => '',
                'vname' => ''
              },
              {
                'entity' => 'addr',
                'ort' => '',
                'name' => '',
                'vname' => ''
              }
            ];

Bei diesem Statement wird der Index genutzt und damit ist die Abfrage auch performant. ORM bedeutet in diesem Fall: Das Mapping beschreibt eine Transformation, aus Attributen werden Datenfelder und umgekehrt.

Data Abstraction Layer in der Praxis

Anwendungen sind mit einem solchen DAL schnell entwickelt. Die Datenfelder werden einzig und allein vom Programmcode bestimmt, in den verlinkten Beispielen sind die Atribute (Datenfelder) mit JavaScript festgelegt.


Datenschutzerklärung: Diese Seite dient rein privaten Zwecken. Auf den für diese Domäne installierten Seiten werden grundsätzlich keine personenbezogenen Daten erhoben. Das Loggen der Zugriffe mit Ihrer Remote Adresse erfolgt beim Provider soweit das technisch erforderlich ist. s​os­@rolf­rost.de.