Configuring Go Apps with TOML
So you’ve been writing an application in Go, and you’re getting to the point where you have a lot of different options in your program. You’ll likely want a configuration file, as specifying every option on the command-line can get difficult and clunky, and launching applications from a desktop environment makes specifying options at launch even more difficult.
This post will cover configuring Go apps using a simple, INI-like configuration language called TOML, as well as some related difficulties and pitfalls.
TOML has quite a few implementations, including several libraries for
Go. I particularly like
BurntSushi’s TOML parser and decoder, as it lets
you marshal a TOML file directly into a struct. This means your
configuration can be fully typed and you can easily do custom
conversions (such as parsing a time.Duration
) as you read the
config, so you don’t have to do them in the rest of your application.
Configuration location
The first question you should ask when adding config files to any app is "where should they go?". For tools that aren’t designed to be run as a service, as root, or under a custom user (in other words, most of them), you should be putting them in the user’s home directory, so they’re easily changed. A few notes:
Even if you currently have only one file, you should use a folder and put the config file within it. That way, if and when you do need other files there, you won’t have to clutter the user’s home directory or deal with loading config files that could be in two different locations (Emacs, for instance, supports both
~/.emacs.d/init.el
and~/.emacs
for historical reasons, which ends up causing confusing problems when both exist).You should name your configuration directory after your program.
You should typically prefix your config directory with a
.
(but see the final note for Linux, as configuration directories withinXDG_CONFIG_HOME
should not be so prefixed).On most OSs, putting your configuration files in the user’s “home” directory is typical. I recommend the library go-homedir, rather than the
User.Homedir
available in the stdlib fromos/user
. This is because use ofos/user
uses cgo, which, while useful in many situations, also causes a number of difficulties that can otherwise be avoided - most notably, cross-compilation is no longer simple, and the ease of deploying a static Go binary gets a number of caveats.On Linux specifically, I strongly encourage that you do not put your configuration directory directly in the user’s home directory. Most commonly-used modern Linux distributions use the XDG Base Directory Specification from freedesktop.org, which specifies standard locations for various directories on an end-user Linux system. (Despite this, many applications don’t respect the standard and put their configurations directly in
~
anyway). By default, this is~/.config/
, but it can also be set with theXDG_CONFIG_HOME
environment variable. Directories within this should not use a leading.
, as the directory is already hidden by default.
The following function should get you the correct location for your config directory on all platforms (if there’s a platform with a specific convention for config locations which I’ve missed, I’d appreciate you letting me know so I can update the post - my email is at the bottom of the page).
import (
"path/filepath"
"os"
"runtime"
"github.com/mitchellh/go-homedir"
)
var configDirName = "example"
func GetDefaultConfigDir() (string, error) {
var configDirLocation string
homeDir, err := homedir.Dir()
if err != nil {
return "", err
}
switch runtime.GOOS {
case "linux":
// Use the XDG_CONFIG_HOME variable if it is set, otherwise
// $HOME/.config/example
xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
if xdgConfigHome != "" {
configDirLocation = xdgConfigHome
} else {
configDirLocation = filepath.Join(homeDir, ".config", configDirName)
}
default:
// On other platforms we just use $HOME/.example
hiddenConfigDirName := "." + configDirName
configDirLocation = filepath.Join(homeDir, hiddenConfigDirName)
}
return configDirLocation, nil
}
Within the config folder, you can use any filename you want for your
config - I suggest config.toml
.
Loading the config file
To load a config file, you’ll first want to define the what config
values you’ll use. burntsushi/toml
will ignore options in the TOML
file that you don’t use, so you don’t have to worry about that causing
errors. For instance, here’s the proposed configuration for a project
I’m maintaining, wuzz (the keybindings aren’t currently
implemented, but I’ve left them in for the sake of demonstration):
type Config struct {
General GeneralOptions
Keys map[string]map[string]string
}
type GeneralOptions struct {
FormatJSON bool
Insecure bool
PreserveScrollPosition bool
DefaultURLScheme string
}
It’s pretty simple. Note that we use a named struct for
GeneralOptions
, rather than making Config.General
an anonymous
struct. This makes nesting options simpler and aids tooling.
Loading the config is quite easy:
import (
"errors"
"os"
"github.com/BurntSush/toml"
)
func LoadConfig(configFile string) (*Config, error) {
if _, err := os.Stat(configFile); os.IsNotExist(err) {
return nil, errors.New("Config file does not exist.")
} else if err != nil {
return nil, err
}
var conf Config
if _, err := toml.DecodeFile(configFile, &conf); err != nil {
return nil, err
}
return &conf, nil
}
toml.DecodeFile
will automatically populate conf
with the values
set in the TOML file. (Note that we pass &conf
to toml.DecodeFile
,
not conf
- we need to populate the struct we actually have, not a
copy). Given the above Config
type and the following TOML file…
[general]
defaultURLScheme = "https"
formatJSON = true
preserveScrollPosition = true
insecure = false
[keys]
[keys.general]
"C-j" = "next-view"
"C-k" = "previous-view"
[keys.response-view]
"<down>" = "scroll-down"
…we’ll get a Config
like the following:
Config{
General: GeneralOptions{
DefaultURLScheme: "https",
FormatJSON: true,
PreserveScrollPosition: true,
Insecure: false,
},
Keys: map[string]map[string]string{
"general": map[string]string{
"C-j": "next-view",
"C-k": "previous-view",
},
"response-view": map[string]string{
"<down>": "scroll-down",
},
},
}
Automatically decoding values
wuzz
actually uses another value in its config - a default HTTP
timeout. In this case, though, there’s no native TOML value that
cleanly maps to the type we want - a time.Duration
. Fortunately, the
TOML library we’re using supports automatically decoding TOML values
into custom Go values. To do so, we’ll need a type that wraps
time.Duration
:
type Duration struct {
time.Duration
}
Next we’ll need to add an UnmarshalText
method, so we satisfy the
toml.TextUnmarshaler
interface. This will let toml
know that we
expect a string value which will be passed into our UnmarshalText
method.
func (d *Duration) UnmarshalText(text []byte) error {
var err error
d.Duration, err = time.ParseDuration(string(text))
return err
}
Finally, we’ll need to add it to our Config
type. This will go in
Config.General
, so we’ll add it to GeneralOptions
:
type GeneralOptions struct {
Timeout Duration
// ...
}
Now we can add it to our TOML file, and toml.DecodeFile
will
automatically populate our struct with a Duration
value!
Input:
[general]
timeout = "1m"
# ...
Equivalent output:
Config{
General: GeneralOptions{
Timeout: Duration{
Duration: 1 * time.Minute
},
// ...
}
}
Default config values
We now have configuration loading, and we’re even decoding a text
field to a custom Go type - we’re nearly finished! Next we’ll want to
specify defaults for the configuration. We want values specified in
the config to override our defaults. Fortunately, toml
makes really
easy to do.
Remember how we passed in &conf
to toml.DecodeFile
? That was an
empty Config
struct - but we can also pass one with its values
pre-populated. toml.DecodeFile
will set any values that exist in the
TOML file, and ignore the rest. First we’ll create the default values:
import (
"time"
)
var DefaultConfig = Config{
General: GeneralOptions{
DefaultURLScheme: "https",
FormatJSON: true,
Insecure: false,
PreserveScrollPosition: true,
Timeout: Duration{
Duration: 1 * time.Minute,
},
},
// You can omit stuff from the default config if you'd like - in
// this case we don't specify Config.Keys
}
Next, we simply modify the LoadConfig
function to use
DefaultConfig
:
func LoadConfig(configFile string) (*Config, error) {
if _, err := os.Stat(configFile); os.IsNotExist(err) {
return nil, errors.New("Config file does not exist.")
} else if err != nil {
return nil, err
}
conf := DefaultConfig
if _, err := toml.DecodeFile(configFile, &conf); err != nil {
return nil, err
}
return &conf, nil
}
The important line here is conf := DefaultConfig
- now when conf
is passed to toml.DecodeFile
it will populate that.
Summary
I hope this post helped you! you should now be able to configure Go apps using TOML with ease.
If this post was helpful to you, or you have comments or corrections, please let me know! My email address is at the bottom of the page. I’m also looking for work at the moment, so feel free to get in touch if you’re looking for developers.
Complete code
package config
import (
"errors"
"path/filepath"
"os"
"runtime"
"time"
"github.com/BurntSushi/toml"
"github.com/mitchellh/go-homedir"
)
var configDirName = "example"
func GetDefaultConfigDir() (string, error) {
var configDirLocation string
homeDir, err := homedir.Dir()
if err != nil {
return "", err
}
switch runtime.GOOS {
case "linux":
// Use the XDG_CONFIG_HOME variable if it is set, otherwise
// $HOME/.config/example
xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
if xdgConfigHome != "" {
configDirLocation = xdgConfigHome
} else {
configDirLocation = filepath.Join(homeDir, ".config", configDirName)
}
default:
// On other platforms we just use $HOME/.example
hiddenConfigDirName := "." + configDirName
configDirLocation = filepath.Join(homeDir, hiddenConfigDirName)
}
return configDirLocation, nil
}
type Config struct {
General GeneralOptions
Keys map[string]map[string]string
}
type GeneralOptions struct {
DefaultURLScheme string
FormatJSON bool
Insecure bool
PreserveScrollPosition bool
Timeout Duration
}
type Duration struct {
time.Duration
}
func (d *Duration) UnmarshalText(text []byte) error {
var err error
d.Duration, err = time.ParseDuration(string(text))
return err
}
var DefaultConfig = Config{
General: GeneralOptions{
DefaultURLScheme: "https",
FormatJSON: true,
Insecure: false,
PreserveScrollPosition: true,
Timeout: Duration{
Duration: 1 * time.Minute,
},
},
}
func LoadConfig(configFile string) (*Config, error) {
if _, err := os.Stat(configFile); os.IsNotExist(err) {
return nil, errors.New("Config file does not exist.")
} else if err != nil {
return nil, err
}
conf := DefaultConfig
if _, err := toml.DecodeFile(configFile, &conf); err != nil {
return nil, err
}
return &conf, nil
}
If you’d like to leave a comment, please email benaiah@mischenko.com