The Yesod Framework

Ein Webrahmenwerk in Haskell

Persistenz

Ansatz der Speicheranbindung von Yesod

In der heutigen Zeit kommen selbst kleinere Webanwendungen kaum noch ohne die Anbindung an ein System aus der Speicherebene aus. Schon für einfache Lösungen des Content Managements ist eine Speicheranbindung wie beispielsweise die Verwendung von Datenbanken eine Pflichtaufgabe bei der Entwicklung der Applikation. So ist es wenig überraschend, dass auch das Yesod-Framework mit einer Standardlösung für das Problem der persistenten Speicherung entsprechender Daten aufwartet. Persistent nennt sich diese und bietet durch Abstraktion vom schließlich eingesetzten Datenbankbackend eine unabhängige und flexible Implementierung der Speicheranbindung.

Ähnlich zu dem Bestreben, Datentypen aus der objektorientierten Programmierung in eine Repräsentation des relationalen Schemas, welches bei den Mainstream-Datenbankverwaltungssystemen eingesetzt wird, umzuwandeln, hat es auch in der Haskell-Welt bereits zahlreiche Versuche gegeben, Haskell-Werte dergestalt in Datenbanken zu sichern, dass diese auch nach dem Ladevorgang on-the-fly weiterverwendet werden können. Dies mag für einige Anwendungsfälle eine elegante Lösung bedeuten, ist für die überwiegende Mehrheit der in der Praxis relevanten Aufgabenstellungen jedoch ohne Wert, da z. B. oftmals bereits ein Datenbankmodell vorhanden ist, welches nicht zu verändern ist und an das sich die Webanwendung anzupassen hat. Die Aufgabe von Persistent besteht darin, gleichermaßen typsicher und effizient Speicherfunktionalitäten bereitzustellen, ohne dabei an eine bestimmte technische Umsetzung der eigentlichen Datenbank gebunden zu sein.[1]

Im Yesod Web wird ein schönes Einführungsbeispiel veranschaulicht, welches den entscheidenden Vorteil von Persistent gegenüber vielen anderen Standardlösungen auf den Punkt bringt. Angenommen, es sollen Personendaten in einer Datenbank abgespeichert werden. Im Kontext einer Datenbankabfragesprache würde der Befehl zum Anlegen dieser Datenbanktabelle beispielsweise wie folgt aussehen.

1CREATE TABLE Person (
2    id PRIMARY KEY,
3    name VARCHAR NOT NULL,
4    age INTEGER
5)

Eine daran angelehnte Haskell-Datentypdefinition würde dann in etwa der folgenden entsprechen.

1data Person = Person
2    { personName :: String
3    , personAge :: Int
4    }

Auf den ersten Blick macht es den Eindruck, als wenn an dieser Stelle nichts schief gehen könnte. Doch was passiert, wenn das Datenbankverwaltungssystem ein ungetyptes Resultat liefert? Und was ist, wenn die Datenbank, an welche eine auszuwertende Anfrage gesendet wurde, erst zur Laufzeit einen entsprechenden Syntaxfehler aufzeigt? In der Praxis wird man bei dynamischen Programmiersprachen wie PHP oder Ruby nicht um Unit-Tests herumkommen, welche für jedwede Anfrage ihre Korrektheit verifizieren. Bei Yesod ist dies meist nicht notwendig, da die starke Typisierung von Haskell in fast jedem Fall sicherstellt, dass nur wohldefinierte und die vom Nutzer gewünschten Anfragen typsicher ausgeführt werden. Es wird sich zudem zeigen, dass mögliche Fehler bereits bei der Kompilierung aufgedeckt werden.[2]

Technischer Hintergrund

Für die technische Umsetzung der Anbindung der Webanwendung an die Speicherschicht führt Yesod einen Datentypen zur Kennzeichnung eines Wertes ein, welcher für die Interaktion von Daten mit der Datenbank zum Einsatz kommt.

1data PersistValue = PersistText Text
2    | PersistByteString ByteString
3    | PersistInt64 Int64
4    | PersistDouble Double
5    | PersistBool Bool
6    | PersistDay Day
7    | PersistTimeOfDay TimeOfDay
8    | PersistUTCTime UTCTime
9    | PersistNull
10    | PersistList [PersistValue]
11    | PersistMap [(T.Text, PersistValue)]
12    | PersistForeignKey ByteString

Mit diesen Datentypen kommt der Entwickler in der Regel nicht in Berührung. Sie dienen der Realisierung verschiedener Datenbankspezialisierungen. In diesem Zusammenhang müssen die Datentypen dergestalt umgeformt werden, dass sie einen Datenaustausch mit der entsprechenden Datenbankvariante ermöglichen.[3]

Einfache praktische Anwendungen

Wie auch schon bei der Definition von Routen oder der Anwendung von Templates wie Hamlet, Cassius und Julius, ermöglichen Quasi-Quotations im Kontext der Definition von persistent zu speichernden Entitäten die automatische Code-Generierung bei gleichzeitig bequemer und lesbarer Konfiguration durch den Entwickler. So lässt sich beispielsweise durch den folgenden Codeausschnitt das zuvor gezeigte Einführungsbeispiel in der Datenbank etablieren.

1mkPersist [persist|
2Person
3    name String
4    age Int
5|]

Die folgende Befehlskette ermöglicht eine beispielhafte Interaktion mit dem soeben definierten Datenbankmodell.

1main = withSqliteConn ":memory:" $ runSqlConn $ do
2    personId <- insert $ Person "Max Mustermann" 32
3    person <- get personId
4    liftIO $ print person

Dieser Code ist so anschaulich, dass er sich nahezu von selbst erklärt. Der do-Block zwischen den Zeilen 2 und 4 enthält die eigentliche Interaktion mit der Datenbank. Wie in Zeile 1 beschrieben, erfolgt in diesem Fall der Austausch mit einer SQLite-Datenbank. Dabei handelt es sich um eine Single-Connection, welche die Verbindung mit der Datenbank nur für die hier aufgeführten Operationen aufrecht erhält. In der Praxis wird man eine Umsetzung präferieren, welche in der Lage ist, unterschiedliche Verbindungen zu verwalten und diese je nach Anwendungsfall auch länger aufrecht zu erhalten als dies hier geschieht. In Zeile 2 erfolgt zunächst das Anlegen einer 32jährigen Person mit dem Namen «Max Mustermann». Die Funktion insert erhält dazu einen Wert vom Datentyp Person und liefert die ID des angelegten Tabelleneintrags. Diese ID ist typsicher und auf die Entität Person bezogen. Dies stellt beispielsweise sicher, dass die ID nicht für eine andere Datenbanktabelle missbraucht werden kann. Die Funktion get empfängt diese ID und liefert den angeforderten Datensatz in Form des Haskell-Datentyps Person.[4]

Dieses Beispiel allein wird nicht funktionieren. Dies liegt daran, dass die Datenbanktabelle Person noch nicht existiert. Persistent trifft diese Vorkehrungen erst auf Anweisung. Fügt man dem obigen Beispiel zwischen den Zeilen 1 und 2 die Zeile runMigration $ migrate (undefined :: Person) bei, so wird die fehlende Tabelle entsprechend der Entitätskonfigurationen angelegt. Dies funktioniert ebenso automatisch für Änderungen am Datenbankschema. Über den Migration-Mechanismus versucht Persistent, die gewünschte Änderung am gedanklichen Datenbankschema durch ALTER TABLE-Anweisungen und notwendige Datenkopien auch auf technischer Ebene im relationalen Modell umzusetzen. Umbenennungen von Entitätsattributen sind von diesem Prozess jedoch leider ausgeschlossen. Ebenso ist das Löschen von Attributen aufgrund von Datenverlust sicherheitshalber standardmäßig deaktiviert, kann über die Funktion runMigrationUnsafe jedoch erzwungen werden. Eine hilfreiche Funktion in diesem Zusammenhang ist printMigration, welche den SQL-Code zurückgibt, welchen Persistent für die Schemaänderung durchführen würde. Dieser kann dann den eigenen Vorstellungen entsprechend angepasst und ausgeführt werden, um auch Änderungen durchzuführen, die allein mit Persistent nicht ohne weiteres möglich wären.[5]

Attribute

Persistent ermöglicht Entwicklern die Angabe zusätzlicher beschreibender Attribute für jedes Feld einer Entität. Beispielsweise kann jedem Entitätsfeld ein Maybe-Attribut beigefügt werden, um das Feld als optional zu kennzeichnen. Dies wird auf Datenbankebene über den Wert NULL realisiert. Über das Attribut default kann analog zur bekannten Konvention aus den Datenbanksprachen zudem ein Standardwert angegeben werden.[6]

Auch assoziierte Typen können im Rahmen der Beschreibung von persistenten Daten definiert werden. Dabei stehen die folgenden Attribute zur Verfügung.

In diesem Zusammenhang werden die obigen Vergleichsoperationen als für den Interaktionsprozess wohldefiniert beschrieben. In der Folge generiert Persistent automatisch Datenkonstruktoren für einen Filter der Entität. Eine gegebene Beschreibung

1mkPersist [persist|
2Person
3    name String Eq
4    age Int Lt
5|]

würde die Datentypdefinition data Filter Person = PersonNameEq String | PersonAgeLt Int einführen, so dass Personen durch den beispielhaften Aufruf

1main = withSqliteConn ":memory:" $ runSqlConn $ do
2    runMigration migrateAll
3    persons <- selectList
4        [ PersonNameEq "Tom"
5        , PersonAgeLt 18
6        ]
7        [] 0 0
8    liftIO $ print persons

ausgewählt werden würden, welche jünger als 18 sind und Tom heißen. Dies wird durch eine Liste von Filtern gewährleistet, deren Elemente nacheinander angewandt werden. Auf diese Weise kann auch bei Vergleichen im Rahmen einer Datenbankabfrage Typsicherheit erlangt werden, welche von vielen Datenbanken auf nativem Weg nicht garantiert würde.[7]

Anzumerken ist jedoch, dass bei Persistent nicht jede Art der Filterung von Tabelleneinträgen typsicher unterstützt wird. Möchten Entwickler eine Menge von Einträgen beispielsweise mit dem in SQL verfügbaren LIKE-Mechanismus einschränken, so ist dies nur über einen alternativen Ansatz zum Senden von Anfragen möglich, bei dem zu sendener roher SQL-Code anzugeben ist. In diesem Fall muss auf die Typsicherheit verzichtet werden.[8]

Relationen

Ein unverzichtbarer Bestandteil eines Webframeworks ist die Unterstützung von Beziehungen zwischen Entitäten. Selbstverständlich können auch bei Yesod anhand der Persistent-Erweiterung diese Verknüpfungen von Daten beschrieben und verwaltet werden. Das folgende Beispiel richtet eine Many-To-Many-Relation zwischen Personen und Hobbys ein. Eine Person kann also mehrere Hobbys haben, welche wiederum auch von anderen Personen gepflegt werden können.

1mkPersist [persist|
2Person
3    name String
4Hobby
5    name String
6PersonHobby
7    person PersonId
8    hobby HobbyId
9    UniquePersonHobby person hobby
10|]

Dabei fallen zunächst die Typen PersonId und HobbyId auf, welche nichts anderes als Alias-Typen für die entsprechenden Fremdschlüssel auf die referenzierte Entität darstellen. Im Falle des Verweises auf eine Person hat der Schlüssel z. B. den Typen type PersonId = Key Person, was den angesprochenen Mechanismus hervorhebt, dass Schlüssel unmittelbar mit der zugehörigen Datenbanktabelle verbunden sind. Der Ausdruck UniquePersonHobby person hobby führt schließlich einen UNIQUE-Key ein, welcher sicherstellt, dass das eine Hobby-Person-Beziehung nicht mehrfach gespeichert werden kann.[9]