Gestión de configuración: Flags, Env vars y archivos

La gestión de la configuración en una aplicación profesional no se trata de leer un archivo y ya; se trata de establecer una jerarquía de precedencia clara. El estándar de la industria (Twelve-Factor App) sugiere que la configuración debe residir en el entorno, pero la realidad de sistemas complejos, entornos de desarrollo y pipelines de CI/CD exige un modelo híbrido.

Una estrategia robusta utiliza una estructura de datos tipada como única fuente de verdad, alimentada por cuatro capas en este orden de importancia: flags de línea de comando (lo más específico, para overrides manuales), variables de entorno (para contenedores y orquestadores como Kubernetes), archivos de configuración (para estructuras complejas como JSON o YAML) y, finalmente, valores por defecto (para que la aplicación arranque sin configuración mínima).

Implementar esto de forma dispersa, usando os.Getenv directamente en la lógica de negocio, es un antipatrón que rompe la testabilidad y oculta errores de tipado hasta que la aplicación ya está corriendo. El objetivo es que el sistema falle inmediatamente al arrancar (fail-fast) si una variable crítica no es válida o falta, y que el resto del código solo vea un objeto Config con valores ya validados y tipados.

package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"os"
	"strconv"
)

// Config es la única fuente de verdad para la aplicación.
// Al estar tipada, evitamos errores de "stringly-typed" en el resto del código.
type Config struct {
	Port    int    `json:"port"`
	DBAddr  string `json:"db_addr"`
	Debug   bool   `json:"debug"`
	Timeout int    `json:"timeout_seconds"`
}

// LoadConfig orquestra la jerarquía de configuración.
// Precedencia: Flags > Env Vars > Config File > Defaults.
func LoadConfig() (*Config, error) {
	// 1. Definir valores por defecto
	cfg := &Config{
		Port:    8080,
		DBAddr:  "localhost:5432",
		Debug:   false,
		Timeout: 30,
	}

	// Para que los flags tengan la máxima precedencia, 
	// primero debemos procesar los archivos y env vars 
	// y usarlos como "defaults" para los flags.
	
	// 2. Cargar desde un archivo (si se proporciona la ruta vía flag)
	// Usamos una variable temporal para capturar el path del archivo antes de parsear flags
	var configPath string
	// Definimos un flag temporal para no interferir con el flujo principal de parsing
	flag.StringVar(&configPath, "config", "", "Ruta al archivo de configuración JSON")
	
	// Parseamos primero para obtener la ruta del archivo si existe
	// Nota: En una CLI real, podrías requerir un orden más complejo, 
	// pero para servicios esto es suficiente.
	flag.Parse()

	if configPath != "" {
		if err := loadFile(configPath, cfg); err != nil {
			return nil, fmt.Errorf("error cargando archivo de configuración: %w", err)
		}
	}

	// 3. Sobrescribir con Variables de Entorno
	if val := os.Getenv("APP_PORT"); val != "" {
		if p, err := strconv.Atoi(val); err == nil {
			cfg.Port = p
		}
	}
	if val := os.Getenv("DB_ADDR"); val != "" {
		cfg.DBAddr = val
	}
	if val := os.Getenv("APP_DEBUG"); val != "" {
		cfg.Debug, _ = strconv.ParseBool(val)
	}

	// 4. Sobrescribir con Flags (Precedencia máxima)
	// Redefinimos los flags usando los valores actuales de cfg como base
	// para que flag.Parse() solo cambie lo que el usuario realmente escribió.
	flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
	flag.IntVar(&cfg.Port, "port", cfg.Port, "Puerto de escucha")
	flag.StringVar(&cfg.DBAddr, "db", cfg.DBAddr, "Dirección de la base de datos")
	flag.BoolVar(&cfg.Debug, "debug", cfg.Debug, "Modo debug activado")
	
	flag.Parse()

	return cfg, nil
}

func loadFile(path string, cfg *Config) error {
	data, err := os.ReadFile(path)
	if err != nil {
		return err
	}
	// Unmarshal sobre la estructura existente aplica el "override" 
	// sobre los valores por defecto.
	return json.Unmarshal(data, cfg)
}

func main() {
	// Simulamos un entorno de ejecución donde:
	// 1. El archivo tiene un puerto 9000
	// 2. La Env Var tiene un puerto 7000 (debe ganar al archivo)
	// 3. El Flag tiene un puerto 5000 (debe ganar a todo)
	
	// Simulamos la existencia de un archivo de config
	dummyConfig := `{"port": 9000, "db_addr": "prod-db:5432"}`
	os.WriteFile("config.json", []byte(dummyConfig), 0644)
	defer os.Remove("config.json")

	// Simulamos variables de entorno
	os.Setenv("APP_PORT", "7000")
	os.Setenv("APP_DEBUG", "true")

	// Simulamos ejecución de la aplicación con flags
	// En un entorno real, os.Args vendría del shell
	os.Args = []string{"cmd", "-config", "config.json", "-port", "5000"}

	cfg, err := LoadConfig()
	if err != nil {
		fmt.Fprintf(os.Stderr, "Error crítico: %v\n", err)
		os.Exit(1)
	}

	fmt.Printf("Configuración final cargada:\n")
	fmt.Printf("- Puerto: %d (Esperado: 5000)\n", cfg.Port)
	fmt.Printf("- DB: %s (Esperado: prod-db:5432)\n", cfg.DBAddr)
	fmt.Printf("- Debug: %v (Esperado: true)\n", cfg.Debug)
}

Desglose de la implementación

El flujo de control en LoadConfig es lo que garantiza la jerarquía.

  1. Inicialización: Empezamos con una instancia de Config con valores por defecto. Esto asegura que la aplicación siempre tenga un estado válido.
  2. Carga de archivos: Usamos json.Unmarshal directamente sobre la instancia cfg. Esto es un truco eficiente: los campos presentes en el JSON sobrescriben los valores por defecto, pero si un campo falta en el JSON, el valor por defecto se mantiene intacto.
  3. Variables de Entorno: Iteramos sobre las variables clave. Fíjate en el uso de strconv.Atoi y strconv.ParseBool. Nunca asumas que el entorno te dará el tipo correcto; el parsing debe ocurrir en el arranque para detectar errores de formato inmediatamente.
  4. El truco de los Flags: Para que flag.Parse() respete la jerarquía, no podemos simplemente definir los flags con sus valores por defecto habituales. Primero cargamos todo lo anterior en el struct cfg y luego inicializamos flag.IntVar(&cfg.Port, "port", cfg.Port, ...) usando cfg.Port (que ya tiene el valor del archivo o de la variable de entorno) como el valor por defecto para el flag. Así, si el usuario no pasa el flag, se mantiene el valor previo; si lo pasa, el flag gana.

El error frecuente

El error más común en aplicaciones Go que escalan es el uso de os.Getenv disperso por toda la lógica de negocio.

// ANTIPATRÓN: Configuración dispersa
func ConnectDB() {
    // Esto es un desastre:
    // 1. No sabes qué variables necesita la app sin leer todo el código.
    // 2. No puedes testear esto fácilmente sin ensuciar el entorno global.
    // 3. Si APP_PORT no es un número, el error ocurre en medio de una operación.
    port := os.Getenv("PORT") 
    db := os.Getenv("DB_URL")
    // ...
}

Esto crea dependencias ocultas. Tu función ConnectDB ya no es pura; depende de un estado global externo (os.Environ). Además, os.Setenv no es seguro para hilos (thread-safe), lo que significa que tus tests paralelos podrían fallar aleatoriamente si intentan cambiar la misma variable de entorno. Al centralizar todo en un struct Config inyectado en los constructores de tus servicios, haces que el código sea predecible, testeable y robusto.

207

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio