Dieser Blogpost stellt eine neue Core-Library vor, die in Clojure 1.9 enthalten sein und clojure.spec heißen wird. Nach einer kurzen Einleitung zeigen wir, wie wir clojure.spec für nicht-triviale Aufgaben wie das Spezifizieren, Validieren und das Bearbeiten von HTTP-URL-Query-Parametern verwenden.

Was ist clojure.spec?

clojure.spec ist eine neue Core Library für das Validieren von dynamischen Datenstrukturen, die in Clojure 1.9 enthalten sein wird. Da Clojure eine sehr dynamische Sprache ist, werden Domänenmodelle in der Regel eher implizit als explizit gehalten. Einige Leute verwenden Records, um ihre Domäne zu modellieren, während andere einfache Maps ohne explizite Strukturen bevorzugen.

Während dieser Ansatz die Entwicklung gerade in der frühen Phase der Implementation beschleunigt, ist es oft notwendig Daten, gründlich zu validieren, wenn die Code-Basis wächst. Während Clojure selbst schon Prädikate für einfache Fälle wie string?, integer?, vector?, etc. bietet, ist es für gewöhnlich schwieriger, komplexe Strukturen zu validieren. Bis jetzt enthielten die meisten unserer Code-Basen nur unregelmäßige (assert (valid-foo? some-value)). Richtige Validierung war begrenzt auf die Teile unseres Systems, die Daten von der Außenwelt entgegennehmen oder an sie übertragen.

Es gibt viele verschiedene andere Libraries für die Validierung komplexer Daten. Zu nennen wäre insbesondere plumatic/schema, aber auch hier bleiben unserer Meinung nach Wünsche offen. Insbesondere ist es keine gute Grundlage für komplexe Features, da es nicht genug Flexibilität und "Hackbarkeit" bietet.

Was ist also so besonders an clojure.spec? Der größte wahrnehmbare Unterschied ist die Verwendung eines einzigartigen Clojure Features: namespaced keywords.

Der häufigste Einsatzfall von Keywords in Clojure ist als Key zu einem Value in einer Map: (get user :name) (mit (def user {:name "Rich Hickey"}) gibt zum Beispiel den String "Rich Hickey" zurück. Viel seltener genutzt ist der optionale Namespace-Teil eines Keywords, den man sich wie eine Art Präfix vorstellen kann.

(Wir werden nicht weiter auf das Auto-Präfix-Feature eingehen, das Meikel Brandmeyer bereits detailliert beschrieben hat.)

Wie also nutzt clojure.spec dieses Feature? Es erlaubt, eine Validierungs-Funktion für jedes Keyword zu schreiben! Natürlich ergibt es wenig Sinn diese Funktionen in einem globalen Scope für Keywords ohne Namespace zu haben. Wer weiß schon, ob :name den Namen eines Benutzers oder den Namen einer Straße bezeichnen soll? Die Lösung in Clojure ist hier, die Keywords mit Hilfe eines Präfix eindeutig zu machen: :street/name and :person/name. Das ist der gleiche Ansatz, den auch Datomic für Attribute nutzt.

Einfache Anwendungsfälle mit clojure.spec

Einfache Validierung sieht folgendermaßen aus:

(s/def :person/name string?)
(s/def :album/year  int?)

(s/valid? :person/name "Rich Hickey")
;;=> true
(s/valid? :album/year "1977")
;;=> false, :album/year is a string
(s/valid? :album/year  1977)
;;=> true

Komplexere Validierung ist ebenfalls unterstützt. Ein Vector aus Releasejahren von Alben kann wie folgt validiert werden (Man beachte, dass wir ein existierendes Prädikat für :album/year wiederverwenden):

(s/valid? (s/coll-of :album/year) [12345 53563]) ;=> true

Maps haben eine besondere Stellung, die clojure.spec einzigartig macht: Sie sind definiert als eine Menge von Keys, die jeweils Specs für sich selber sind.

(s/def :person/name string?)
(s/def :person/age  (s/and integer? pos?))
(s/def :address/street string?)

(s/def :model/user (s/keys :req [:person/name
                                 :person/age]
                           :opt [:address/street]))

Dieses Beispiel definiert eine neue Spec, genannt :model/user mit zwei erfoderlichen und einem optionalen Key. Es wird folgendermaßen verwendet:

(s/valid? :model/user
          {:person/name "Rich Hickey"
           :person/age 23                    ; (schlecht) geraten
           :address/street "Paren Boulevard" ; auch geraten
           })
;=> true

(s/valid? :model/user
          {:person/name "Rich Hickey"
           :person/age 23})
;=> true

(s/valid? :model/user
          {:person/name "Rich Hickey"
           :person/age -23})
;=> false, :person/age ist negativ

Das sind nur die Basis-Features von clojure.spec. Andere enthaltene Features sind das Generieren von Daten, die die Specs erfüllen (hilfreich beim Testen), Umwandlung von Daten eines Types zu einem anderen und Generierung wiederverwendbarer Fehlermeldungen und Fehlerdaten.

Eine detallierte Anleitung der meisten dieser Features gibt es im Spec Guide.

Weitergedacht

Eine unsere Kernaufgaben ist das Bereitstellen von HTTP-API-Endpunkten für unsere Kunden. Daten kommen herein, werden geparst, validiert, verarbeitet und eine Antwort wird an den Client gesendet.

Wir verwenden Liberator für unsere Endpunkte, sodass wir sie folgendermaßen deklarieren können:

(defresource submit-data
  :allowed-media-types ["application/json"]
  :allowed-methods #{:post}
  :processable? (fn [ctx]
                  ;; 1) Extract client-data
                  ;; 2) Validate
                  ;; 3) Generate error message if invalid
                  ))

Unsere APIs sind relativ datenintensiv: Einige Endpunkte haben recht viele Query-Parameter, andere empfangen komplexe multipart/form-data- oder JSON-Dokumente.

Zum Parsen und Validieren enhalten unsere Endpunkte oft komplexe ad-hoc-Logik in ihrer :processable?-Eintscheidung (Liberator nennt diese Funktionen decisions), die weder wiederverwendbar noch leicht verständlich ist. Liberator selbst stellt hierfür keine Werkzeuge bereit und Entscheidungsfunktionen lassen sich nicht gut schreiben.

Außerdem wollen wir API-Dokumentation aus unserem Code generieren könnnen, ohne den Wartungsaufwand zu haben, Dokumentation und die Logik der Implementierung von processable? synchron zu halten.

Was wir brauchten war die Spezifikation von Parameternamen, validen Eingabedaten und Tranformationsfunktionen pro Endpunkt in deklarativer Form. Wie sich rausstellt, erfüllt clojure.spec diese Anforderungen.

Nehmen wir an, dass wir einen Endpunkt implementieren wollen, der zwei Query-Parameter entgegennimmt: limit und offset. Beide Integer, beide größer oder gleich Null. Ohne clojure.spec sähe die :processable?-Entscheidung folgendermaßen aus:

(fn [ctx]
  (let [request (:request ctx)
        {:strs [offset limit]} (:query-params request)
        offset* (string->long offset)
        limit*  (string->long limit)]
    (cond
      (nil? offset) [false "Missing parameter 'offset'"]
      (nil? limit)  [false "Missing parameter 'limit'"]

      (nil? offset*) [false "Couldn't parse value for parameter 'offset'"]
      (nil? limit*)  [false "Couldn't parse value for parameter 'limit'"]

      (neg? offset*) [false "Parameter 'offset' can't be negative "]
      (neg? limit*)  [false "Parameter 'limit' can't be negative "]

      true
      [true {::offset offset*
             ::limit limit*}])))

Hier werden nur zwei Parameter validiert und geparst und zwar nicht besonders gut, da die Fehldermeldungen nicht gesammelt, sondern nur die erste zurückgegeben wird. Das bedeutet z.B., dass wir nur die erste Fehlermeldung für offset erhalten, wenn wir überhaupt keine Parameter übergeben.

Man stelle sich vor, diese Validierung regelmäßig für mehrere Parameter, von denen einige optional sind, sowie die Behandlung von Standardwerten und beliebigen Transformationen zu erstellen. Es würde sehr schnell sehr unübersichtlich. An das Generieren von API-Dokumentation aus diesem Haufen imperativem Code ist gar nicht erst zu denken.

Mit dem Ziel, deklarativ zu sein (und somit im Stande, API-Dokumentation zu generieren), evaluierten wir verschiedene Optionen. Man kann natürlich einfach eine Funktion schreiben, die eine Map mit Parametername und Transformations-/Validierungsfunktionen engegennimmt. Aber auch so wären Validierung, Transformation und zukünftig auch Dokumentation miteinander vermischt.

Enter clojure.spec

Als Rich Hickey clojure.spec ankündigte, waren viele nicht beeindruckt. Alle definierten Specs sind in einer globalen Variable gespeichert und von überall aus einem Programm heraus erreichbar. Dadurch könnten sehr einfach Namenskonflikte verursacht werden, wie bereits weiter oben an dem Beispiel von :name gezeigt wurde. Die Schönheit der Problemlösung, die clojure.spec anbietet, liegt darin, dass Keywords, die keinen Namespace haben, in der internen Datenbank nicht zugelassen sind.

Angewandt auf unsere Query-Parameter schien eine ähnliche Datenbank eine gute Lösung darzustellen.

Diese Datenbank würde alle oben erwähnten Informationen enthalten: Interne und externe Parameternamen, Transformations- und Validierungsfunktionen, Dokumentation und weiteres.

Soweit ist dies noch nichts Besonderes – der wahre Nutzen tritt erst in der Verbindung mit clojure.spec zu Tage.

Unser Ansatz einer globale Datenbank von Parameterinformationen verwendet die gleichen Identifier (in Form von Keywords mit Namespace) wie clojure.spec. Dadurch können wir ein Endpunkt, ähnlich wie weiter oben, folgendermaßen definieren:

(require '[clojure.spec :as s])

(s/def ::limit  integer?)
(s/def ::offset integer?)

(defparam ::limit  "limit"  string->integer)
(defparam ::offset "offset" string->integer)

(defresource submit-data
  :allowed-media-types ["application/json"]
  :allowed-methods #{:post}
  :query-params {:req [::limit
                       ::offset]})
  ;; `::limit` und `::offset` sind über `ctx` in späteren
  ;; Liberator-Funktionen verfügbar, genau wie in unserer vorherigen
  ;; manuellen Implementierung

Dieser Code definiert zwei neue Specs: Eine für ::limit und eine andere für ::offset. Beide prüfen, ob der Wert ein Integer ist.

defparam gibt den Namen des Query-Parameters für diesen Identifier an, sowie die Transformationsfunktion, die vor der Validerung angewandt wird. Hier deklarieren wir, dass der Parameterwert in einen Integer umgewandelt werden soll (Werte von Query-Parameter-Werte sind in Liberator/Ring immer Strings).

Eine eigene :processable?-Funktion, die automatisch in unserem defresource Wrapper verwendet wird, interpretiert die :query-params-Map und erledigt die Tranformation und Validierung für uns.

Die Struktur von :query-params basiert lose auf dem, was clojure.spec/keys akzeptiert. Es gibt :req, welches einen Vektor von erforderlichen Query-Parametern spezifiziert und :opt für optionale Parameter.

Im Falle eines Validierungsfehlers wird das Ergebnis von clojure.spec/explain-data verwendet, um daraus hilfreiche Fehlermeldungen für API-Clients zu erzeugen. Dieser datengetriebene Ansatz erlaubt es außerdem, die Fehlermeldungen abhängig von dem Accept Header, der vom Client gesendet wird, beispielsweise als JSON oder HTML an den Client auszuliefern.

Zusätzliche Validierung via :spec

Die :query-params-Deklaration erlaubt es uns, jeden Query-Parameter für sich zu validieren. Aber machmal liegen Interdependenzen vor, sodass die Parameter-Map als ganzes validiert werden muss. Dafür unterstützt :query-params einen :spec-Key.

Die Benutzung ist recht einfach:

;; `::location` is a map with both latitude and longitude and must fit
;; some predicate
(s/def ::location (s/and (s/keys :req [::latitude
                                       ::longitude])
                         ;; any other predicate
                         valid-location?))

(defresource submit-data
  :allowed-media-types ["application/json"]
  :allowed-methods #{:post}
  :query-params {:spec ::location
                 :req [::latitude
                       ::longitude]})

Hier werden zuerst wie im vorigen Beispiel ::latitude und ::longitude extrahiert und validiert. Anschließend wird die ::location-Spec mittels clojure.spec/conform auf das Ergebnis angewandt. Auch hierbei werden hilfreiche Fehlermeldungen via clojure.spec/explain-data erzeugt.

Handling von dynamischen Parametern

Ein spezieller Anwendungsfall ist das dynamische Spezifizieren der Parameter-Map. Zum Beispiel hat Liberator keine unterschiedlichen Entscheidungen für unterschiedliche HTTP Methoden wie GET und POST.~:query-params~ unterstützt, analog zu den anderen Liberator-Optionen, dass man statt einer Map eine Funktion angibt, die ctx als als Argument entgegenimmt.

Ein Endpunkt, der nur Query-Parameter für GET akzeptiert, könnte folgendermaßen aussehen.

(defresource paginated-get
  :allowed-media-types ["application/json"]
  :allowed-methods #{:get :post}
  :query-params (fn [ctx]
                  (when (= :get (get-in ctx [:request :request-method]))
                    {:req [::limit
                           ::offset]})))

Natürlich ist der Nachteil dieses Ansatzes, dass wir keine API-Dokumentation generieren können, da wir uns wieder in imperatives Territorium begeben. Glücklicherweise kommt dieser Fall bisher nur ein einziges Mal in unser Code-Basis vor. Wir evaluieren noch, wie wir es deklarativer machen könnten.

Hier zeigt sich aber auch eine Limitierung von Liberator: Es wird nicht zwischen verschiedenen Requestmethoden unterschieden. Die selbe :processable? Funktion wird sowohl für GET, POST, etc. verwendet. Das zwingt Benutzer dazu, prozeduralen Code zu schreiben, wenn sie verschiedene Implementierungen wollen. juxt/yada löst dieses Problem, indem es verschiedene Sets von Parametern für verschiedene Requestmethoden erlaubt.

Transformation

Zusätzlich zu dem dritten Parameter von deftransform (transform-fn), ist es möglich, clojure.spec/conformer zu verwenden, um den Input vor oder nach der Validierung zu transformieren.

Man könnte argumentieren, dass wir transform-fn entfernen und durch diesen Ansatz ersetzen könnten, aber der Nachteil wäre, dass es die Definition einer Spec und wie diese von dem Request gelesen wird vermischen würde. Die Schönheit von clojure.spec liegt aber gerade in der Wiederverwendbarkeit. Wir wollen unser Domänenmodell nicht mit der externen Repräsentation vermischen.

Zusammengesetzte Parameter

Das letzte Feature, das ich erwähnen möchte, sind zusammengesetzte Parameter (compount parameters). Konzeptionell gruppiert ein solcher Parameter eine Menge von Parametern, sodass entweder alle oder keiner der Parameter spezifiziert werden muss. Zusätzlich werden diese Parameter nicht direkt zu ctx hinzugefügt, sondern als eine seperate Map unter dem Namen des zusammengesetzten Parameters.

Unser Paginierungs-Beispiel würde mit zusammengesetzten Parametern folgendermaßen aussehen:

(s/def ::limit  integer?)
(s/def ::offset integer?)

(defparam ::limit  "limit"  string->integer)
(defparam ::offset "offset" string->integer)

;;; This `s/def` is optional - if our code finds a spec for a compound
;;; param it will use it for validation in addition to validating all
;;; "basic" parameters
(s/def ::pagination (s/keys :req [::limit ::offset]))
(def-compound-param ::pagination #{::limit ::offset})

(defresource submit-data
  :allowed-media-types ["application/json"]
  :allowed-methods #{:post}
  :query-params {:req [::pagination]})
  ;; `::limit` and `::offset` are available under `(::pagination ctx)`

Obwohl es anfangs sehr langatmig aussieht, erlaubt es uns viel mehr Parameter sehr einfach wiederzuverwenden. Zum Beispiel ist ein geläufiger Parameter in unserem Code ::location, welcher wiederrum aus drei Parametern besteht (lat, lon, name). Ohne zusammengesetzte Parameter, müssten sie von jedem Endpunkt in dessen :query-params-Map wiederholt werden und wir müssten alle Endpunkte bearbeiten, würden wir uns dazu entscheiden, etwas an diesen Parametern zu ändern.

Zusammenfassung

Die Kombination aus clojure.spec und unserem Code in Liberator vereinfacht unseren Code ungemein. Insgesamt sind wir mit dem Endergebnis sehr zufrieden.

Unsere Implementierung mit einer globalen Datenbank erlaubt uns auch, statisch zu überprüfen, ob alle Parameter in einem Endpunkt ebenfalls in defparam deklariert sind, um Laufzeifehler zu vermeiden.

Andersherum ist es sogar noch wichtiger: Wenn wir unsere internen Datenstrukturen zu einer externen Repräsentation konvertieren, die an Clients gesendet wird. Darauf werden wir detaillierter in einem späteren Blogpost eingehen.

Allerdings gibt es wie immer einige potentielle Fallen:

  • Das Einführen einer globalen Datenbank für Query-Parameter birgt Probleme im Bezug auf den globalen Zustand: Das Definieren von Parametern erzeugt Seiteneffekte, sodass das System, auf dem man interaktiv arbeitet, unter Umständen nicht identisch mit dem System ist, das aus dem Quellcode erstellt wird.

    Dies kann man durch Neuladen und Neustarten des kompletten System umgehen, wie es von Stuart Sierra in My Clojure Workflow, Reloaded beispielhaft beschrieben wird.

  • Auswertungsreihenfolge: Wir haben defparam so implementiert, dass es uns nicht gestattet, noch nicht existierende Specs als Parameter zu deklarieren. Das widerspricht zwar dem, wie clojure.spec nicht existierende Specs behandelt (sie werden zur Laufzeit aufgelöst), jedoch wollen wir Fehler während der Entwicklung so schnell wie möglich fangen.

    Das bedeutet für die Auswertungsreihenfolge: Man muss clojure.spec/def aufrufen, bevor man defparam aufruft. Das mag etwas strikt klingen, stellte sich jedoch nicht als Problem raus.

Ideen für die Zukunft

Automatische Reflection via clojure.spec

Eine Sache, die wir ziemlich früh bei der Umstellung unserer Code-Basis auf clojure.spec und dem Einbau der hier vorgestellten Features vermissten, war die Möglichkeit der Reflection beliebiger Specs. Es hätte uns an einigen Stellen sehr geholfen (zusammengesetzte Parameter, :spec-Validierung), würde clojure.spec uns eine Liste aller erforderlichen (:req) und optionaler (:opt) Eintrage für clojure.spec/keys-Specs geben. Wir bräuchten def-compound-param (und möglicherweise :spec) nicht mehr.

Doch Reflection ist ein zweischneidiges Schwert: Während sie in einigen Situationen sehr nützlich wäre, gibt es keinen Weg, sie komplett generisch zu halten. Was würde die hypothetische reflect-spec-Funktion für das folgende komplexe Konstrukt zurückgeben?

(s/or :case1 (s/keys :req [::foo])
      :case2 (s/map-of string? (s/or :i integer?
                                     :f float?)))

Es ist möglich, clojure.spec/form zu verwenden, um die ursprünglichen Ausdrücke zu erhalten, mit der eine Spec deklariert wurde, aber wir entschieden uns dagegen, diese zu verwenden. Denn während dieser Ansatz ziemlich gut für einfache Specs wie (clojure.spec/keys :req [:foo]) funktionieren würde, scheitert er recht schnell bei komplexeren:

user> (s/form (s/map-of string? int?))
(clojure.spec/every
 (clojure.spec/tuple string? int?)
 :into
 {}
 :clojure.spec/kind-form
 clojure.core/map?
 :kind
 #function[clojure.core/map?--6182]
 :clojure.spec/kfn
 #function[user/eval69383/fn--69384]
 :clojure.spec/conform-all
 true)
Beliebige Daten an eine Spec anhängen

Etwas anderes, das Teile unserer Implementierung weiter vereinfachen würde, wäre die Möglichkeit beliebige Daten an einen Spec zu hängen, wie in dem folgendem Beispiel.

(s/def ::offset pos-int?  
  {::doc "A non-negative offset"
   ::query-param/name "offset"})

(s/extra-data ::offset)
;=> {::doc "An Integer", ::query-param/name "offset"}

Das würde uns erlauben, unsere eigene Datenbank zu entfernen und stattdessen alles über clojure.spec abzubilden. Namenskonflikte könnten ebenfalls einfach verhindert werden, indem man wiederum keine Keywords ohne Namespce der extra-data-Map verböte.

Wir denken, dass dies ein sehr nützliches Feature wäre, das neue Möglichkeiten für clojure.spec eröffnen könnte.