In a previous article, Simplifying Environment Config in Go, I wrote about creating a library for unmarshaling environment variables into a configuration struct. In this article, I will explain how I added support for nested structs, pointers, and slices. Here are links to: code before the changes, after the changes, and the related PR and additional refactoring commit.

Preliminary Refactoring

When I first began the process of adding the new type support, I realized that some of the functions defined in "getenv.go" needed to be in their own file (since they were unrelated helpers) called "helpers.go". In the process, I added support for "on"/"off" for boolean variables. This focused "getenv.go" only on the functions related to pulling values from environment variables.

Adding Struct and Pointer Support

The goal for supporting structs is that the actual struct-type field shouldn't have to be tagged. Instead, the library should be able to recursively scan fields to find tagged fields. For example, the following struct-type field would have fields populated:

...
type redisConfig struct {
    Host string `env:"REDIS_HOST"`
    Port int    `env:"REDIS_PORT"`
}

type appConfig struct {
    Redis redisConfig
}

func main() {
    config := appConfig{}
    envconfig.Unmarshal(&config)
    ...
}
...

This was achieved by pulling out the field iteration code that was previously in "Unmarshal" into a new function called "unmarshalStructValue". After that, this if statement (unmarshal.go, line 33) was added:

// Handle structs (with recursion!)
if fv.Kind() == reflect.Struct {
    err := unmarshalStructValue(fv.Type(), fv)
    if err != nil {
        return err
    }

    // Since we ignore tags on fields that have their own fields, skip to the next one
    continue
}

This code runs before the normal Kind switch that handles other types. We also resolve pointers in a similar way (unmarshal.go, line 22):

// Handle pointers
if fv.Kind() == reflect.Ptr {
    // Skip over it if it's nil
    if fv.IsNil() {
        continue
    }

    // We're just going to reset fv here so the code below works as-if pointers don't exist
    fv = fv.Elem()
}

Adding Slice Support

Since environment variables are always represented as strings (and I couldn't think of a personal use-case for another slice type), I only implemented support for string slices. If you want to implement support for any other types, I'll accept a PR. To support string slices, I added a new case for "reflect.Slice" to the big "Kind" switch in "getenv.go". After it determines the item kind is string, it does this:

func setStringSliceFromEnv(v reflect.Value, s string) bool {
    v.Set(reflect.ValueOf(strings.Split(s, ":")))
    return true
}

What this function does is it sets the value to a split (by colon) of the string value "s" from the environment variable. It's pretty straightforward, and adding support for other types of slices would not be very difficult if it turns out to be needed for something.

Conclusion

Thank you for reading my article. I will try to publish an article about once every week or two about some coding topic that's on my mind. The next article will be about creating a quick API for generating IDs like Twitter's Snowflake IDs. You can follow me on Twitter for more coding stuff: @everettcaleb.