Why should you try GoCfg or yet another Go config manager

jagerente

Jagerente

Posted on April 11, 2024

Why should you try GoCfg or yet another Go config manager

Developing applications in Golang, I've tried numerous Config managers, but unfortunately, none of them fully satisfied my needs:

  • Simplicity and ease of use; really as simple as possible.
  • Struct unmarshalling.
  • Struct tags usage.
  • Customization and injection of custom parsers and value providers as freely as possible while maintaining ease of use.
  • Time spent on deploying a new configuration:
    • Creating a configuration contract
    • Setting default values
    • Setting constraints
    • etc., up to obtaining a ready-to-use configuration.

Don't get me wrong, most of this is implemented in existing Config managers, but unfortunately - partially, not all at once.

So I embarked on reinventing the wheel, doing my best to ensure it wouldn't end up square.

Meet the GoCfg

GitHub logo Jagerente / gocfg

⚙️ Golang config manager. Control your configurations using tags, unmarshal to structs, implement and inject your own value providers and parsers.

CI CodeQL Go Report Card codecov Go Reference

GoCfg

Key Features

  • Unmarshal from Environment Variables, .env and any other sources right to your structs.
  • Set default values for each field using tags.
  • Easy to inject as much custom parsers as you need.
  • Easy to inject your own values providers as much as you need and use them all at once with priority.
  • Automatic documentation generator.

Quick start

Install package:

go get -u github.com/Jagerente/gocfg
Enter fullscreen mode Exit fullscreen mode

Basic usage:

It will use environment variables and default values defined in tags.

package main
import (
    "github.com/Jagerente/gocfg"
    "github.com/Jagerente/gocfg/pkg/parsers"
    "github.com/Jagerente/gocfg/pkg/values"
    "time"
)

type LoggerConfig struct {
    LogLevel string `env:"LOG_LEVEL" default:"debug"`
}

type RedisConfig struct {
    RedisHost     string `env:"REDIS_HOST" default:"localhost"`
    RedisPort     uint16 `env:"REDIS_PORT" default:"6379"`
    RedisUser     string `env:"REDIS_USER,omitempty"`
    RedisPassword string `env:"REDIS_PASS"`
    RedisDatabase string `env:"REDIS_DATABASE"`
}

type AppConfig struct {
    // Supported Tags:
    // - env: Specifies the environment variable name.
    // - default: Specifies the default value for the field.
    // - omitempty: Allows empty fields. 
Enter fullscreen mode Exit fullscreen mode

I don't want to "beat around the bush", so I'll just familiarize you with the functionality of my library and try to explain why it's convenient and why you should try it.

What's inside

  • Define the configuration contract using Golang Structures and Tags:
    • Variable name
    • Default value
    • Permission not to set the variable value
    • Description for documentation.
  • Unmarshal from Environment Variables, .env files, or implement, for example your own Custom Value Provider for YAML and inject it.
  • Custom Value Parsers
    • Not satisfied with the default Value Parsers? Want the value 10 to be parsed into the number 83 during unmarshalling? Or maybe you want to parse your own type, like time.Duration? It's covered by gocfg!
  • Automatic Documentation Generation based on the contract.

Quick view

  1. Start by importing go get -u github.com/Jagerente/gocfg.
  2. Create an internal configuration package, for example - internal/config/config.go.
package config

import (
    "github.com/Jagerente/gocfg"
    "github.com/Jagerente/gocfg/pkg/values"
    "time"
)

type Config struct {
}

func New() (*Config, error) {
    var cfg = new(Config)

    cfgManager := gocfg.NewDefault()

    if err := cfgManager.Unmarshal(cfg); err != nil {
        return nil, err
    }

    return cfg, nil
}
Enter fullscreen mode Exit fullscreen mode

Looks pretty simple, doesn't it?

  1. Let's create a configuration contract by filling in the Config structure:
type LoggerConfig struct {
    LogLevel     int  `env:"LOG_LEVEL" default:"6" description:"Possible values:\n0 - Trace\n1 - Debug\n2 - Info\n3 - Error"`
    ReportCaller bool `env:"REPORT_CALLER" default:"true"`
    LogFormatter int  `env:"LOG_FORMATTER" default:"0"`
}

type CassandraConfig struct {
    CassandraHosts    string `env:"CASSANDRA_HOSTS" default:"127.0.0.1"`
    CassandraKeyspace string `env:"CASSANDRA_KEYSPACE" default:"messenger"`
}

type RouterConfig struct {
    ServerPort               uint16        `env:"SERVER_PORT" default:"8080"`
    Debug                    bool          `env:"ROUTER_DEBUG" default:"true"`
    CacheAdapter             string        `env:"CACHE_ADAPTER,omitempty" description:"Leave blank to not use.\nPossible values:\n- redis\n- memcache"`
    CacheAdapterTTL          time.Duration `env:"CACHE_ADAPTER_TTL,omitempty" default:"1m"`
    CacheAdapterNoCacheParam string        `env:"CACHE_ADAPTER_NOCACHE_PARAM,omitempty" default:"no-cache"`
}

type Config struct {
    LoggerConfig               `title:"Logger configuration"`
    RouterConfig               `title:"Router configuration"`
    CassandraConfig            `title:"Cassandra configuration"`
}
Enter fullscreen mode Exit fullscreen mode

I believe that the tag names are informative and intuitive enough, but just in case, let's go through them:

  • env - Specifies the variable name in the configuration file and is necessary for Value Provider implementations to map Key-Value.
  • default - Specifies the default value of the variable.
  • omitempty - Means that the variable can be left undeclared, and then the default value will be used, or if the default is missing, the field will take Golang's zero value; for example, 0 for int type or false for bool type.
  • description - The field is used during automatic documentation generation. It is necessary to provide a description of the variable.
  • title - The field is used during automatic documentation generation. It is necessary to separate groups of variables; a group consists of variables in a separate structure, for example, LoggerConfig is a separate group of variables.
  1. Let's go back to the functionality and inspect it:
func New() (*Config, error) {
    var cfg = new(Config)

    cfgManager := gocfg.NewDefault()

    if err := cfgManager.Unmarshal(cfg); err != nil {
        return nil, err
    }

    return cfg, nil
}
Enter fullscreen mode Exit fullscreen mode

Code above is equivalent to the following:

func New() (*Config, error) {
    var cfg = new(Config)

    cfgManager := gocfg.NewEmpty().
        UseDefaults().
        AddParserProviders(parsers.NewDefaultParserProvider()).
        AddValueProviders(values.NewEnvProvider())

    if err := cfgManager.Unmarshal(cfg); err != nil {
        return nil, err
    }

    return cfg, nil
}
Enter fullscreen mode Exit fullscreen mode
  • UseDefaults() - Enables the use of default values from the default tag.
  • AddParserProviders() - Sets value parsers.
    • The following types are supported by default parsers:
    • time.Duration
    • bool
    • string
    • int, int8, int16, int32, int64
    • uint, uint8, uint16, uint32, uint64
    • float32, float64
  • AddValueProviders() - Sets configuration providers.
  • values.NewEnvProvider() - Parser for Environment Variables.
    • Not to be confused with .env file!

.ENV files parser

Let's add parsing values from the .env file, the driver is available out of the box:

func New() (*Config, error) {
    // With default '.env' file
    dotEnvProvider, _ := values.NewDotEnvProvider()

    // With custom env file path 
    dotEnvProvider, _ = values.NewDotEnvProvider("local.env")

    // With multiple env files
    dotEnvProvider, _ = values.NewDotEnvProvider("local.env", "dev.env")

    var cfg = new(Config)

    cfgManager := gocfg.NewEmpty().
        UseDefaults().
        AddParserProviders(parsers.NewDefaultParserProvider()).
        AddValueProviders(
            values.NewEnvProvider(),
            dotEnvProvider,
        )

    if err := cfgManager.Unmarshal(cfg); err != nil {
        return nil, err
    }

    return cfg, nil
}
Enter fullscreen mode Exit fullscreen mode

You might be wondering:

What will happen when using multiple .env files?

Values will be read and written in the order from the first added to the last added file. Suppose the first file has 5/10 variables, and the second has 7/10, then the second file will overwrite 2 intersecting values that were already written from the first file, and the missing ones will simply be written.

Or:

What about the priority of value providers, when you add multiple?

When attempting to retrieve a value from all value providers in the order from the first added to the last added, the first non-empty value will be returned.

Different Key Tag name

All you need is add .UseCustomKeyTag("mapstructure"):

func New() (*Config, error) {
    dotEnvProvider, _ := values.NewDotEnvProvider()

    var cfg = new(Config)

    cfgManager := gocfg.NewEmpty().
        UseDefaults().
        AddParserProviders(parsers.NewDefaultParserProvider()).
        AddValueProviders(
            values.NewEnvProvider(),
            dotEnvProvider,
        ).
        UseCustomKeyTag("mapstructure")

    if err := cfgManager.Unmarshal(cfg); err != nil {
        return nil, err
    }

    return cfg, nil
}
Enter fullscreen mode Exit fullscreen mode

Do not forget to update our contract:

type LoggerConfig struct {
    LogLevel     int  `mapstructure:"LOG_LEVEL" default:"6" description:"Possible values:\n0 - Trace\n1 - Debug\n2 - Info\n3 - Error"`
    ReportCaller bool `mapstructure:"REPORT_CALLER" default:"true"`
    LogFormatter int  `mapstructure:"LOG_FORMATTER" default:"0"`
}

type CassandraConfig struct {
    CassandraHosts    string `mapstructure:"CASSANDRA_HOSTS" default:"127.0.0.1"`
    CassandraKeyspace string `mapstructure:"CASSANDRA_KEYSPACE" default:"messenger"`
}

type RouterConfig struct {
    ServerPort               uint16        `mapstructure:"SERVER_PORT" default:"8080"`
    Debug                    bool          `mapstructure:"ROUTER_DEBUG" default:"true"`
    CacheAdapter             string        `mapstructure:"CACHE_ADAPTER,omitempty" description:"Leave blank to not use.\nPossible values:\n- redis\n- memcache"`
    CacheAdapterTTL          time.Duration `mapstructure:"CACHE_ADAPTER_TTL,omitempty" default:"1m"`
    CacheAdapterNoCacheParam string        `mapstructure:"CACHE_ADAPTER_NOCACHE_PARAM,omitempty" default:"no-cache"`
}

type Config struct {
    LoggerConfig               `title:"Logger configuration"`
    RouterConfig               `title:"Router configuration"`
    CassandraConfig            `title:"Cassandra configuration"`
}
Enter fullscreen mode Exit fullscreen mode

Want to use values ONLY from default tags?

No problem, let's add .ForceDefaults()

func New() (*Config, error) {
    dotEnvProvider, _ := values.NewDotEnvProvider()

    var cfg = new(Config)

    cfgManager := gocfg.NewEmpty().
        UseDefaults().
        AddParserProviders(parsers.NewDefaultParserProvider()).
        AddValueProviders(
            values.NewEnvProvider(),
            dotEnvProvider,
        ).
        UseCustomKeyTag("mapstructure").
        ForceDefaults()

    if err := cfgManager.Unmarshal(cfg); err != nil {
        return nil, err
    }

    return cfg, nil
}
Enter fullscreen mode Exit fullscreen mode

Custom Parser Provider

Nothing special, just an example of already implemented time.Duration parser.

type CustomParserProvider struct {
}

func NewCustomParserProvider() *CustomParserProvider {
    return &CustomParserProvider{}
}

func (p *CustomParserProvider) Get(field reflect.Value) (func(v string) (any, error), bool) {
    switch field.Type() {
    case reflect.TypeOf(time.Duration(83)):
        return func(v string) (any, error) {
            return time.ParseDuration(v)
        }, true
    default:
        return nil, false
    }
}

func New() (*Config, error) {
    customParserProvider := NewCustomParserProvider()   

    var cfg = new(Config)

    cfgManager := gocfg.NewDefault().
        AddParserProviders(customParserProvider)

    if err := cfgManager.Unmarshal(cfg); err != nil {
        return nil, err
    }

    return cfg, nil
}
Enter fullscreen mode Exit fullscreen mode

Custom Value Provider

Nothing special as well, let's just implement provider that will read environment variables with a prefix:

type CustomValueProvider struct {
}

func NewCustomValueProvider() *CustomValueProvider {
    return &CustomValueProvider{}
}

func (p *CustomValueProvider) Get(key string) string {
    return os.Getenv("CUSTOM_" + key)
}

func New() (*Config, error) {
    customValueProvider := NewCustomValueProvider() 

    var cfg = new(Config)

    cfgManager := gocfg.NewDefault().
        AddValueProviders(customValueProvider)

    if err := cfgManager.Unmarshal(cfg); err != nil {
        return nil, err
    }

    return cfg, nil
}
Enter fullscreen mode Exit fullscreen mode

Documentation Generation

You can do it just in 2 steps using copy & paste dev power.

  1. Create app /cmd/docs/main.go:
package main

import (
    "fmt"
    "github.com/Jagerente/gocfg"
    "github.com/Jagerente/gocfg/pkg/docgens"
    "os"
    "your_cool_app/internal/config"
)

const outputFile = ".env.dist.generated"

func main() {
    cfg := new(config.Config)

    file, err := os.Create(outputFile)
    if err != nil {
        panic(fmt.Errorf("error creating %s file: %v", outputFile, err))
    }

    cfgManager := gocfg.NewDefault()
    if err := cfgManager.GenerateDocumentation(cfg, docgens.NewEnvDocGenerator(file)); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Run it with go run cmd/docs/main.go; after which the file .env.dist.generated will be generated:
# Auto-generated config

#############################
# Logger configuration
#############################

# Description:
#  Possible values:
#  0 - Trace
#  1 - Debug
#  2 - Info
#  3 - Error
LOG_LEVEL=6

REPORT_CALLER=true

LOG_FORMATTER=0

#############################
# Router configuration
#############################

SERVER_PORT=8080

ROUTER_DEBUG=true

# Allowed to be empty
# Description:
#  Leave blank to not use.
#  Possible values:
#  - redis
#  - memcache
CACHE_ADAPTER=

# Allowed to be empty
CACHE_ADAPTER_TTL=1m

# Allowed to be empty
CACHE_ADAPTER_NOCACHE_PARAM=no-cache

#############################
# Cassandra configuration
#############################

CASSANDRA_HOSTS=127.0.0.1

CASSANDRA_KEYSPACE=messenger

Enter fullscreen mode Exit fullscreen mode

This is my very first open-source library, as well as this article, so If you've reached this point, I'm truly grateful to you. I'd like to ask you to share the most important thing - criticism! I also won't refuse kind words, stars on GitHub repository and any kind of support in promoting this library. <3

This project is open to contributions. If you wish to add your own implementation of a Value Provider driver or Documentation Generator, or perhaps you'd like to conduct a code review or suggest improvements, feel free to submit a pull request or contact me!

💖 💪 🙅 🚩
jagerente
Jagerente

Posted on April 11, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related