Etter å ha jobbet med programmeringsspråk som har gode typesystemer som f.eks Swift og Kotlin, så har jeg omfavnet hva typer kan gi, og forsøkt å utnytte de i mye større grad enn tidligere til å forhindre at man skal kunne opprette datastrukturer i en ugyldig tilstand.

Make illegal states unrepresentable

I boken Domain Modeling Made Functional skriver Scott Wlaschin følgende:

We’re trying to capture business rules in the type system. If we do this properly, invalid situations can’t ever exist in the code and we never need to write unit tests for them. Instead, we have “compile-time” unit tests.

Jon A De Goes twittret:

Making illegal states unrepresentable is all about statically proving that all runtime values (without exception) correspond to valid objects in the business domain. The effect of this technique on eliminating meaningless runtime states is astounding and cannot be overstated.

Dette er som musikk i mine ører, og er noe jeg streber etter å få til i de prosjektene jeg er i.

Typesikker konfigurasjon?

Tidligere så har jeg typisk lest ut informasjonen som enkeltverdier og lagret de i et konfig-objekt, som jeg med hånden på hjertet må si ofte har vært litt stemoderlig designet. Burde vi ikke ta jobben med å strukturere konfigurasjonen riktig og typesikker når vi har språk som støtter dette?

La oss si vi har en konfigurasjonsfil som i dette eksempelet bruker formatet HOCON (Human-Optimized Config Object Notation).

adminEmails = [
    "andre@kodemaker.no",
    "nils@kodemaker.no"
]

kafka {
  bootstrapServers=“our-prod.aivencloud.com:27222”
  schemaRegistryUrl="https://our-prod.aivencloud.com:27221”
  active: true
  ... 
}

auth {
    domain="auth.our.domain.com"
    clientId=“CAFEBABE”
    redirectUrl="https://our.domain.com"
    cookieDomain=“our.domain.com"
}

jobs: [
  {
    name = EXPORT_JOB_
    timeBetweenRuns = 10000
    maxDuration =  10000
    initialDelay = 20000
  },
  {
    name = IMPORT_JOB_
    timeBetweenRuns = 20000
    maxDuration =  15000
    initialDelay = 2000
  }
]

For å lese ut verdiene for kafka blokken samt adminbrukere, så kan man gjøre som følgende:

Config conf = ConfigFactory.load()
val boostrapServer = config.getURI("bootstrapServers")
val schemaRegistry = config.getURI("schemaRegistryUrl")
val isActive = config.getDefaultBoolean("active", false)
val admins = config.getStringList("adminEmails")

I stedet for å kopiere denne informasjonen inn i en klump av et heller dårlig designet konfigurasjonsobjekt, så velger jeg heller å lage noen dataklasser som vist nedenfor, og lese inn informasjonen dit.

data class KafkaClientConfig(
    val bootstrapServers: URI,
    val schemaRegistryUrl: URL,
    val active: Boolean
 	...
)

data class JobConf(
    val name: String,
    val timeBetweenRuns: Long,
    val maxDuration: Long,
    val initialDelay: Long,
    val jobData: Map<String, String> = emptyMap()
)

data class AuthConfig(
    val domain: String,
    val clientId: Masked,
    val redirectUrl: URL,
    val cookieDomain: String,
    ...
)

@JvmInline
value class Email(val value: String)

data class Config(
    val kafka: KafkaClientConfig,
    val jobs: List<JobConf>,
    val auth: AuthConfig,
    val adminEmails: Set<Email>
)

Vi kan da bruke APIet som vist tidligere for å pelle ut verdiene og opprette objektgrafen. Dette krever at man må investere litt mer jobb for å få det på plass, men resultatet er objekter som garantert er i en gyldig tilstand ut fra spesifikasjonen til klassene.

Dette er jo noe som andre også må ha tenkt på, og kanskje laget noe som de har delt med oss?

Hoplite til tjeneste!

Det er vel ingen overraskelse at det finnes biblioteker som kan hjelpe oss med dette. Bibliotektet jeg har gått for er Hoplite, og den første setningen i README filen sier:

Hoplite is a Kotlin library for loading configuration files into typesafe classes in a boilerplate-free way.

Dette høres jo riktig ut, og ja det er virkelig boilerplate-free.

Hoplite tilbyr bl.a:

  • Flere konfigurasjonsformater som Yaml, JSON, Toml, Hocon, eller Java .properties. Man kan til og med blande disse, selv om jeg ikke vil anbefale det så lenge man kan unngå det.
  • Støtter flere typer av konfigurasjonskilder
  • Ut av boksen støtte for mange av standardtypene, enums, collection typer, etc. Typer fra andre tredjepartsbiblioteker støttes også gjennom separate moduler, som f.eks Arrow ♥, Hikari, etc
  • Dersom typer mangler så kan man implementere sine egne dekodere.
  • Støtte for flere lag av konfigurasjonskilder, med muligheter for overstyring av tidligere verdier som kan være nyttig når man har flere miljøer.
  • Lettleste og nøyaktige feilmeldinger.

Innlesing av konfigurasjonsfiler

En enkel innlasting av denne konfigurasjonen kan gjøres slik:

val config = ConfigLoader().loadConfigOrThrow<Config>("/application.conf")

Dersom alle verdier er satt, har gyldige verdier og ellers er på stell, så sitter man igjen med en gyldig instans av konfigurasjonen. Skulle dette mot formodning ikke være tilfelle, så kastes en exception med informasjon om hva som gikk galt.

En litt mer avansert initialisering av konfigurasjonen kan se slik ut:

fun initConfig(env: Environment): Config {
    val configLoader = ConfigLoader.Builder().addPropertySource(
        EnvironmentVariablesPropertySource(
            useUnderscoresAsSeparator = true,
            allowUppercaseNames = true
        )
    ).addSource(PropertySource.resource("/application-personal.conf", optional = true))
        .addPropertySource(PropertySource.resource("/application-${env.tag}.conf"))
        .addPropertySource(PropertySource.resource("/application.conf"))
        .build()
    return configLoader.loadConfigOrThrow<Config>()
}

Her leses konfigurasjonen inn i flere steg:

  1. Fra environment
  2. Fra en personlig konfigurasjonsfil hvor man kan overstyre verdier lokalt om man ønsker det. Denne er markert som valgfri, så det vil ikke feile dersom denne filen mangler.
  3. Fra en miljøspesifikk konfigurasjonsfil hvor miljøet leses fra environment, f.eks application-dev.conf
  4. En felles fil uavhengig av miljø

Maskerte verdier

For at verdier som man ikke ønsker skal logges eller vises i fritekst som nøkler og passord, så kan man bruke type Masked som vist for AuthConfig.clientId. Verdier vil da vises slik *****

Inline klasser

Inline klasser støttes slik at man slipper nesting av konfigurasjonsverdiene, som dere kan se i konfigurasjonen vist tidligere. Der er ikke value nestet inne i email.

Sealed klasser

Sealed klasser er også støttet ved at Hoplite matcher de nødvendige nøklene med parameterene i implementasjonene i klassene. Du finner dette godt beskrevet i dokumentasjonen.

Logging av mangelfull konfigurasjon

Dersom konfigurasjonsverdier mangler eller er av feil type, så vil Hoplite logge dette på en veldig oversiktlig måte:

    Error loading config because:

    - Could not instantiate 'no.kodemaker.Config' because:
        - 'kafka': - Could not instantiate 'no.kodemaker.KafkaClientConfig' because:
            - 'bootstrapServers': Missing from config
            - 'schemaRegistryUrl': Missing from config

    - 'adminEmails': Required a Set but a Boolean cannot be converted to a collection (/application.conf:3)

Oppsummering

Hoplite gir et enkelt, fleksibelt og type-sikkert konfigurasjons-oppsett, som er en sann glede å jobbe med. Gjør du feil i konfigurasjonen, så rapporteres dette på en veldig oversiktlig måte, og du vet at alle forventede verdier er satt. Prøv du også, du vil ikke se deg tilbake.