Written by Alex de Sousa
Once an Elixir project is large enough, maintaining config files and configuration variables becomes a nightmare:
Ideally though, configurations should be:
In summary: easy to maintain.
We'll elaborate using the the following example:
config :myapp,
hostname: System.get_env("HOSTNAME") || "localhost",
port: String.to_integer(System.get_env("PORT") || "80")
The previous code is:
hostname
and port
of what?hostname
and port
used?Conclusion: it's hard to maintain.
We could mitigate some of these problems with one simple approach:
The following, though a bit more verbose, would be the equivalent to the previous config:
defmodule Myapp.Config do
@moduledoc "My app config."
@doc "My hostname"
def hostname do
System.get_env("HOSTNAME") || "localhost"
end
@doc "My port"
def port do
String.to_integer(System.get_env("PORT") || "80")
end
end
Unlike our original code, this one is:
@doc
attribute.However, we still have essentially the same code we had before, which is:
There's gotta be a better way!
Skogsrå is a library for loading configuration variables with ease, providing:
:persistent_term
as temporal storage.The previous example can be re-written as follows:
defmodule Myapp.Config do
@moduledoc "My app config."
use Skogsra
@envdoc "My hostname"
app_env :hostname, :myapp, :hostname,
default: "localhost",
os_env: "HOSTNAME"
@envdoc "My port"
app_env :port, :myapp, :port,
default: 80,
os_env: "PORT"
end
This module will have these functions:
Myapp.Config.hostname/0
for retrieving the hostname.Myapp.Config.port/0
for retrieving the port.With this implementation, we end up with:
@envdoc
module attribute.Myapp.Config
module.app_env
options are self explanatory.Calling Myapp.Config.port()
will retrieve the value for the port in the following order:
$PORT
.From the configuration file e.g. our test config file might look like:
# file config/test.exs
use Mix.Config
config :myapp,
port: 4000
From the default value, if it exists (In this case, it would return the integer 80
).
The values will be casted as the default values' type unless the option type
is provided (see Explicit type casting section).
Though Skogsrå has many options and features, we will just explore the ones I use the most:
When the types are not any
, binary
, integer
, float
, boolean
or atom
, Skogsrå cannot automatically cast values solely by the default value's type. Types then need to be specified explicitly using the option type
. The available types are:
:any
(default).:binary
.:integer
.:float
.:boolean
.:atom
.:module
: for modules loaded in the system.:unsafe_module
: for modules that might or might not be loaded in the system.Skogsra.Type
implementation: a behaviour
for defining custom types.Let's say we need to read an OS environment variable called HISTOGRAM_BUCKETS
as a list of integers:
export HISTOGRAM_BUCKETS="1, 10, 30, 60"
We could then implement Skogsra.Type
behaviour to parse the string correctly:
defmodule Myapp.Type.IntegerList do
use Skogsra.Type
@impl Skogsra.Type
def cast(value)
def cast(value) when is_binary(value) do
list =
value
|> String.split(~r/,/)
|> Stream.map(&String.trim/1)
|> Enum.map(String.to_integer/1)
{:ok, list}
end
def cast(value) when is_list(value) do
if Enum.all?(value, &is_integer/1), do: {:ok, value}, else: :error
end
def cast(_) do
:error
end
end
And finally use Myapp.Type.IntegerList
in our Skogsrå configuration:
defmodule Myapp.Config do
use Skogsra
@envdoc "Histogram buckets"
app_env :buckets, :myapp, :histogram_buckets,
type: Myapp.Type.IntegerList,
os_env: "HISTOGRAM_BUCKETS"
end
Then it should be easy to retrieve our buckets
from an OS environment variable:
iex(1)> System.get_env("BUCKETS")
"1, 10, 30, 60"
iex(2)> Myapp.Config.buckets()
{:ok, [1, 10, 30, 60]}
or if the variable is not defined, from our application configuration:
iex(1)> System.app_env(:myapp, :histogram_buckets)
[1, 10, 30, 60]
iex(2)> Myapp.Config.buckets()
{:ok, [1, 10, 30, 60]}
Skogsrå provides an option for making configuration variables mandatory. This is useful when there is no default value for our variable and Skogsrå it's expected to find a value in either an OS environment variable or the application configuration e.g. given the following config module:
defmodule MyApp.Config do
use Skogsra
@envdoc "Server port."
app_env :port, :myapp, :port,
os_env: "PORT",
required: true
end
The function Myapp.Config.port()
will error if PORT
is undefined and
the application configuration is not found:
iex(1)> System.get_env("PORT")
nil
iex(2)> Application.get_env(:myapp, :port)
nil
iex(3)> MyApp.Config.port()
{:error, "Variable port in app myapp is undefined"}
All the configuration variables will have the correct function @spec
definition e.g. given the following definition:
defmodule Myapp.Config do
use Skogsra
@envdoc "PostgreSQL hostname"
app_env :db_port, :myapp, [:postgres, :port],
default: 5432
end
The generated function Myapp.Config.db_port/0
will have the following @spec
:
@spec db_port() :: {:ok, integer()} | {:error, binary()}
The type is derived from:
default
value (in this case the integer 5432
)type
configuration value (see the previous Explicit type casting section).Skogsra provides a simple way to handle your Elixir application configurations in a type-safe and organized way. Big projects can certainly benefit from using it.
Hope you found this article useful. Happy coding!
Alex de Sousa
Elixir alchemist. Tech enthusiast.