Query-Parameter deklarativ parsen mit clojure.spec
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, wieclojure.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 mandefparam
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.