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 ID1, 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-Funktion2 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:

  1. monadische Kommandos mocken

  2. 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.

  1. Dass unsere Adressen eine eindeutige ID benötigen, ergibt sich aus praktischen Gesichtspunkten: Es kann sein, dass wir aus Versehen doppelte Adressen in unserem Adressbuch abgespeichert haben. Wenn wir dann diese doppelten Adressen aufräumen wollen, können wir nicht einfach alle Adressen mit einem bestimmten Namen und einem bestimmten Ort löschen, weil wir dann ja alle diese Adressen löschen würden (die Adressen sind gleich im Sinne von struktureller beziehungsweise extensionaler Gleicheit weil sie den gleichen Wert repräsentieren). Stattdessen wollen wir ja nur die Doppelten löschen, die können wir nur mit einer eindeutigen ID identifizieren (intensionale Gleichheit würde das Problem auch lösen, macht aber nur Sinn bei mutierbaren Objekten, die wir aus vielen anderen Gründen aber immer vermeiden wollen). 

  2. Die monadischen Kommandos, die von dieser Monaden-Kommando-Konfiguration abgedeckt sind, sind in dieser Repräsentation implizit durch die Fälle der Interpreter-Funktion beschrieben.