Higher-Kinded Data für Konfigurationen in Haskell
Viele Anwendungen verwenden Konfigurationen, um ihr Verhalten zur Laufzeit zu beeinflussen. Die Parameter in diesen Konfigurationen können z. B. Standardwerte haben, die verwendet werden falls nichts anderes angegeben wird. Andere Werte, wie Passwörter, haben keine Standardwerte und müssen deshalb immer beim Start der Anwendung angegeben werden.
In diesem Artikel werden wir über mehrere Iterationen sehen, wie wir mit Higher-Kinded Data in Haskell, Konfigurationen in unseren Programmen abbilden können.
Hinweis: Begleitender Quellcode ist auf Github zu finden.
Erster Versuch
Angenommen wir haben ein Programm, das ein Passwort, die URL eines Services und dessen Port als Konfiguration benötigt. Die Konfiguration könnten wir dann durch folgenden Datentyp modellieren.
data Config = Config
{ password :: String
, serviceUrl :: String
, servicePort :: Int
}
deriving (Show)
Wir nehmen an, dass die einzelnen Teile der Konfiguration zum Start des Programms aus Umgebungsvariablen ausgelesen werden. Wird eine Umgebungsvariable nicht gesetzt, oder kann der gegebene Wert nicht interpretiert werden, soll ein Standardwert verwendet werden. Zusätzlich muss das Passwort immer zur Laufzeit angegeben werden und besitzt deshalb keinen Standardwert.
Die Funktion, die all dies umsetzt, könnte z. B. so aussehen:
getConfig :: IO Config
getConfig = do
pw <- getPassword
url <- fromMaybe "localhost" <$> getUrl
port <- fromMaybe 8080 <$> getPort
pure (Config pw url port)
getPassword :: IO String
getPassword = do
mPassword <- lookupEnv "PASSWORD"
pure (fromMaybe (error "Environment variable PASSWORD not set") mPassword)
getUrl :: IO (Maybe String)
getUrl = lookupEnv "SERVICE_URL"
getPort :: IO (Maybe Int)
getPort = do
mPortStr <- lookupEnv "SERVICE_PORT"
pure (readMaybe =<< mPortStr)
Wir benutzen hier die Funktion:
lookupEnv :: String -> IO (Maybe String)
die den Wert einer Umgebungsvariable zurück gibt, sofern diese gesetzt ist.
Wir sehen hier drei Hilfsfunktionen, die das Einlesen und Interpretieren der jeweiligen
Teile der Konfiguration übernehmen. In der Funktion getConfig
wird alles zusammengesetzt.
Hier findet sowohl das Zusammenbauen des Config
-Datentyps als auch das
Vereinigen der Standardwerte mit den eingelesenen statt.
Der Ansatz hat mindestens zwei Probleme:
- Die Funktion
getConfig
vermischt das Bauen der Konfiguration mit dem Lesen der einzelnen Parameter und dem Zusammenbauen mit Standardwerten. Das führt dazu, dass nicht direkt klar ist, ob ein Feld einen Standardwert hat. - Weitere Konfigurations-Typen müssen ihre eigene
getConfig
-Funktion schreiben.
Zweiter Versuch
Eine weitere Möglichkeit wäre, den folgenden Datentyp zu wählen:
data Config' static dynamic = Config
{ password :: dynamic String
, serviceUrl :: static String
, servicePort :: static Int
}
Wie wir sehen, wurden im Vergleich zum ersten Config'
-Datentyp zwei Typ-Parameter eingeführt.
Beide Parameter sind Typ-Funktionen mit dem Kind Type -> Type
. Durch die Wahl der beiden
Typ-Funktionen können wir im Folgenden erreichen, dass dynamische Werte nicht zur Compile-Zeit
angegeben werden können, statische dagegen schon.
Um die Semantik hinter static
und dynamic
zu implementieren, nutzen wir die zwei eingebauten Datentypen
Identity
und Proxy
.
Identity a
ist ein Datentyp, der einen Wert vom Typ a
enthält. Das führt dazu, dass in allen Definitionen einer
Konfiguration diese Werte vorhanden sein müssen.
Proxy a
dagegen enthält keinen Wert vom Typ a
. Somit können wir einen Wert vom Typ Proxy a
definieren, ohne einen Wert vom Typ a
angeben zu müssen.
Wir definieren uns die folgenden Typ-Aliase, um die Belegung von static
und dynamic
in verschiedenen
Situationen klarer zu machen.
type DefaultConfig = Config' Identity Proxy
type PartialConfig = Config' Maybe Identity
type Config = Config' Identity Identity
PartialConfig
ersetzt hier static
durch den Maybe
Typ-Konstruktor und dynamic
durch Identity
.
Somit gilt für partielle Konfigurationen, dass statische Werte potentiell angegeben werden können, statische Werte
dagegen müssen angegeben werden.
In einem Wert vom Typ Config
müssen sowohl statische als auch dynamische Werte angegeben werden.
Damit lässt sich nun eine Standard-Konfiguration definieren:
defaultConfig :: DefaultConfig
defaultConfig =
Config
{ password = Proxy
, serviceUrl = Identity "localhost"
, servicePort = Identity 8080
}
Diese Definition enthält zwar nur die Werte, die wir statisch kennen, allerdings sind hier noch
Aufrufe von Proxy
und Identity
nötig, die die Lesbarkeit erschweren.
Die Funktion, die nun zur Laufzeit die Umgebungsvariablen einliest, sieht wie folgt aus:
readInPartialConfig :: IO PartialConfig
readInPartialConfig = do
password <- Identity <$> getPassword
url <- getUrl
port <- getPort
pure (Config password url port)
Hier ist zu beachten, dass sowohl die Url als auch der Port eingelesen werden können, aber nicht notwendigerweise vorhanden sein müssen. Das Passwort dagegen muss dynamisch geladen werden.
Der letzte fehlende Baustein ist die Funktion, die die Standard- und
partielle Konfiguration zur finalen Config
kombiniert. Dabei werden
jeweils Werte aus der PartialConfig
denen aus der DefaultConfig
vorgezogen.
combineConfig :: DefaultConfig -> PartialConfig -> Config
combineConfig
(Config _defaultPasswordProxy defaultServiceURL defaultServicePort)
(Config pw url port) =
Config
pw
(maybe defaultServiceURL Identity url)
(maybe defaultServicePort Identity port)
Die Funktion getConfig
nutzt nun lediglich die bisher definierten Funktionen:
getConfig :: IO Config
getConfig = combineConfig defaultConfig <$> readInPartialConfig
Mit unserer neuen Definition des Konfigurationsdatentyps, konnten wir das erste der obigen Probleme beheben:
An der Definition von Config'
ist klar erkennbar, welche Felder Standardwerte haben und welche nicht und
es gibt einen Wert defaultConfig
, der alle diese Werte enthält.
Leider ist der Code so nicht wiederverwendbar. Für eine neue Konfiguration müssen wir immer noch den Datentyp und manche Funktionen neu schreiben.
Typfamilien zur Hilfe
Um den Code wiederverwendbar zu machen, definieren wir uns die
Typfamilie HKD
(Higher-Kinded Data). So modellieren wir das Anwenden
von Typfunktionen auf Typen:
type HKD :: (Type -> Type) -> Type -> Type
type family HKD f a where
HKD Identity a = a
HKD f a = f a
Durch diese Definition ersparen wir uns später unnötige Aufrufe von Identity
.
Unser Konfigurationstyp ist unverändert bis auf den Aufruf von HKD
in den Signaturen der Felder. Außerdem
müssen wir aus technischen Gründen noch eine Instanz der Typklasse Generic
generieren lassen.
data Config' static dynamic = Config
{ password :: HKD dynamic String
, serviceUrl :: HKD static String
, servicePort :: HKD static Int
}
deriving (Generic)
Auch die Definition von defaultConfig
ist fast dieselbe, bis auf die fehlenden Aufrufe des Identity
Konstruktors:
defaultConfig :: DefaultConfig
defaultConfig =
Config
{ password = Proxy
, serviceUrl = "localhost"
, servicePort = 8080
}
Analog für die partielle Konfiguration:
readInPartialConfig :: IO PartialConfig
readInPartialConfig = do
password <- getPassword
url <- getUrl
port <- getPort
pure (Config password url port)
Dadurch, dass wir bei der Definition von Config'
die Typklasse Generic
abgeleitet haben,
können wir eine Funktion genericApply
, mit dem im Folgenden etwas vereinfachten Typ, schreiben.
genericApply :: c Identity Proxy -> c Maybe Identity -> c Identity Identity
Diese Funktion übernimmt die Aufgabe von der Funktion combineConfig
.
Implementieren können wir sie mit Hilfe von GHC.Generics
, ohne dabei die Definition von Config'
zu benutzen.
Somit kann genericApply
in eine Bibliothek ausgelagert werden und muss nicht für jeden Konfigurationstyp neu
geschrieben werden.
Die genaue Implementierung führt hier zu weit, im verlinkten Github-Repo ist sie zu
finden.
Durch das Ersetzen der Typvariable c
mit Config'
erhalten wir denselben Typ wie für combineConfig
.
Damit haben wir alles, um die Funktion getConfig
zu bauen:
getConfig :: IO Config
getConfig = do
partialConfig <- readInPartialConfig
pure (genericApply defaultConfig partialConfig)
Mit dieser Iteration von Config'
haben wir es endlich geschafft. Für einen neuen Konfigurationstyp müssen wir nur noch
den Typ, den Standardwert und das Einlesen der dynamischen Werte definieren. Das Kombinieren bekommen wir geschenkt.
Insbesondere können die HKD
-Typfamilie, die Funktion genericApply
und die nötigen Hilfsfunktionen in eine Bibliothek
ausgelagert werden, da sie unabhängig von der Config'
Definition immer gleich sind.
Durch drei weitere kleine Typ-Aliase ist es sogar möglich, Proxy
und Identity
zu entfernen:
type Default c = c Identity Proxy
type Partial c = c Maybe Identity
type Complete c = c Identity Identity
dynamic :: Proxy a
dynamic = Proxy
Auch diese Definitionen können in die Bibliothek ausgelagert werden.
Final sieht die gesamte Definition der Konfiguration dann so aus:
data Config' static dynamic = Config
{ password :: HKD dynamic String
, serviceUrl :: HKD static String
, servicePort :: HKD static Int
}
deriving (Generic)
type DefaultConfig = Default Config'
type PartialConfig = Partial Config'
type Config = Complete Config'
defaultConfig :: DefaultConfig
defaultConfig =
Config
{ password = dynamic
, serviceUrl = "localhost"
, servicePort = 8080
}
readInPartialConfig :: IO Config
readInPartialConfig =do
password <- getPassword
url <- getUrl
port <- getPort
pure (Config password url port)
getConfig :: IO Config
getConfig = do
partialConfig <- readInPartialConfig
pure (genericApply defaultConfig partialConfig)
Fazit
Mit Hilfe von Typfunktionen und Higher-Kinded Data haben wir es geschafft, die Standardwerte unserer Konfiguration vom Einlesen der dynamischen Werte zu trennen. Wir konnten auf Typebene klar machen, welche Werte dynamisch und welche statisch bekannt sind. Zu guter Letzt konnten wir Funktionen auslagern, sodass nur relevante Teile neugeschrieben werden müssen.