Las variables de entorno son la forma habitual de configurar un programa que corre en un contenedor o en un servidor: no hay archivo que montar ni secretos en el repositorio. Go las lee con dos funciones, y la diferencia entre ellas es más importante de lo que parece.
También te puede interesar
Getenv o LookupEnv
os.Setenv("VACIA", "")
fmt.Printf("Getenv(VACIA) = %q\n", os.Getenv("VACIA"))
fmt.Printf("Getenv(NO_EXISTE) = %q\n", os.Getenv("NO_EXISTE"))
v, hay := os.LookupEnv("VACIA")
fmt.Printf("LookupEnv(VACIA) = %q, %v\n", v, hay)
v, hay = os.LookupEnv("NO_EXISTE")
fmt.Printf("LookupEnv(NO_EXISTE) = %q, %v\n", v, hay)
salidaGetenv(VACIA) = "" Getenv(NO_EXISTE) = "" LookupEnv(VACIA) = "", true LookupEnv(NO_EXISTE) = "", false
Ahí está todo. Getenv devuelve la cadena vacía en los dos casos, así que no puedes saber si alguien puso APP_DEPURAR= a propósito o si simplemente no está. LookupEnv te lo dice con el segundo valor.
¿Importa? Importa cuando el valor por omisión no es el vacío. Si APP_HOST por omisión es localhost y alguien lo define vacío queriendo decir «escucha en todas las interfaces», con Getenv le vas a poner localhost igualmente.
Un ayudante por cada tipo
Todo lo que llega del entorno es texto. Estas cuatro funciones son prácticamente todo lo que se necesita:
func texto(clave, porOmision string) string {
if v, hay := os.LookupEnv(clave); hay {
return v
}
return porOmision
}
func entero(clave string, porOmision int) (int, error) {
v, hay := os.LookupEnv(clave)
if !hay || v == "" {
return porOmision, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("%s: %q no es un número entero", clave, v)
}
return n, nil
}
func booleano(clave string, porOmision bool) (bool, error) {
v, hay := os.LookupEnv(clave)
if !hay || v == "" {
return porOmision, nil
}
b, err := strconv.ParseBool(v)
if err != nil {
return false, fmt.Errorf("%s: %q no es un booleano", clave, v)
}
return b, nil
}
func duracion(clave string, porOmision time.Duration) (time.Duration, error) {
v, hay := os.LookupEnv(clave)
if !hay || v == "" {
return porOmision, nil
}
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf("%s: %q no es una duración", clave, v)
}
return d, nil
}
strconv.ParseBool acepta más de lo que crees: 1, t, T, TRUE, true, True y sus equivalentes para el falso. Lo que no acepta es yes, on ni sí. Y time.ParseDuration entiende 300ms, 1.5h, 2h45m: mucho más legible que un número suelto de segundos del que nadie recuerda la unidad.La configuración entera, de una vez
La idea es cargarla al arrancar, fallar ahí mismo si algo está mal, y no volver a tocar el entorno en todo el programa:
type Config struct {
Puerto int
Host string
Depurar bool
Espera time.Duration
Clave string
}
func cargar() (Config, error) {
var c Config
var err error
c.Host = texto("APP_HOST", "localhost")
if c.Puerto, err = entero("APP_PUERTO", 8080); err != nil {
return c, err
}
if c.Depurar, err = booleano("APP_DEPURAR", false); err != nil {
return c, err
}
if c.Espera, err = duracion("APP_ESPERA", 5*time.Second); err != nil {
return c, err
}
c.Clave = os.Getenv("APP_CLAVE")
if c.Clave == "" {
return c, fmt.Errorf("APP_CLAVE es obligatoria y no está definida")
}
return c, nil
}
APP_PUERTO=9000 APP_DEPURAR=true APP_ESPERA=1500ms APP_CLAVE=s3cr3t go run .
salida{Puerto:9000 Host:localhost Depurar:true Espera:1.5s Clave:s3cr3t}
Sin nada definido salvo la clave, salen los valores por omisión:
salida{Puerto:8080 Host:localhost Depurar:false Espera:5s Clave:x}
Y cuando algo está mal, el programa no arranca a medias:
salidaerror de configuración: APP_CLAVE es obligatoria y no está definida exit status 1
salidaerror de configuración: APP_PUERTO: "ocho" no es un número entero exit status 1
Ver qué hay definido
for _, e := range os.Environ() {
clave, valor, _ := strings.Cut(e, "=")
if strings.HasPrefix(clave, "APP_") {
if strings.Contains(clave, "CLAVE") {
valor = "********"
}
fmt.Printf(" %-12s = %s\n", clave, valor)
}
}
salida APP_DEPURAR = true APP_CLAVE = ******** APP_PUERTO = 9000 APP_ESPERA = 1500ms
os.Environ() devuelve un slice de cadenas con la forma CLAVE=valor, y strings.Cut las parte por el primer =, que es justo lo que hace falta porque el valor puede contener más.
Que los secretos no se escapen al imprimir
Arriba enmascaré la clave a mano, pero es fácil olvidarse en el siguiente fmt.Printf que alguien añada. La forma de que no se olvide nunca es darle un tipo propio al secreto:
type Secreto string
func (s Secreto) String() string {
if s == "" {
return "(vacío)"
}
return "********"
}
type Config struct {
Host string
Clave Secreto
}
c := Config{Host: "localhost", Clave: "s3cr3t"}
fmt.Printf("%+v\n", c)
fmt.Println("y el valor real sigue ahí:", len(string(c.Clave)), "caracteres")
salida{Host:localhost Clave:********}
y el valor real sigue ahí: 6 caracteres
Ahora la contraseña no aparece ni en los registros, ni en un volcado de la configuración, ni en un mensaje de error, porque todo eso pasa por fmt. Para usarla de verdad hay que convertirla explícitamente con string(c.Clave), que es un gesto lo bastante visible como para que se note en una revisión de código.
En las pruebas
func TestConfig(t *testing.T) {
t.Setenv("APP_PUERTO", "9999") // se deshace solo al acabar la prueba
c, err := cargar()
...
}
t.Setenv es mejor que os.Setenv en una prueba: restaura el valor anterior al terminar. A cambio, el paquete testing no deja usarlo en pruebas marcadas con t.Parallel(), porque el entorno lo comparte todo el proceso.
Lo esencial
| Para | Usa |
|---|---|
| Leer con valor por omisión | os.LookupEnv, no os.Getenv |
| Convertir a número | strconv.Atoi |
| Convertir a booleano | strconv.ParseBool |
| Convertir a tiempo | time.ParseDuration |
| Recorrer todo el entorno | os.Environ + strings.Cut |
| Que no se filtre un secreto | Un tipo propio con String() |
| Cambiarlo en una prueba | t.Setenv |
Todo el código se compiló y ejecutó con Go 1.25 antes de publicarla; las salidas están copiadas de esa ejecución. Documentación oficial: os.LookupEnv y paquete strconv.