TL;DR: Loading Yaml, Json, etc config files into Kotlin data classes without boilerplate and with error checking with Hoplite.
For those of us who like to develop in a more functional way and avoid using Spring, we find ourselves looking for a way to handle configuration in our apps.
The most basic way is to use a java Properties file and use
key=value pairs, or we may choose to use the popular Lightbend Config library and write config in their Json superset called Hocon. Whatever we choose we end up writing code like the following:
val jdbcUrl = config.getString("jdbc.url")
In other words, pulling the value out of the config by referencing the key. The config values may be extracted when they are needed at the call point, or we may choose to place all the config values into some central object and pass that about. Either way, there's no guarantee the key we reference actually exists until the config is required.
Even worse, if the value inside the config is not compatible with the target type, we may run into a conversion problem. We've all been bitten by a typo where we had
:8080 as our port number only to find out 10 minutes into deployment we get a number format exception.
Some libraries may support marshalling automatically to some basic types, like doubles and bools, but often go no further than the basic primitives and collections.
Rather than pulling config out by key, we should push config into classes.
We start by declaring a data class (including nested classes).
data class Database(val host: String, val port: Int, val database: String) data class WebServer(val resources: Path, val port: Int, val timeout: Duration) data class Config(val env: String, val db: Database, val server: WebServer)
Next we write our config, in either Yaml, Json, Toml, Hocon, or Java Props. I'll use Yaml here, in a file called
env: staging db: host: staging.wibble.com port: 3306 database: userprofiles server: port: 8080 resources: /var/www/web timeout: 10s
Finally, we use the
ConfigLoader class to marshall the config directly into our config classes.
val cfg = ConfigLoader().loadConfigOrThrow<Config>("/staging.yaml")
cfg instance is a fully inflated version of the configuration file, with all values converted into the defined types.
Note: The files should be on the classpath. We can load from files outside the classpath instead if we wish by using instances of
As we mentioned at the start, one of the biggest problems with config is when things go wrong. With Hoplite, errors are beautifully displayed as soon as the config is marshalled.
Let's use the same config as before, but this time with a file with a bunch of errors:
envvv: staging db: host: staging.wibble.com port: .3306 databas: userprofiles server: port: localhost resources: /var/www/web timeout: 10ty
Using this file, gives the following error output:
Exception in thread "main": Error loading config because: - Could not instantiate 'com.example.Config' because: - 'env': Missing from config - 'db': - Could not instantiate 'com.example.Database' because: - 'host': Missing from config - 'port': Could not decode .3306 into a Int (/foo.yml:4:8) - 'database': Missing from config - 'server': - Could not instantiate 'com.example.WebServer' because: - 'port': Could not decode localhost into a Int (/foo.yml:8:8) - 'timeout': Required type java.time.Duration could not be decoded from a String value: 10ty (/foo.yml:10:11)
You can see how easy it is to debug this file - detailed error messages showing the exact key, plus where possible the line number and file name is included. The errors include values that could not be converted to a number, missing values, and erroneous formats for a Duration.
Hoplite supports many different types out of the box - batteries included as the cool kids say. These are your usual primitive types, collections, enums, dates, durations,
UUID, files, paths and so on. In addition the data types from Arrow -
NonEmptyList, Tuples and
Option are supported as well.
It's also very easy to add support for custom types if you wish. Just implement the
Decoder interface and add this via a service loader.
There's plenty more to Hoplite, so I'll point you to the official docs for further reading.