Configuração com viper: env, flags e arquivos

1. Introdução ao Viper e seu papel em aplicações Go

Aplicações modernas em Go exigem flexibilidade na obtenção de configurações. Usar variáveis de ambiente brutas com os.Getenv ou flags simples com o pacote flag rapidamente se torna insustentável à medida que o número de parâmetros cresce. É aí que entra o Viper, a biblioteca de configuração mais popular do ecossistema Go.

O Viper suporta múltiplas fontes de configuração simultaneamente: arquivos (YAML, JSON, TOML, HCL, envfile, Java properties), variáveis de ambiente, flags de linha de comando e até sistemas remotos como etcd ou Consul. Sua grande vantagem é a hierarquia de precedência, que define como conflitos entre fontes são resolvidos (do maior para o menor):

  1. Chamadas explícitas via Set
  2. Flags de linha de comando
  3. Variáveis de ambiente
  4. Arquivos de configuração
  5. Valores padrão (SetDefault)

Isso permite que um desenvolvedor defina defaults sensatos, que podem ser sobrescritos por um arquivo YAML no servidor, que por sua vez podem ser substituídos por variáveis de ambiente em containers Docker, e finalmente por flags durante execução local.

2. Instalação e configuração básica

A instalação é simples:

go get github.com/spf13/viper

Uma estrutura de projeto recomendada:

meuapp/
├── cmd/
│   └── main.go
├── config/
│   ├── config.go
│   └── config.yaml
├── go.mod
└── go.sum

O Viper pode ser usado de duas formas: como pacote global (viper.GetString(...)) ou instanciando um objeto *viper.Viper. A segunda forma é preferível para testabilidade e injeção de dependência.

Exemplo mínimo de inicialização:

package config

import (
    "fmt"
    "github.com/spf13/viper"
)

func LoadConfig() (*viper.Viper, error) {
    v := viper.New()
    v.SetConfigName("config")
    v.SetConfigType("yaml")
    v.AddConfigPath(".")
    v.AddConfigPath("./config")

    if err := v.ReadInConfig(); err != nil {
        return nil, fmt.Errorf("erro ao ler config: %w", err)
    }
    return v, nil
}

Arquivo config.yaml correspondente:

app:
  name: "meuapp"
  port: 8080

Uso no main.go:

v, _ := config.LoadConfig()
fmt.Println(v.GetString("app.name")) // "meuapp"
fmt.Println(v.GetInt("app.port"))    // 8080

3. Lendo configuração de arquivos (YAML, JSON, TOML)

O Viper suporta nativamente diversos formatos. Basta configurar o tipo e o caminho.

v := viper.New()
v.SetConfigName("config")    // sem extensão
v.SetConfigType("yaml")      // ou "json", "toml", "hcl"
v.AddConfigPath("/etc/app/") // diretórios em ordem de busca
v.AddConfigPath("$HOME/.app")
v.AddConfigPath(".")

Para arquivos ausentes, trate o erro adequadamente:

if err := v.ReadInConfig(); err != nil {
    if _, ok := err.(viper.ConfigFileNotFoundError); ok {
        // Arquivo não encontrado - pode ser aceitável se env/flags estiverem definidos
        fmt.Println("Nenhum arquivo de config encontrado, usando defaults")
    } else {
        return nil, fmt.Errorf("erro fatal ao ler config: %w", err)
    }
}

Exemplo prático com seções aninhadas (config.yaml):

server:
  host: "0.0.0.0"
  port: 3000
  timeout: 30

database:
  host: "localhost"
  port: 5432
  user: "admin"
  password: "secret"
  name: "meubanco"

Acessando valores:

host := v.GetString("server.host")
dbPort := v.GetInt("database.port")

4. Utilizando variáveis de ambiente

O Viper permite mapear automaticamente variáveis de ambiente para chaves de configuração. Use AutomaticEnv() e SetEnvPrefix:

v := viper.New()
v.SetEnvPrefix("APP")        // prefixo opcional
v.AutomaticEnv()             // mapeia automaticamente

Agora, a variável de ambiente APP_DATABASE_HOST será mapeada para a chave database.host. O Viper converte pontos e hífens para underscore automaticamente.

Para controle explícito, use BindEnv:

v.BindEnv("database.host", "DB_HOST")
v.BindEnv("server.port", "PORT")

Isso é útil quando o nome da variável de ambiente não segue o padrão de nomenclatura do Viper.

Exemplo prático de sobrescrita:

export APP_DATABASE_HOST="db-producao.example.com"
export APP_SERVER_PORT=9000
v.SetDefault("database.host", "localhost")
v.SetDefault("server.port", 3000)
v.AutomaticEnv()

fmt.Println(v.GetString("database.host")) // "db-producao.example.com"
fmt.Println(v.GetInt("server.port"))      // 9000

5. Integração com flags de linha de comando (pflag)

O pacote flag padrão do Go não suporta flags estilo POSIX (--flag). Para isso, use github.com/spf13/pflag:

go get github.com/spf13/pflag

Vinculação de flags ao Viper:

import "github.com/spf13/pflag"

func main() {
    v := viper.New()

    // Define flags
    pflag.Int("port", 8080, "Porta do servidor")
    pflag.String("db-host", "localhost", "Host do banco")
    pflag.Parse()

    // Vincula ao Viper
    v.BindPFlag("server.port", pflag.Lookup("port"))
    v.BindPFlag("database.host", pflag.Lookup("db-host"))

    // Define defaults
    v.SetDefault("server.port", 3000)
    v.SetDefault("database.host", "localhost")

    // Lê arquivo e env (já configurados anteriormente)
    v.AutomaticEnv()

    fmt.Println("Porta:", v.GetInt("server.port"))
    fmt.Println("DB Host:", v.GetString("database.host"))
}

Prioridade prática: flag > env > arquivo > default.

Execução:

go run main.go --port 9090
# Porta: 9090 (flag vence)
export APP_SERVER_PORT=7070
go run main.go
# Porta: 7070 (env vence sobre arquivo e default)

6. Estruturando a configuração com structs e deserialização

Para evitar chamadas dispersas a GetString, GetInt, etc., deserialize toda a configuração em structs tipadas usando Unmarshal:

type Config struct {
    Server   ServerConfig   `mapstructure:"server"`
    Database DatabaseConfig `mapstructure:"database"`
}

type ServerConfig struct {
    Host    string `mapstructure:"host"`
    Port    int    `mapstructure:"port"`
    Timeout int    `mapstructure:"timeout"`
}

type DatabaseConfig struct {
    Host     string `mapstructure:"host"`
    Port     int    `mapstructure:"port"`
    User     string `mapstructure:"user"`
    Password string `mapstructure:"password"`
    Name     string `mapstructure:"name"`
}

Uso:

var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
    log.Fatalf("erro ao deserializar config: %v", err)
}

fmt.Printf("Servidor rodando em %s:%d\n", cfg.Server.Host, cfg.Server.Port)

Para validação, combine com go-playground/validator:

import "github.com/go-playground/validator/v10"

type ServerConfig struct {
    Host    string `mapstructure:"host" validate:"required,hostname"`
    Port    int    `mapstructure:"port" validate:"required,min=1024,max=65535"`
}

validate := validator.New()
if err := validate.Struct(&cfg); err != nil {
    log.Fatalf("config inválida: %v", err)
}

7. Boas práticas e armadilhas comuns

Valores ausentes: Use IsSet para verificar se uma chave foi explicitamente definida (não é default):

if v.IsSet("database.password") {
    // usar senha
} else {
    // alertar que senha não foi configurada
}

Defaults: Defina valores padrão antes de ler arquivos/env:

v.SetDefault("server.port", 3000)
v.SetDefault("database.host", "localhost")

Recarregamento em tempo de execução: Use WatchConfig para detectar mudanças em arquivos:

v.WatchConfig()
v.OnConfigChange(func(e fsnotify.Event) {
    fmt.Println("Config alterada:", e.Name)
    // recarregar structs ou reiniciar serviços
})

Evitar o pacote global: Injete *viper.Viper como dependência em vez de usar viper.GetString(). Isso facilita testes e evita efeitos colaterais.

Segurança: Nunca logue senhas, tokens ou chaves de API. Use logs condicionais:

log.Printf("Conectando ao banco %s:%d", cfg.Database.Host, cfg.Database.Port)
// NÃO logue cfg.Database.Password

Cuidado com maiúsculas/minúsculas: O Viper é case-insensitive para chaves, mas variáveis de ambiente são case-sensitive no Linux. Use o prefixo e AutomaticEnv consistentemente.

Referências