Bibliothek für Konfigurationen
Software muss konfigurierbar sein, um flexibel zu sein. Eine Konfiguration legt Parameter und Einstellungen für eine Software fest. Meist sind die Einstellungen in einer Konfigurationsdatei gespeichert, welche die Software einliest. Aber wie stellen wir sicher, dass eine Konfiguration vollständig und gültig ist? Also dass alle Aspekte, die konfiguriert werden müssen, auch konfiguriert sind? Dass es sinnvolle Voreinstellungen gibt für nicht explizit konfigurierte Werte? Und dass die Werte, die in der Konfiguration eingetragen sind, auch sinnvolle Werte sind?
Um diese Fragen nicht für jedes Projekt neu zu beantworten, haben wir für Clojure und ClojureScript eine Bibliothek für Konfigurationen entwickelt, die wir seit vielen Jahren in der Praxis erfolgreich einsetzen – und in diesem Artikel vorstellen.
Konfiguration
Nach typischer Clojure-Art repräsentieren wir Konfigurationen als verschachtelte Key-Value-Maps.1 Solche Konfigurations-Maps bestehen aus Einstellungen Settings und Abschnitten Sections. Hier ein einfaches Beispiel für eine Konfiguration einer Applikation mit einem Webserver:
{:log-level :info
:webserver {:host "0.0.0.0"
:port 80}}
Die Konfiguration enthält das Setting :log-level
zur Konfiguration des
minimalen Logging-Levels der Applikation, das auf den Wert :info
gesetzt ist;
außerdem die Section :webserver
für die Konfiguration der Eigenschaften für
den Webserver. Die Section :webserver
ist wieder eine Konfigurations-Map mit
Settings :host
für den Listen-Host (hier ist der Webserver durch die Angabe
von "0.0.0.0"
von außerhalb erreichbar) und den Listen-Port, nämlich der
Standard-HTTP-Port 80
.
Hier kann man jetzt schon einen möglichen Fallstrick mit Konfigurationen
erahnen: Eine Applikation erwartet in der Regel ganz bestimmte konfigurierte
Werte, die sie dann interpretiert und entsprechend darauf reagiert. In unserem
Beispiel geht die Applikation also davon aus, dass der Wert für das
Logging-Level tatsächlich ein Keyword wie zum Beispiel :info
ist und nicht
etwa eine Zeichenkette "info"
oder "INFO"
. Und der Webserver-Port soll eine
Zahl 80
sein und nicht eine Zeichenkette "80"
und so weiter.
Und genau solche Einschränkungen können Programmiery mit unserer Bibliothek festlegen und Konfigurationen dann auf diese Einschränkungen überprüfen und so sicherstellen, dass die Applikation eine gültige und für sie verständliche Konfiguration erhält.
Konfigurationsschema
Die Applikation definiert dafür ein Konfiugrationsschema, welches sie erwartet.
So ein Konfigurationsschema heißt Schema in unserer Bibliothek. Jetzt
definieren wir mal der Reihe nach die Settings, Sections und dann das Schema für
die obige Beispielkonfiguration. Wir gehen in den Codebeispielen davon aus,
dass der Namespace active.clojure.config
als config
importiert ist.
Starten wir mit der Definition des Logging-Level-Settings:
(def log-level-setting
(config/setting
:log-level
"Minimal log level, defaults to :error."
(config/one-of-range #{:trace :debug :info :warn :error :fatal}
:error)))
Der Aufruf von config/setting
erwartet als erstes Argument das Keyword der
Einstellung, hier ist das :log-level
. Dann folgt verpflichtend eine
Zeichenkette zur Dokumentation der Einstellung – verständliche Beschreibungen
hier sind sehr hilfreich zum Nachlesen, was Benutzys denn alles konfigurieren
können und wenn die Konfiguration Fehler verursacht. Und als drittes Argument
legen wir den gültigen Wertebereich der Einstellung fest – in unserer
Bibliothek heißt ein Wertebereich Range. Der Wertebereich für das Loglevel
ist eine one-of-range
, also ist ein gültiger Wert dafür eines der angegebenen
Schlüsselwörter :trace
, :debug
, :info
, :warn
, :error
, oder :fatal
.
Zusätzlich erlaubt die Range die Angabe einer Voreinstellung, wenn das Setting
nicht ausdrücklich konfiguriert ist. Hier ist das :error
– im
Beschreibungstext ist diese Tatsache auch hilfreich beschrieben.
Nach demselben Muster schreiben wir jetzt die Einstellungen für den Webserver. Zuerst für den Host:
(def webserver-host-setting
(config/setting
:host
"The address the webserver listens on, defaults to 0.0.0.0."
(config/default-string-range "0.0.0.0")))
Die Einstellung für den Webserver-Host soll eine Zeichenkette sein, also eine
String-Range, und hier eine, die zusätzlich die Angabe einer Voreinstellung
"0.0.0.0"
erlaubt, daher benutzen wir default-string-range
um das
festzulegen. Unsere Bibliothek enthält bereits eine Vielzahl von Ranges für
häufig gebrauchte Wertebereiche und es ist einfach, eigene Ranges für speziellere
Wertebereiche zu definieren. Eine weitere eingebaute Range sehen wir bei der
nächsten Konfigurationseinstellung.
Die Definition der Konfigurationseinstellung für den Port sieht so aus:
(def webserver-port-setting
(config/setting
:port
"The port the webserver listens on, defaults to 3000."
(config/integer-between-range 0 65535 3000)))
Den Port erwartet die Konfiguration als ganze Zahl, also eine Integer-Range;
aber es gibt in TCP-Netzwerken nur eine beschränkte Anzahl von Ports, nämlich
von 0 bis 65535, daher schränken wir den möglichen Wertebereich gleich darauf
mit integer-between-range
ein. Das dritte Argument für
integer-between-range
ist die Voreinstellung, falls die Einstellung nicht
explizit getroffen wurde. Hier benutzt unsere Applikation in diesem Fall den
Port 3000
.
Diese zwei Einstellungen machen den Webserver-Abschnitt aus – daher bündeln wir sie in einem Schema zusammen mit einer passenden Beschreibung:
(def webserver-schema
(config/schema
"Configuration settings for the web server"
webserver-host-setting
webserver-port-setting))
Und mit diesem Schema können wir nun den Webserver-Abschnitt definieren:
(def webserver-section
(config/section
:webserver
webserver-schema))
Hier ist keine Beschreibung nötig, da die Beschreibung ja schon im Schema steckt.
Jetzt haben wir alles beisammen, was wir für unser gesamtes Konfigurationsschema brauchen:
(def schema
(config/schema
"Configuration for our application"
log-level-setting
webserver-section))
Validierung und Normalisierung
Damit können wir Konfigurationen anhand unseres Schemas nun auf Gültigkeit überprüfen:
(config/normalize&check-config-object
schema
{:log-level :info
:webserver {:host "0.0.0.0"
:port 80}})
Der Aufruf gibt die übergebene Konfiguration zurück, was bedeutet, dass die übergebene Konfiguration gültig und vollständig ist.
Unvollständige Konfigurationen werden mit Voreinstellungen komplettiert: Der Aufruf
(config/normalize&check-config-object
schema
{:webserver {:host "0.0.0.0"}})
liefert die vervollständigte Konfiguration mit den definierten Voreinstellungen:
{:log-level :error
:webserver {:host "0.0.0.0"
:port 3000}}
Und eine fehlerhafte Konfiguration
(config/normalize&check-config-object
schema
{:webserver {:port "80"}})
liefert eine Datenstruktur namens RangeError
, die den Fehler in der
Konfiguration mittels Pfad zur fehlerhaften Konfigurationseinstellung, dem
falschen Wert und dem eigentlich erwarteten Wertebereich beschreibt. Und so
einem Benutzy die Möglichkeit gibt, das Problem zu verstehen und zu beheben.
Die mit str
ausgedruckte Repräsentation des obigen RangeError
s sieht so aus:
"Range error at path [:webserver :port]: value \"80\" is not in range integer between 0 and 65535"
Zugriff auf Konfigurationseinstellungen
Jetzt könnten wir die Einstellungen mit Hilfe der Settings-Keywords aus der
verschachtelten Konfigurations-Map auslesen. Aber das ist keine besonders
robuste Vorgehensweise, da dem Clojure-Kompiler mögliche Tippfehler in den
Keywords gar nicht auffallen – im Zweifel liefert so ein Zugriff auf einen
nicht-existenten Key einfach nil
zurück. Und das wiederum könnte unsere
Applikation missverstehen oder durcheinander bringen – was wir durch robuste
Konfiguration ja verhindern wollen.
Daher wollen wir gar nicht mit Keywords auf die Konfigurations-Map direkt zugreifen, sondern wir wollen sicherere Mechanismen dafür nutzen, die es in unserer Bibliothek gibt:
-
Wir hantieren nicht mit der Map direkt, sondern mit einem speziellen Datentyp namens Configuration.
-
Wir benutzen die an Variablen gebundenen Settings und Sections, um auf die Werte zuzugreifen – damit bemerkt schon der Compiler mögliche Tippfehler.
-
Wir benutzen Funktionen für den Zugriff, die eine validierte Konfiguration sicherstellen.
Wie das praktisch aussieht, zeigen wir gleich. Zunächst binden wir unsere Beispiel-Configuration an einen Namen für unsere nächsten Versuche und Erklärungen:
(def c
(config/make-configuration
schema
{:log-level :info
:webserver {:host "0.0.0.0"
:port 80}}))
Aus dieser Configuration können wir nun mit Hilfe der Funktion access
die
Werte zu den Einstellungen auslesen:
(config/access c log-level-setting)
liefert :info
.
Und aus verschachtelte Konfigurationen auslesen geht so:
(config/access c webserver-port-setting webserver-section)
Dieser Aufruf liefert 80
.
Es gibt auch die Möglichkeit, eine ganze Section herauszulösen und daraus die Werte auszulesen:
(let [webserver-config (config/section-subconfig c webserver-section)]
(config/access webserver-config webserver-port-setting))
Das ermöglicht Konfiguration von verschiedenen Bausteinen der Applikation ohne zu große Kopplung.
Linsen auf Konfigurationseinstellungen
Linsen spielen in unserer täglichen Arbeit – und daher auch in diesem Blog – eine große Rolle, weil sie einen großen praktischen Nutzen haben. Daher weiß natürlich auch unsere Konfigurations-Bibliothek mit Linsen umzugehen, zum Beispiel für den Zugriff auf Einstellungen. Der Code
(let [log-level-lens (config/access-lens log-level-setting)]
(log-level-lens c))
liefert wie oben der direkte Zugriff auch :info
. Beachtenswert ist, dass wir
die Linse ohne die Konfiguration konstruieren können – eine sehr elegante Art,
Selektoren zu schreiben.
Analog geht das auch für verschachtelte Konfigurationen:
(let [webserver-port-lens (config/access-lens webserver-port-setting
webserver-section)]
(webserver-port-lens c))
Das liefert wie zu erwarten 80
.
Projektion von Konfigurationseinstellungen
Kopplung vermeiden oder zumindest verringern ist das wichtigste Mantra für gute Software. Eine Methode zur Entkopplung sind verschiedene Datenstrukturen für verschiedene Bereiche der Applikation zu verwenden, auch wenn sie sehr ähnlich sind.2
Daher lohnt es sich in einer Software, die Einstellungen von der Konfiguration zu trennen – in dem man zum Beispiel eine Datenstruktur einführt, die die Einstellungen repräsentiert, die aber nicht die Configuration-Datenstruktur ist. Unsere Bibliothek macht uns das einfach durch ein Zusammenspiel von Records und Projektionslinsen.
Wir können nämlich die Einstellungs-Datenstruktur, die wir in unserer Applikation verwenden wollen, als Record definieren:
(define-record-type Settings
{:projection-lens settings-projection-lens}
make-settings
settings?
[log-level settings-log-level?
webserver-host settings-webserver-host
webserver-port settings-webserver-port])
Und zwischen diesem Record und der Konfiguration eine Projektionslinse definieren:
(def configuration->settings
(settings-projection-lens
(config/access-lens log-level-setting)
(config/access-lens webserver-host-setting webserver-section)
(config/access-lens webserver-port-setting webserver-section)))
Mit dieser kompakten Definition können wir nun eine Konfiguration in Settings übersetzen3:
(configuration->settings c)
Profile
Ein weiteres nützliches Feature ist die Unterstützung von Profilen. Oft gibt es
Varianten einer Konfiguration, die einer bestimmten Umgebung oder einem
bestimmten Deployment geschuldet sind. Auch dabei hilft unsere Bibliothek. Zum
Beispiel können wir ein Profil für Testumgebungen konfigurieren, das einige
Aspekte im Vergleich zur Produktivumgebung anpasst. Dazu fügen wir in unserer
Beispielkonfiguration unter :profiles
ein Profil namens :test
hinzu. Die
vollständige Konfiguration sieht dann so aus:
{:log-level :info
:webserver {:host "0.0.0.0"
:port 80}
:profiles
{:test {:log-level :debug}}}
Im Test-Profil ist konfiguriert, dass wir in der Testumgebung also auch Debugging-Logs sehen wollen.
Beim Einlesen der Konfiguration können wir dann die Einstellungen für dieses
Profil „reinmischen“, die Profil-spezifischen Einstellungen überschreiben dann
die allgemeinen Einstellungen. Dazu rufen wir normalize&check-config-object
zusätzlich mit einer Liste von zu berücksichtigenden Profilen auf:
(config/normalize&check-config-object
schema
[:test]
{:log-level :info
:webserver {:host "0.0.0.0"
:port 80}
:profiles
{:test {:log-level :debug}}})
Das liefert dann die vervollständigte Konfiguration für die Testumgebung mit
Logging-Level :debug
:
{:log-level :debug
:webserver {:host "0.0.0.0"
:port 80}}
Fazit
Eine wichtige Grundlage von flexiblen Applikationen sind robuste Konfigurationen. Die vorgestellte Bibliothek ermöglicht diesen robusten Umgang mit Konfigurationen. Die Bibliothek ist bei uns in allen unseren produktiven Clojure-Projekten seit vielen Jahren erfolgreich im Einsatz.
-
Key-Value-Maps, und somit auch unsere Konfigurationen, sind gültiges EDN-Format, ein gängiges Datentransferformat im Clojure-Umfeld; das Speichern und Laden von Konfigurationsdateien in diesem Format geht daher mit Clojure-Bordmitteln
spit
,slurp
undread-string
– darauf gehen wir in diesem Artikel nicht näher ein. ↩ -
Aber da Software wächst und Anforderungen sich ändern, bleiben Ähnlichkeiten oft nicht erhalten. ↩
-
Das geht sogar in die andere Richtung, da Projektionslinsen bidirektional sind. Wenn also zum Beispiel die Applikation den Benutzys Änderungen an den Settings ermöglicht und diese dann als Konfiguration in der Konfigurationsdatei persistieren will. ↩