Why should you try GoCfg or yet another Go config manager
Tags: go, opensource, github
Published: 2024-04-11 12:39:52

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 {% embed https://github.com/Jagerente/gocfg %}

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`.

```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
}
```
> Looks pretty simple, doesn't it? 

3. Let's create a configuration contract by filling in the Config structure:

```go
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"`
}
```

> 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.

4. Let's go back to the functionality and inspect it:

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

	cfgManager := gocfg.NewDefault()

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

	return cfg, nil
}
```

> Code above is equivalent to the following:

```go
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
}
```

- `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:

```go
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
}
```

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")`:

```go
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
}
```

Do not forget to update our contract:

```go
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"`
}
```

#### Want to use values ONLY from default tags? 

No problem, let's add `.ForceDefaults()`

```go
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
}
```

#### Custom Parser Provider

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

```go
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
}
```

#### Custom Value Provider

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

```go
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
}
```

#### Documentation Generation

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

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

```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)
	}
}
```

2. 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

```

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](https://github.com/Jagerente/gocfg) 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!