Monaden sind ein wichtiges Konzept in der funktionalen Programmierung mit
immensem praktischen Nutzen, auch hier im Blog haben wir schon viel darüber
geschrieben. Zum Beispiel zeigt der Artikel „Freie Monaden oder: Wie ich
lernte, die Unabhängigkeit zu
lieben“,
wie freie Monaden in Scala eingesetzt werden können, um die Ausführung eines
Programmes von dessen Beschreibung zu entkoppeln.
Hier werden wir die Fallstudie aus diesem Artikel aufgreifen und zeigen, wie wir
in Clojure die Entkopplung mit Monaden erreichen können. Dabei zeigen wir, wie
wir das nutzen können, um Testfälle zu schreiben; und wir werden zeigen, wie wir
ganz einfach Mock-Tests für unsere monadischen Programme angeben können.
Clojure
Clojure ist eine funktionale Programmiersprache, deren Syntax aus den aus
Lisp bekannten geklammerten Ausdrücken besteht. Clojure kompiliert nach
Java-Bytecode und läuft auf der Java Virtual Machine. Wir verwenden Clojure in
der Praxis in sehr vielen unserer Projekte und haben über die letzten Jahre eine
umfangreiche und frei verfügbare Clojure-Bibliothek namens Active
Clojure entwickelt, die wir in
unseren Projekten verwenden. Die Funktionalität aus dieser Bibliothek benutzen
wir in diesem Artikel.
Fallstudie Adressbuch
In dem Beispiel geht es um die Implementierung eines einfachen Adressbuchs, das
Adressen enthält.
Adressen
Eine Adresse besteht der Einfachheit halber nur aus ID, Name und Stadt, auf
Details wie Straße und PLZ oder andere Einschränkungen verzichten wir, damit das
Beispiel übersichtlich bleibt. Wir implementieren Adressen als zusammengesetzte
Daten mit Hilfe eines Records aus unserer
Active-Clojure-Bibliothek:
(define-record-type
^{:doc "An address"}
Address
(make-address id name town)
address?
[^{:doc "Id of address"}
id address-id
^{:doc "Name of address"}
name address-name
^{:doc "Town of address"}
town address-town])
Diese Record-Definition liefert uns Funktionen, mit denen wir Adressen
konstruieren können, zum Beispiel erzeeugt
(make-address 23 "Marcus" "Tübingen")
eine Adresse. Dazu liefert die Record-Definition Selektoren, mit denen wir auf
die Bestandteile einer Adresse zugreifen können. Zum Beispiel liefert
(address-town (make-address 23 "Marcus" "Tübingen"))
die Zeichenkette "Tübingen", die Stadt unserer Tübinger Beispieladresse.
Adressbuch-Funktionen
Es soll Funktionen geben, die es erlauben, Adressen im Adressbuch abzulegen und
wieder aus dem Adressbuch zu löschen:
(put-address <address>) ; eine Adresse im Adressbuch speichern
(delete-address <id>) ; eine Adresse aus dem Adressbuch löschen
Beide Funktionen haben keinen nützlichen Rückgabewert, sie geben einfach nil
zurück.
Die Funktion get-address gibt eine Adresse anhand ihrer ID aus dem Adressbuch
zurück:
(get-address <id>) ; liefert Adresse aus Adressbuch
Außerdem soll es eine Funktion geben, die alle Adressen heraussucht, die auf ein
bestimmtes Prädikat passen, also einen Filter für Adressen:
(filter-addresses <predicate?>) ; filtert Adressen aus dem Adressbuch heraus
Diese Funktion liefert eine Liste von Adressen, die auf das Pädikat
<predicate?> passen. Ein Prädikat ist eine einstellige Funktion, die true
oder false zurück gibt. Zum Beispiel ist dies ein Prädikat, das true
ergibt, wenn die Adresse in Tübingen ist, ansonsten liefert es false:
(defn in-tübingen?
[address]
(= "Tübingen" (address-town address)))
Der Aufruf
(filter-addresses in-tübingen?)
liefert dann eine Liste aller Adressen aus unserem Adressbuch, die in Tübingen
sind.
Adressbuch-Datenbank
Wir können die aufgeführten Funktionen jetzt so implementieren, dass sie über
einen Datenbank-Treiber direkt in eine Datenbanktabelle schreiben. Für die
Kommunikation mit der Datenbank verwenden wir
Clojure-JDBC. Das sieht dann so aus:
(defn put-address
[db-connection address]
(jdbc/insert! db-connection "adresses" (into {} address)))
(defn delete-address
[db-connection id]
(jdbc/delete! db-connection
["SELECT * FROM addresses where id = ?" id]
{:row-fn map->Address}))
(defn get-address
[db-connection id]
(first (jdbc/query db-connection ["SELECT * FROM addresses where id = ?" id]
{:row-fn map->Address})))
(defn filter-addresses
[db-connection predicate?]
(filter predicate?
(jdbc/query db-connection ["SELECT * FROM addresses"]
{:row-fn map->Address})))
Clojure-JDBC verwendet Maps als Datenrepräsentation, unsere Adressen-Records
können wir mit (into {} address) in Maps mit den Feldnamen als Keys umwandeln
und mit map->Address von so einer Map zurück in einen Record. Die Funktion
map->Address ist eine Funktion, die unsere Record-Definition liefert. Wenn
wir die Spalten unserer Tabelle also so nennen, wie die Felder im Record heißen,
dann reicht diese Datenkonvertierung aus. Das Datenbanktabellen-Schema sollte
also so aussehen:
CREATE TABLE addresses (
ID INT,
NAME VARCHAR,
TOWN VARCHAR
);
Die Funktion put-address fügt also via jdbc/insert! eine Adresse in die
Datenbanktabelle adresses ein, delete-address löscht sie via jdbc/delete!
wieder, get-address gibt die Adresse, die zur übergebenen ID passt, via
jdbc/query zurück und filter-addresses selektiert alle Adressen via
jdbc/query und filtert sie mit der in Clojure eingebauen filter-Funktion
anhand des übergebenen Prädikats.
So nicht!
Diese Implementierung funktioniert zwar, enthält aber mehrere Probleme, die
ausführlicher in diesem
Artikel
beschrieben sind:
-
Das Programm ist schwer testbar, da man nur schwer Unit-Tests schreiben kann und
auch für einfache Integrationstests ein Datenbank-Backend braucht.
-
Die Seiteneffekte werden sofort ausgeführt und Mehrfachausführungen liefern
unterschiedliche Ergebnisse.
-
Die Schnittstelle ist zu speziell, da das Datenbank-Verbindungs-Objekt
existieren und immer mitgegeben werden muss.
Die Kopplung zur Datenbank behindert die Entwicklung und zukünftige Anpassungen
und muss weg.
Sprache für Adressbuch-Funktionen
Der erste Schritt zur Entfernung der Kopplung ist, dass wir unsere Operationen
als Daten repräsentieren anstatt als Funktionen mit Seiteneffekten. Wir können
in Clojure unsere Operationen als Records definieren:
(define-record-type
^{:doc "Put an address into the address book"}
PutAddress
(put-address address)
put-address?
[^{:doc "Address to put into the address book"}
address put-address-address])
(define-record-type
^{:doc "Get an address from the address book"}
GetAddress
(get-address id)
get-address?
[^{:doc "ID of the address to get from the address book"}
id get-address-id])
(define-record-type
^{:doc "Delete an address from the address book"}
DeleteAddress
(delete-address id)
delete-address?
[^{:doc "ID of the Address to delete from the address book"}
id delete-address-id])
(define-record-type
^{:doc "Filter the address book"}
FilterAddresses
(filter-addresses predicate?)
filter-addresses?
[^{:doc "Predicate to filter the address book"}
predicate? filter-addresses-predicate?])
Mit Listen aus diesen Operationen können wir jetzt schon mal einfache Programme
beschreiben, zum Beispiel eine Adresse einfügen und gleich wieder löschen:
(put-address (make-address 23 "Marcus" "Tübingen"))
(delete-address 23)
Um sinnvollere Programme zu beschreiben, die auch komplexe Zusammenhänge
abbilden, brauchen wir aber noch die Möglichkeit, Zwischenergebnisse zu binden,
zum Beispiel um eine Prozedur zu schreiben, die alle Tübinger Adressen löscht.
Monadic
Monadische Operationen können wir mit bind (oft auch flatMap genannt)
verknüpfen und die Zwischenergebnisse zur nächsten Operation weitergeben. Das
händische Verknüpfen der Operationen mit bind ist allerdings mühsam und schwer
leserlich, daher benutzen wir mit monadic spezielle Syntax aus unserer
Active-Clojure-Bibliothek für
Monaden
active.clojure.monad, mit der wir monadische Programme kompakter aufschreiben
können.
Damit können wir ein Programm schreiben, das alle Tübinger Adressen löscht:
(monad/monadic
[tübinger (filter-addresses in-tübingen?)]
(monad/sequ (map (fn [address]
(delete-address (address-id address)))
tübinger)))
Das Programm bindet zunächst das Ergebnis der monadischen Berechnung von
(filter-addresses in-tübingen?) an die Variable tübinger, tübinger enthält
also eine Liste aller Tübinger Adressen. Die letzte Zeile iteriert dann mit
map über die Liste aller Tübinger Adressen und nutzt delete-address, um die
Adressen zu löschen. Da delete-adress selbst wieder eine monadische Operation
ist, ist das Ergebnis eine Liste von monadischen Operationen, die wir mit dem
eingebauten Kommando monad/sequ auswerten können.
Dieses Programm können wir in eine Funktion einbauen:
(defn remove-addresses-in-tübingen
"Remove all addresses that are located in Tübingen."
[]
(monad/monadic
[tübinger (filter-addresses in-tübingen?)]
(monad/sequ_ (map (fn [address]
(delete-address (address-id address)))
tübinger))))
Wir haben nun also eine Repräsentation für Operationen, Zwischenergebnisse und
Abstraktionen. Was noch fehlt, ist die Ausführung. Dazu benötigen wir einen
Interpreter, der ein monadisches Programm entgegennimmt und alle Operationen
des Programms ausführt. Oben haben wir ja bereits eine direkte
Datenbank-Implementierung geschrieben. Diese Implementierung nutzen wir nun im
Interpreter für unsere Adressbuch-Monade:
(defn database-run-command
"Run a monadic address book program."
[run env state m]
(cond
(lang/put-address? m)
[(db/put-address (::db-spec env) (lang/put-address-address m))
state]
(lang/delete-address? m)
[(db/delete-address (::db-spec env) (lang/delete-address-id m))
state]
(lang/get-address? m)
[(db/get-address (::db-spec env) (lang/get-address-id m))
state]
(lang/filter-addresses? m)
[(db/filter-addresses (::db-spec env) (lang/filter-addresses-predicate? m))
state]
:else
monad/unknown-command))
Funktionen, die Interpreter implementieren, heißen in
active.clojure.monad-Konvention in der Regel run-command. Diese Interpreter
akzeptieren vier Argumente, wobei das letzte Argument m das monadische
Programm ist. Im Rumpf des Interpreters findet eine Fallunterscheidung auf das
monadische Programm m statt, je nach Kommando (per Konvention meist im
lang-Namespace) leitet der Interpreter auf die entsprechende Funktion der
Datenbank-Implementierung im Namespace db weiter.
Weitere Argumente sind eine Funktion run (um möglicherweise verschachtelte
monadische Kommands zu interpretieren), eine initiale Umgebung env und einen
initialen Zustand state; Umgebung und Zustand sind als Maps repräsentiert.
Der Rückgabewert des Interpreters ist ein Tupel aus dem Rückgabewert der
Datenbank-Implementierung und dem Zustand, der sich in diesem Interpreter aber
nicht verändert, state wird also in allen Fällen unverändert zurückgegeben.
Wir sehen gleich noch einen anderen Interpreter, der den Zustand verändert.
Außerdem gibt der Interpreter den Spezialwert monad/unknown-command zurück,
wenn er die übergebene Operation nicht kennt.
Dieser Interpreter erwartet das Datenbank-Verbindungs-Objekt in der Umgebung
env und gibt es an die Datenbank-Implementierung weiter. Dieses
Datenbank-Verbindungs-Objekt müssen wir in der initialen Umgebung zur Verfügung
stellen, dann kümmert sich der Interpreter aber selbst darum, dass es an alle
Datenbank-Funktionen weitergegeben wird – unser monadisches Programm muss sich
nicht darum kümmern.
In der Active-Clojure-Monade ist eine Monaden-Kommando-Konfiguration eine
Abstraktion für eine Interpreter-Funktion zusammen mit der zugehörigen
initialen Umgebung und dem zugehörigen initialen Zustand.
monad/make-monad-command-config erzeugt eine solche Abstraktion, bei uns sieht
das so aus:
(defn database-monad-command-config
[db]
(monad/make-monad-command-config database-run-command
{::db-spec db}
{}))
Die Interpreter-Funktion heißt bei uns database-run-command, in der initialen
Umgebung legen wir unter dem Schlüsselwort ::db-spec das
Datenbank-Verbindungs-Objekt ab; der initiale Zustand ist die leere Map {}.
Wirklich laufen lassen können wir ein monadisches Programm mit
monad/execute-monadic, das eine Monaden-Kommando-Konfiguration und das
Programm akzeptiert. Hier ist eine Beispielbenutzung:
(monad/execute-monadic (database-monad-command-config db)
(put-address (make-address 23 "Marcus" "Tübingen")))
Offen lassen wir hier absichtlich die Definition des
Datenbank-Verbindungs-Objekt db. Der vollständige, funktionierende Code aus
diesem Blog-Posting haben wir in diesem
Github-Repo
veröffentlicht. Darin ist auch eine H2-in-memory-Datenbank mit zugehöriger
Initalisierung in der Monaden-Kommando-Konfiguration abstrahiert, worauf wir in
diesem Blog-Posting nicht weiter eingehen werden.
Testen
Die monadischen Kommandos als Indirektion vor der komplizierten
Datenbank-Implementierung bieten uns jetzt verschiedene Möglichkeiten, unsere
Programme – also unsere Businesslogik – zu testen, ohne dass wir die
Datenbank – oder andere integrierte Umgebungen – aufwändig dafür
berücksichtigen müssen. Zwei Möglichkeiten schauen wir uns an:
-
monadische Kommandos mocken
-
Test-Interpreter, der keine Datenbank benutzt
monadische Kommandos mocken
Die Active-Clojure-Bibliothek stellt zum Testen von monadischen Programmen einen
einfachen Testinterpreter bereit, der die ausgeführten Operationen lediglich
aufzeichnet und mit einer Liste von erwarteten Operationen abgleicht – und die
Ergebnisse der Operationen mocken kann.
mock/mock-run-command bekommt als erstes Argument eine Liste der erwarteten
Operationen – repräsentiert als Mock-Ergebnisse – und als zweites Argument
das monadische Programm. Ein Mock-Ergebnis besteht aus dem erwarteten
monadischen Kommando und dem Rückgabewert, mit dem das Programm weitermachen
soll. Hier ist ein Beispiel dafür:
(deftest t-state-put-get
(is (= (make-address 2 "Marcus" "Tübingen")
(mock/mock-run-monad
[(mock/mock-result (put-address (make-address 2 "Marcus" "Tübingen"))
true)
(mock/mock-result (get-address 2)
(make-address 2 "Marcus" "Tübingen"))]
(monad/monadic
(put-address (make-address 2 "Marcus" "Tübingen"))
(get-address 2))))))
Hier sieht man, dass man so zwar die Reihenfolge der erwarteten Kommandos testen
kann, aber hierzu doch auch oft das Verhalten in den Mock-Ergebnissen
implementiert werden muss, vor allem wenn wie hier Ergebnisse von vorherigen
Kommandos abhängen. Durch die flexible Kombinierbarkeit von
Monaden-Kommando-Konfigurationen ist es so aber zum Beispiel möglich, bestimmte
Kommandos tatsächlich interpretieren zu lassen und andere zu mocken.
Test-Interpreter
Anstatt die Adressen in eine externe Datenbank zu schreiben, können wir auch
andere Backends benutzen. Und für Tests der Business-Logik würde ein einfacher
Interpreter ausreichen, der die Adressen zum Beispiel im Monadenzustand
speichert. So ein Interpreter könnte so aussehen:
(defn state-run-command
"Run a monadic address book program --- with state as simple backend storage."
[run env state m]
(cond
(lang/put-address? m)
(let [address (lang/put-address-address m)]
[true
(assoc state (address-id address) address)])
(lang/get-address? m)
[(get state (lang/get-address-id m))
state]
(lang/delete-address? m)
[true
(dissoc state (lang/delete-address-id m))]
(lang/filter-addresses? m)
[(filter (lang/filter-addresses-predicate? m) (vals state))
state]
:else
monad/unknown-command))
Hier repräsentieren wir unsere Adressen-Datenbank als Clojure-Map im
Monadenzustand – und die Monadenimplementierung kümmert sich darum, dass der
Zustand von Auswertungsschritt zu Auswertungsschritt weitergegeben wird; darum
müssen wir uns nicht selbst kümmern.
Damit haben unsere Testfälle überhaupt keine Abhängigkeiten in die
Infrastruktur. Allerdings besteht die Gefahr, dass der Test-Interpreter und der
Datenbank-Interpreter sich abweichend verhalten – aber das könnten wir mit
Tests auf anderer Ebene abdecken. Und unsere eigentliche Business-Logik
ausgiebig ohne die Datenbank testen.
Fazit
Monaden erlauben uns, Programme zu schreiben, die unabhängig von der späteren
Ausführung sind. Kopplung an komplexe Infrastruktur vermeiden wir so. Dadurch
gewinnen wir eine kompaktere Notation, höhere Verständlichkeit und bessere
Testbarkeit für unsere Business-Logik.