Variáveis de ambiente e configuração com Viper

Variáveis de ambiente e configuração com Viper

Configuração em Go com os.LookupEnv, godotenv e Viper: arquivo YAML, variáveis de ambiente com prefixo, flags do Cobra na mesma instância lida, durações validadas em vez de zero, senha mascarada com url.Redacted e validação com o go-playground/validator.
Golang

• • 17 min de leitura

Um sistema bem projetado separa código de configuração. O mesmo binário deve funcionar em desenvolvimento com um banco SQLite local, em staging com PostgreSQL compartilhado e em produção com um cluster gerenciado — sem recompilar, sem alterar código. A configuração é o que muda, não o programa.

A metodologia The Twelve-Factor App estabelece que configurações devem vir de variáveis de ambiente. Na prática, sistemas reais combinam múltiplas fontes: arquivos para configurações padrão, variáveis de ambiente para sobrescritas por ambiente, flags de linha de comando para sobrescritas pontuais. O Viper unifica todas essas fontes com precedência bem definida.

Abordagem 1: os.Getenv e os.LookupEnv

Para aplicações simples, a biblioteca padrão é suficiente:

package main

import (
    "fmt"
    "os"
    "strconv"
    "time"
)

type Config struct {
    Servidor ServidorConfig
    Banco    BancoConfig
    JWT      JWTConfig
}

type ServidorConfig struct {
    Host         string
    Porta        int
    ReadTimeout  time.Duration
    WriteTimeout time.Duration
}

type BancoConfig struct {
    URL          string
    MaxConexoes  int
    MaxOciosas   int
}

type JWTConfig struct {
    Segredo     string
    Expiracao   time.Duration
}

func carregarConfigEnv() (*Config, error) {
    porta, err := strconv.Atoi(getEnv("SERVIDOR_PORTA", "8080"))
    if err != nil {
        return nil, fmt.Errorf("SERVIDOR_PORTA inválida: %w", err)
    }

    maxConexoes, _ := strconv.Atoi(getEnv("DB_MAX_CONEXOES", "25"))
    maxOciosas, _ := strconv.Atoi(getEnv("DB_MAX_OCIOSAS", "10"))

    segredo, existe := os.LookupEnv("JWT_SEGREDO")
    if !existe || segredo == "" {
        return nil, fmt.Errorf("JWT_SEGREDO é obrigatório")
    }

    dbURL, existe := os.LookupEnv("DATABASE_URL")
    if !existe || dbURL == "" {
        return nil, fmt.Errorf("DATABASE_URL é obrigatório")
    }

    return &Config{
        Servidor: ServidorConfig{
            Host:         getEnv("SERVIDOR_HOST", "0.0.0.0"),
            Porta:        porta,
            ReadTimeout:  getDuration("SERVIDOR_READ_TIMEOUT", 5*time.Second),
            WriteTimeout: getDuration("SERVIDOR_WRITE_TIMEOUT", 10*time.Second),
        },
        Banco: BancoConfig{
            URL:         dbURL,
            MaxConexoes: maxConexoes,
            MaxOciosas:  maxOciosas,
        },
        JWT: JWTConfig{
            Segredo:   segredo,
            Expiracao: getDuration("JWT_EXPIRACAO", 24*time.Hour),
        },
    }, nil
}

func getEnv(chave, padrao string) string {
    if v := os.Getenv(chave); v != "" {
        return v
    }
    return padrao
}

func getDuration(chave string, padrao time.Duration) time.Duration {
    v := os.Getenv(chave)
    if v == "" {
        return padrao
    }
    d, err := time.ParseDuration(v)
    if err != nil {
        return padrao
    }
    return d
}

Abordagem 2: arquivo .env com godotenv

Em desenvolvimento, variáveis de ambiente são frequentemente gerenciadas via arquivo .env:

go get github.com/joho/godotenv
# .env — nunca commitar em repositórios públicos
APP_ENV=desenvolvimento
SERVIDOR_PORTA=8080
DATABASE_URL=postgres://usuario:senha@localhost:5432/app?sslmode=disable
JWT_SEGREDO=meu-segredo-desenvolvimento-nao-usar-em-producao
LOG_LEVEL=debug
REDIS_URL=redis://localhost:6379
package main

import (
    "log"
    "os"

    "github.com/joho/godotenv"
)

func carregarDotEnv() {
    env := os.Getenv("APP_ENV")

    // Carrega arquivo específico do ambiente se existir
    if env != "" {
        godotenv.Load(".env." + env)
    }

    // Carrega .env padrão (não sobrescreve valores já definidos)
    if err := godotenv.Load(); err != nil {
        if os.IsNotExist(err) {
            log.Println("Arquivo .env não encontrado — usando variáveis do sistema")
            return
        }
        log.Printf("Aviso: erro ao carregar .env: %v", err)
    }
}

Arquivos por ambiente:

.env                    ← padrões de desenvolvimento
.env.staging           ← sobrescritas para staging
.env.test              ← sobrescritas para testes
.gitignore             ← .env listado aqui

Abordagem 3: Viper — configuração completa

O Viper é a solução mais completa para configuração em Go. Suporta arquivos YAML, JSON, TOML e dotenv, variáveis de ambiente, flags do Cobra, valores padrão e watching de arquivo para recarga em tempo real. INI e HCL, que versões antigas aceitavam, não fazem mais parte do pacote: na versão 1.21, os dois falham com decoder not found for this format, a menos que um codec seja registrado à parte.

go get github.com/spf13/viper

Arquivo de configuração

# config/config.yaml
app:
  nome: "Minha API"
  versao: "1.0.0"
  ambiente: desenvolvimento

servidor:
  host: "0.0.0.0"
  porta: 8080
  read_timeout: "5s"
  write_timeout: "10s"
  idle_timeout: "60s"

banco:
  url: "postgres://usuario:senha@localhost:5432/app"
  max_conexoes: 25
  max_ociosas: 10
  max_vida: "5m"
  max_ocioso: "2m"

cache:
  url: "redis://localhost:6379"
  ttl: "1h"

jwt:
  expiracao: "24h"

log:
  nivel: "info"
  formato: "json"

Carregando com Viper

package config

import (
    "fmt"
    "strings"
    "time"

    "github.com/spf13/pflag"
    "github.com/spf13/viper"
)

type Config struct {
    App      AppConfig
    Servidor ServidorConfig
    Banco    BancoConfig
    Cache    CacheConfig
    JWT      JWTConfig
    Log      LogConfig
}

type AppConfig struct {
    Nome      string
    Versao    string
    Ambiente  string
}

type ServidorConfig struct {
    Host         string
    Porta        int
    ReadTimeout  time.Duration
    WriteTimeout time.Duration
    IdleTimeout  time.Duration
}

type BancoConfig struct {
    URL        string
    MaxConexoes int
    MaxOciosas  int
    MaxVida    time.Duration
    MaxOcioso  time.Duration
}

type CacheConfig struct {
    URL string
    TTL time.Duration
}

type JWTConfig struct {
    Segredo   string
    Expiracao time.Duration
}

type LogConfig struct {
    Nivel   string
    Formato string
}

// flags pode ser nil; quando vem do Cobra, a flag --port passa a valer
// sobre o ambiente, o arquivo e o padrão
func Carregar(caminhoConfig string, flags *pflag.FlagSet) (*Config, error) {
    v := viper.New()

    // Valores padrão — base de segurança
    v.SetDefault("servidor.host", "0.0.0.0")
    v.SetDefault("servidor.porta", 8080)
    v.SetDefault("servidor.read_timeout", "5s")
    v.SetDefault("servidor.write_timeout", "10s")
    v.SetDefault("servidor.idle_timeout", "60s")
    v.SetDefault("banco.max_conexoes", 25)
    v.SetDefault("banco.max_ociosas", 10)
    v.SetDefault("banco.max_vida", "5m")
    v.SetDefault("banco.max_ocioso", "2m")
    v.SetDefault("cache.ttl", "1h")
    v.SetDefault("jwt.expiracao", "24h")
    v.SetDefault("log.nivel", "info")
    v.SetDefault("log.formato", "json")

    // Arquivo de configuração
    if caminhoConfig != "" {
        v.SetConfigFile(caminhoConfig)
    } else {
        v.AddConfigPath("./config")
        v.AddConfigPath(".")
        v.AddConfigPath("$HOME/.minha-app")
        v.SetConfigName("config")
        v.SetConfigType("yaml")
    }

    if err := v.ReadInConfig(); err != nil {
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
            return nil, fmt.Errorf("ler configuração: %w", err)
        }
        // Arquivo não encontrado é aceitável — usamos padrões e env vars
    }

    // Variáveis de ambiente sobrescrevem o arquivo
    // APP_SERVIDOR_PORTA sobrescreve servidor.porta
    v.SetEnvPrefix("APP")
    v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
    v.AutomaticEnv()

    // A flag precisa ser vinculada a esta instância, v. Vinculá-la ao viper
    // global, com viper.BindPFlag, não teria efeito: a leitura é feita em v
    if flags != nil {
        if f := flags.Lookup("port"); f != nil {
            if err := v.BindPFlag("servidor.porta", f); err != nil {
                return nil, err
            }
        }
    }

    return construirConfig(v)
}

// lerDuracao recusa valores inválidos. GetDuration devolveria 0 em silêncio,
// e um MaxVida de 0 no pool de conexões significa "sem limite"
func lerDuracao(v *viper.Viper, chave string) (time.Duration, error) {
    d, err := time.ParseDuration(v.GetString(chave))
    if err != nil {
        return 0, fmt.Errorf("%s inválido: %w", chave, err)
    }
    return d, nil
}

func construirConfig(v *viper.Viper) (*Config, error) {
    duracoes := map[string]time.Duration{}
    for _, chave := range []string{
        "servidor.read_timeout", "servidor.write_timeout", "servidor.idle_timeout",
        "banco.max_vida", "banco.max_ocioso", "cache.ttl", "jwt.expiracao",
    } {
        d, err := lerDuracao(v, chave)
        if err != nil {
            return nil, err
        }
        duracoes[chave] = d
    }

    cfg := &Config{
        App: AppConfig{
            Nome:     v.GetString("app.nome"),
            Versao:   v.GetString("app.versao"),
            Ambiente: v.GetString("app.ambiente"),
        },
        Servidor: ServidorConfig{
            Host:         v.GetString("servidor.host"),
            Porta:        v.GetInt("servidor.porta"),
            ReadTimeout:  duracoes["servidor.read_timeout"],
            WriteTimeout: duracoes["servidor.write_timeout"],
            IdleTimeout:  duracoes["servidor.idle_timeout"],
        },
        Banco: BancoConfig{
            URL:         v.GetString("banco.url"),
            MaxConexoes: v.GetInt("banco.max_conexoes"),
            MaxOciosas:  v.GetInt("banco.max_ociosas"),
            MaxVida:     duracoes["banco.max_vida"],
            MaxOcioso:   duracoes["banco.max_ocioso"],
        },
        Cache: CacheConfig{
            URL: v.GetString("cache.url"),
            TTL: duracoes["cache.ttl"],
        },
        JWT: JWTConfig{
            Segredo:   v.GetString("jwt.segredo"),
            Expiracao: duracoes["jwt.expiracao"],
        },
        Log: LogConfig{
            Nivel:   v.GetString("log.nivel"),
            Formato: v.GetString("log.formato"),
        },
    }

    return cfg, validarConfig(cfg)
}

func validarConfig(cfg *Config) error {
    var erros []string

    if cfg.Banco.URL == "" {
        erros = append(erros, "banco.url é obrigatório")
    }

    if cfg.JWT.Segredo == "" {
        erros = append(erros, "jwt.segredo é obrigatório")
    }

    if cfg.Servidor.Porta < 1 || cfg.Servidor.Porta > 65535 {
        erros = append(erros, fmt.Sprintf("servidor.porta inválida: %d", cfg.Servidor.Porta))
    }

    if len(erros) > 0 {
        return fmt.Errorf("configuração inválida:\n  - %s",
            strings.Join(erros, "\n  - "))
    }

    return nil
}

Precedência do Viper

Maior precedência → Menor precedência

1. Set explícito (viper.Set)
2. Flag da linha de comando (via pflag/cobra)
3. Variável de ambiente
4. Arquivo de configuração
5. Valor padrão (viper.SetDefault)
// Demonstração de precedência
v := viper.New()
v.SetDefault("porta", 8080)         // 5. padrão
v.SetConfigFile("config.yaml")      // 4. arquivo (porta: 9090)
v.ReadInConfig()
v.SetEnvPrefix("APP")               // sem o prefixo, AutomaticEnv procuraria PORTA
v.AutomaticEnv()                     // 3. APP_PORTA=9191
// v.Set("porta", 9292)             // 1. mais alta precedência

fmt.Println(v.GetInt("porta")) // 9191 se APP_PORTA=9191, senão 9090

Watching: recarga automática de configuração

// fsnotify.Event vem de github.com/fsnotify/fsnotify. O callback roda em outra
// goroutine: onMudanca precisa publicar a nova config de forma sincronizada
func configurarWatching(v *viper.Viper, onMudanca func(cfg *Config)) {
    v.WatchConfig()
    v.OnConfigChange(func(e fsnotify.Event) {
        fmt.Printf("Configuração alterada: %s\n", e.Name)

        cfg, err := construirConfig(v)
        if err != nil {
            fmt.Printf("Erro na nova configuração: %v\n", err)
            return
        }

        onMudanca(cfg)
    })
}

Integração com Cobra

package cmd

import (
    "github.com/spf13/cobra"

    "minha-api/config"
)

var (
    cfgFile string
    cfg     *config.Config // lida antes de qualquer subcomando
)

var rootCmd = &cobra.Command{
    Use: "api",
    PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
        // cmd.Flags() inclui as flags persistentes herdadas da raiz;
        // o Carregar vincula a --port à instância do Viper que ele lê
        var err error
        cfg, err = config.Carregar(cfgFile, cmd.Flags())
        return err
    },
}

func init() {
    rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "",
        "arquivo de configuração")
    rootCmd.PersistentFlags().Int("port", 0,
        "porta do servidor (sobrescreve configuração)")
}

Validação de configuração com struct tags

Para projetos que preferem validação declarativa:

package main

import (
    "fmt"
    "os"
    "strconv"
    "strings"

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

type ConfigValidada struct {
    DatabaseURL  string        `validate:"required,url"`
    Porta        int           `validate:"required,min=1,max=65535"`
    JWTSegredo   string        `validate:"required,min=32"`
    Ambiente     string        `validate:"required,oneof=desenvolvimento staging producao"`
    LogNivel     string        `validate:"required,oneof=debug info warn error"`
}

func carregarEValidar() (*ConfigValidada, error) {
    porta, _ := strconv.Atoi(os.Getenv("PORTA"))
    if porta == 0 {
        porta = 8080
    }

    cfg := &ConfigValidada{
        DatabaseURL: os.Getenv("DATABASE_URL"),
        Porta:       porta,
        JWTSegredo:  os.Getenv("JWT_SEGREDO"),
        Ambiente:    getEnv("APP_ENV", "desenvolvimento"),
        LogNivel:    getEnv("LOG_LEVEL", "info"),
    }

    validate := validator.New()
    if err := validate.Struct(cfg); err != nil {
        erros := err.(validator.ValidationErrors)
        mensagens := make([]string, 0, len(erros))
        for _, e := range erros {
            mensagens = append(mensagens,
                fmt.Sprintf("  %s: falhou na validação '%s'", e.Field(), e.Tag()))
        }
        return nil, fmt.Errorf("configuração inválida:\n%s",
            strings.Join(mensagens, "\n"))
    }

    return cfg, nil
}

Exemplo completo: main.go com configuração

package main

import (
    "context"
    "fmt"
    "log/slog"
    "net/http"
    "net/url"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/joho/godotenv"
    "minha-api/config"
)

func main() {
    // 1. Carrega .env em desenvolvimento
    if os.Getenv("APP_ENV") == "" || os.Getenv("APP_ENV") == "desenvolvimento" {
        godotenv.Load()
    }

    // 2. Carrega configuração completa
    cfg, err := config.Carregar("", nil)
    if err != nil {
        fmt.Fprintf(os.Stderr, "Configuração inválida: %v\n", err)
        os.Exit(1)
    }

    // 3. Configura logger
    var nivel slog.Level
    switch cfg.Log.Nivel {
    case "debug":
        nivel = slog.LevelDebug
    case "warn":
        nivel = slog.LevelWarn
    case "error":
        nivel = slog.LevelError
    default:
        nivel = slog.LevelInfo
    }

    handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: nivel,
    })
    logger := slog.New(handler).With(
        "serviço", cfg.App.Nome,
        "versao", cfg.App.Versao,
        "ambiente", cfg.App.Ambiente,
    )
    slog.SetDefault(logger)

    // 4. Log de inicialização
    slog.Info("aplicação iniciada",
        "porta", cfg.Servidor.Porta,
        "banco", mascararSenha(cfg.Banco.URL),
    )

    // 5. Inicia servidor
    mux := http.NewServeMux()
    mux.HandleFunc("GET /saude", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        fmt.Fprintf(w, `{"status":"ok","versao":"%s"}`, cfg.App.Versao)
    })

    addr := fmt.Sprintf("%s:%d", cfg.Servidor.Host, cfg.Servidor.Porta)
    servidor := &http.Server{
        Addr:         addr,
        Handler:      mux,
        ReadTimeout:  cfg.Servidor.ReadTimeout,
        WriteTimeout: cfg.Servidor.WriteTimeout,
        IdleTimeout:  cfg.Servidor.IdleTimeout,
    }

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

    go func() {
        slog.Info("servidor HTTP iniciado", "addr", addr)
        if err := servidor.ListenAndServe(); err != http.ErrServerClosed {
            slog.Error("erro no servidor", "err", err)
            os.Exit(1)
        }
    }()

    <-quit
    slog.Info("sinal de encerramento recebido")

    ctx, cancelar := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancelar()

    if err := servidor.Shutdown(ctx); err != nil {
        slog.Error("erro no shutdown", "err", err)
        os.Exit(1)
    }

    slog.Info("servidor encerrado com sucesso")
}

func mascararSenha(dsn string) string {
    // postgres://usuario:SENHA@host/banco → postgres://usuario:xxxxx@host/banco.
    // Cortar a string à mão falha com senha que contém @ e com URL sem
    // senha; o Redacted da biblioteca padrão trata os dois casos
    u, err := url.Parse(dsn)
    if err != nil {
        return "(URL inválida)"
    }
    return u.Redacted()
}

Configuração bem feita deixa o mesmo binário rodar em qualquer ambiente, e as três abordagens do artigo cobrem do caso mais simples ao mais completo: os.LookupEnv para poucas variáveis, godotenv para o .env do desenvolvimento e o Viper para juntar padrões, arquivo, ambiente e flags com uma precedência definida. Os defeitos mais comuns vêm de ligações que não acontecem: a variável de ambiente sem o prefixo esperado, a flag vinculada a uma instância do Viper diferente da que é lida, a duração inválida que vira zero. Por isso vale validar a configuração inteira na subida e falhar com uma mensagem clara, em vez de deixar o serviço rodar com um valor que ninguém escolheu. E qualquer configuração que contenha segredo precisa passar por um filtro antes de chegar ao log.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Num ambiente de testes, alguém define APP_PORTA=9191 e roda a demonstração de precedência sem a linha v.SetEnvPrefix("APP"). O programa imprime 9090, o valor do arquivo. A variável de ambiente não deveria vencer o arquivo?

Ver resposta

✓ Resposta: Venceria, se o Viper estivesse procurando por ela. O AutomaticEnv faz cada Get consultar uma variável de ambiente com o nome da chave em maiúsculas, precedido do prefixo, quando há um. Sem SetEnvPrefix, a chave porta corresponde à variável PORTA, que não existe, e o Viper desce para o arquivo. Num teste, com APP_PORTA=9191 definida, a leitura deu 9090; depois de SetEnvPrefix("APP"), deu 9191. Nas chaves aninhadas há uma segunda condição: servidor.porta vira APP_SERVIDOR.PORTA, com ponto, e só o SetEnvKeyReplacer(strings.NewReplacer(".", "_")) do Carregar a transforma em APP_SERVIDOR_PORTA. Como nenhum dos dois casos gera erro, vale um teste que define a variável com t.Setenv e confere o valor lido.

Exercício 2

Na versão antiga da integração com o Cobra, o init fazia viper.BindPFlag("servidor.porta", ...), e o Carregar lia a configuração com v := viper.New(). O comando api serve --port 7777 subia na 8080. Onde a flag foi parar?

Ver resposta

✓ Resposta: As funções do pacote viper, como viper.BindPFlag e viper.GetInt, operam numa instância global, criada pelo próprio pacote. O viper.New() cria outra, independente, sem nada do que foi feito na global. A flag foi vinculada à global, e a configuração foi lida da nova. Num teste, depois de --port 7777, a global respondia 7777 e a instância do Carregar respondia 8080, o padrão. A correção do artigo vincula a flag dentro do Carregar, na mesma instância v, recebendo o FlagSet do Cobra; com ela, a mesma CLI respondeu 8080 sem nada, 9000 com APP_SERVIDOR_PORTA=9000 e 7777 com a flag passada, mesmo com a variável definida. Uma flag vinculada e não passada não atrapalha: o padrão 0 dela não substituiu o 8080. O ideal é escolher um estilo só: ou tudo na global, ou instâncias passadas explicitamente, que são mais fáceis de testar.

Exercício 3

A primeira versão de mascararSenha procurava o primeiro @ e o : anterior a ele. Uma equipe usa a senha p@ss, e outra conecta sem senha, autenticando por certificado. O que cada uma via no log de inicialização?

Ver resposta

✓ Resposta: Com a senha p@ss, o primeiro @ está dentro da senha, e o corte deixa o resto dela à vista: num teste, postgres://usuario:p@ss@localhost/app virou postgres://usuario:***@ss@localhost/app. Um pedaço da senha no log é um vazamento, e logs costumam ser copiados para sistemas com muito mais acesso que o banco. Sem senha, não há : entre o usuário e o @, e a função encontra o : do esquema: postgres://usuario@localhost/app virou postgres:***@localhost/app, sem o usuário, o que atrapalha o diagnóstico. O url.Parse conhece a gramática das URLs, inclusive a regra de que o separador é o último @ antes do host, e o método Redacted, da biblioteca padrão desde o Go 1.15, troca só a senha por xxxxx. Na mesma medição, ele produziu postgres://usuario:xxxxx@localhost/app e deixou a URL sem senha intacta.

Exercício 4

Na versão antiga de construirConfig, quatro durações eram lidas com maxVida, _ := time.ParseDuration(...). Um operador escreve max_vida: "5 minutos" no YAML. A aplicação sobe sem reclamar. O que acontece com as conexões do banco?

Ver resposta

✓ Resposta: O ParseDuration só entende unidades como s, m e h, e devolve erro para "5 minutos", junto com o valor zero. Com o erro descartado, MaxVida fica em 0, e o GetDuration do próprio Viper faz o mesmo: num teste, ele devolveu 0s para esse valor, sem erro. Passado para SetConnMaxLifetime, o zero não significa "fechar logo", e sim "sem limite": as conexões nunca são recicladas pela idade, que é justamente o que a configuração queria evitar, por exemplo para acompanhar o failover de um banco gerenciado. O lerDuracao do artigo devolve o erro, e a mesma configuração fez a aplicação parar na subida com banco.max_vida inválido: time: unknown unit " minutos" in duration "5 minutos". O cache.ttl ganhou um padrão pelo mesmo motivo: sem ele, uma chave ausente viraria um erro de duração vazia.

Exercício 5

A ConfigValidada usa validate:"required,url" em DatabaseURL. Alguém configura DATABASE_URL=localhost:5432, esquecendo o esquema, e a validação passa. Por quê, e como apertar a regra?

Ver resposta

✓ Resposta: A regra url do validator verifica se o texto é uma URL sintaticamente válida com algum esquema, e localhost:5432 é: o que vem antes do primeiro : é lido como esquema, localhost, e o resto como conteúdo. Num teste com o validator 10.30, localhost:5432 passou, assim como a URL completa do PostgreSQL, e só um texto como não é url foi recusado. O erro só aparece na hora de conectar, com uma mensagem que fala de esquema desconhecido, longe da configuração. Para apertar a regra, o validator oferece startswith=postgres://, que pode ser combinado com url; outra saída é uma validação própria que chama url.Parse e confere u.Scheme e u.Host. Uma validação genérica responde se o formato é aceitável, e não se o valor serve para aquele uso.

Comentários

Mais em Golang

Profiling e otimização de performance em Go
Profiling e otimização de performance em Go

Profiling em Go medido: benchmarks com -benchmem, pprof por HTTP e em testes…

Operadores, expressões e conversão de tipo
Operadores, expressões e conversão de tipo

Operadores em Go: aritméticos, de comparação, lógicos com curto-circuito e bit…

Interfaces: contratos implícitos e polimorfismo
Interfaces: contratos implícitos e polimorfismo

Interfaces em Go: implementação implícita, any, type assertion e type switch…