Manejo de configuraciones

Inevitablemente cuando estés trabajando en un proyecto web (y quizá de otras áreas) necesitarás manejar configuración. La idea de la configuración es poder modificar partes del comportamiento de la aplicación sin modificar el código, simplemente ajustando algunos parámetros.

Algunos casos clásicos para valores de configuración son conectarte a servicios externos como bases de datos o para establecer el método y nivel de logging. Muchos de estos casos implican guardar secretos que deben protegerse como son las credenciales de los servicios y los tokens.

Entre las formas de hacer esto están usar lenguajes de marcado como XML (quiérete, no te hagas esto), o lenguajes más sencillos como JSON o YAML. ¿Cuál de estas podría ser la mejor opción? Aquí les va mi propuesta.

¿Qué tiene de malo XML/JSON/YAML/TOML/LOQUESEAML?

Principalmente el problema es que son otro archivo que manejar. Hay que añadirlos al .gitignore pues tendrán información sensible y su uso retrasa un paso el poder iniciar un proyecto recién clonado, ya que vas a forzar a otras programadoras a crear un archivo de estos pra poder iniciar el proyecto. ¿Qué pasa si quieres saber cuáles son todos los posibles parámetros configurables? Quizá tengas que incluir con tu proyecto un config.yaml.sample donde vengan todos los valores posibles y correr el riesgo de que con el tiempo quede desactualizado.

También hay un riesgo por aquí de crear archivos demasiado anidados (ya sabes, objetos dentro de listas dentro de objetos) que sin importar cuán limpia presuma ser la sintaxis van a ser difíciles de leer. Y finalmente está el problema de la sintaxis misma. XML es muy expresivo, quizá demasiado. JSON es simple pero la verdad un poco lleno de símbolos y la simpleza de YAML (advertencia, opinión personal) lo hace un poco oscuro respecto a qué significa una cosa y otra.

¿Entonces qué propones?

Algo que vengo haciendo ya desde hace un tiempo es manejar un archivo de configuración en el mismo lenguaje de programación que utiliza el proyecto. Este archivo va en el repositorio y obtiene los valores sensibles de variables de entorno. Los nombres de las variables son todos en mayúsculas y se exportan para ser importadas en el resto del código. En ningún otro lado del código se leen valores de variables de entorno, esto para evitar tener que duplicar valores por defecto y para tener una sola fuente de información.

Otra ventaja de manejarlo así es que es más fácil que linters detecten si una configuración está en desuso (variable en desuso) o algún error de escritura en su nombre (en el caso de lenguajes dinámicos donde nos enteraríamos hasta el tiempo de ejecución).

A continuación ejemplos de cómo lo hago en tres lenguajes de programación.

Python

Python siendo un lenguaje muy dinámico hace esto muy simple. Cualquier archivo es un módulo y las variables que existan dentro se pueden importar desde afuera. El módulo os contiene las utilerías necesarias para leer las variables de entorno y el lenguaje provee el resto para que todo funcione de maravilla.

Generalmente pongo este archivo en la raíz del proyecto, excepto en django donde ya tiene una ubicación particular. En django específicamente no convertiría todas las configuraciones en variables de entorno sino las que vaya necesitando.

En este código el caso especial es el segundo ejemplo, donde accedo directamente a os.environ para obtener una variable por su nombre. Esto lo haría así cuando quiera obligar a cierta variable a existir. Cosas críticas como un token pueden ser buenos candidatos, esencialmente variables sin las cuales la aplicación no pueda funcionar y para las cuales no exista un buen valor por defecto. Si la variable no existe sucederá un error de tipo KeyError y podrás en el primer arranque corregir el problema.

# /settings.py
import os

DB_NAME = os.getenv('DB_NAME', 'miapp')  # Con un valor por defecto

TOKEN = os.environ['TOKEN']  # Sin esta variable el servicio no inicia

PORT = int(os.getenv('PORT', 5000))  # Variable con un tipo de dato diferente de str
# /main.py
from settings import DB_NAME, TOKEN, PORT

print(TOKEN)

Javascript

Este también es un lenguaje dinámico (quizá demasiado) e igual no es tan difícil manejar este tipo de situaciones. Quizá la diferencia más importante es el cómo dar valores por defecto pero la estrategia no es muy diferente que en python. Lo que sí cambia es el cómo forzar a un valor a existir porque no me parece buena idea dejar un valor como undefined navegando por el código, no te dispares en el pie.

Es de notarse que estoy usando sintaxis de commonjs para los módulos. Lo haría así incluso aunque mi proyecto tenga la última sintaxis de Ecma con babeljs porque a veces querrás cargar una configuración muy pronto, antes incluso de que babel pueda interferir (me ha pasado) entonces es mejor usar una sintaxis soportada desde el principio por nodejs.

Otra cosa interesante es que esto funciona también para proyectos del frontén. Durante la compilación todas las llamadas a process.env se convierten en sus valores y así todo funciona de forma transparente.

// /settings.js

// Valor por defecto
module.exports.DB_NAME = process.env.DB_NAME || 'miapp';

// Fueza este valor a existir
module.exports.TOKEN = process.env.TOKEN || console.error('Please set TOKEN') || process.exit(1);

// Tipo de dato distinto de string
module.exports.PORT = parseInt(process.env.PORT || 5000);
// /main.js
const { DB_NAME, TOKEN, PORT } = requre('./settings');

console.log(TOKEN);

Rust

¿Creyeron que me lo iba a saltar? Pues no. Rust no es un lenguaje dinámico. De hecho es quizá el lenguaje más estático que he visto ¡todos los tipos de dato tienen que ser correctos en tiempo de compilación! Y uno se preguntaría ¿cómo le vas a hacer aquí? Bueno, yo me lo pregunté ayer y ayer mismo lo resolví.

crates.io está lleno de paquetes increíbles que hacen maravillas, y uno de ellos es lazy_static. Este paquete ofrece una macro que permite crear variables estáticas cuyo valor se define en tiempo de ejecución, y es lo que he usado para seguir mi modelo de configuración.

Este es el único caso que acabo de inventar, porque acabo de necesitar, y por ende apenas lo puse en producción en el bot de Telegram eqxbot. Rust es un lenguaje de bajo nivel que tiene más aplicaciones en otras áreas como sistemas operativos, bibliotecas, programas de línea de comandos y embebidos. En esas áreas las configuraciones quizá más bien provengan de argumentos de línea de comandos por ejemplo.

Quizá pasado el tiempo se me ocurra una mejor manera de manejar las configuraciones de mis proyectos web escritos en Rust. Me pasó por la cabeza usar argumentos de línea de comando, pero lo descarté para evitar tener una línea de comando larguísima y difícil de manejar. Además no me gustaría que los valores sensibles se guardaran en los logs de systemd.

Algo bonito de lenguajes de tipado estático es que el tipo de dato de cada configuración va a estar bien definido desde el principio.

// /src/settings.rs

use std::env;
use std::path::PathBuf;

lazy_static! {
    pub static ref DB_NAME: String = env::var("DB_NAME").unwrap_or("miapp".into());

    pub static ref TOKEN: String = env::var("TOKEN").expect("Please set TOKEN environment variable");

    pub static ref PORT: u16 = env::var("PORT").unwrap_or("8000".into()).parse().expect("Could not parse port");
}
// /src/lib.rs

#[macro_use] extern crate lazy_static;

pub mod settings;
// /src/main.rs

use miapp::settings::{ DB_NAME, TOKEN, PORT };

fn main() {
   println!("{}", DB_NAME);
   println!("{}", TOKEN);
   println!("{}", PORT);
}

Ruby

En este lenguaje soy nuevo, así que estas son solo ideas al aire, este ejemplo no lo tengo en producción. Sí manejo un proyecto ruby en producción pero ya tenían un esquema de configuración, parecido al mío pero donde sí usan un archivo config.yaml de referencia en vez de un archivo ruby. Un esquema sobrecomplicado si me preguntan.

Hay una cosa que realmente no me gusta de este lenguaje: las sentencias require y require_relative son oscuras (como las de php) pues no permiten escoger qué se importa y qué no. Entonces es difícil saber realmente de dónde viene cierto valor, clase o función. Y esto hace difícil la depuración. Dicho esto, como también es un lenguaje dinámico es fácil proceder:

# settings.rb

DB_NAME = ENV['DB_NAME'] || 'miapp'

TOKEN = ENV['TOKEN'] ? ENV['TOKEN']: (raise 'Missing token')

PORT = Integer(ENV['PORT'] || 5000)
# main.rb

require_relative 'settings'

puts DB_NAME
puts TOKEN
puts PORT

Cualquier otro lenguaje

Estas ideas son más o menos fáciles de seguir en principio en cualquier otro lenguaje. Lo más importante para mi es evitar tener un archivo config.algo y un config.algo.sample rondando por el proyecto, y que la misma sintaxis del lenguaje acompañe a la configuración. Y que la configuración se pueda leer de variables de entorno.

Cargando las variables de entorno

Para el desarrollo cargar las variables de entorno es fácil, ¡ni siquiera necesitas dotenv! Solo necesitas un archivo .env con los valores que uses para desarrollo como:

# .env
export DB_NAME=miapp
export TOKEN=supersecreto
export PORT=8000

No olvides añadirlo al .gitignore y luego para cargarlo puedes hacer esto:

$ source .env
$ python main.py # o node app.js o cargo run según el lenguaje

Personalmente no me gusta añadir dotenv en mis proyectos, trato siempre de mantener las dependencias al mínimo para reducir los posibles puntos de fallo de la aplicación y la carga de actualizar versiones. Y como puedes ver su funcionalidad puede ser fácilmente reemplazable con una simple línea de comandos.

Cargando variables de entorno en producción

Y claro, no se podía esperar a que en producción las cosas funcionaran igual. Yo personalmente dejo a systemd a cargo de mis servicios, y para configurarlos sigo este esquema:

Por seguridad el servicio corre bajo su propio nombre de usuario UNIX, digamos miapp. Entonces hay una carpeta, quizá /opt/miapp y todos los archivos dentro tiene usuario y dueña miapp. El archivo de configuración puede seguir siendo /opt/miapp/.env aunque escuché una propuesta por ahí de que sea /etc/miapp/miapp.conf, que sigue más la filosofía unix. En cualquier caso ese archivo tendría permisos 640 o lo que es lo mismo: Dueña puede leer y escribir, grupo puede leer, otros no pueden hacer nada.

Finalmente el archivo /etc/systemd/system/miapp.service tendría esto:

[Service]
EnvironmentFile=/opt/miapp/.env

¿Qué opinas?

¿Te parece una buena idea manejar así las variables? ¡O quizá tienes una forma mil veces mejor! Mándame tus comentarios, ya sabes dónde encontrarme ;)

Actualización 2020-11-14

Si bien esta forma de cargar la configuración es bastante cómoda, también pone en el juego la tentación de cargar los valores configurados desde cualquier parte, importando las configuraciones como una variable global. Las variables globales son una terrible idea y de hacerlo así corres el riesgo de código más difícil de probar o mantener en orden. En esta otra entrada escribo al respecto.