Propiedades en Spring Boot y variables de entorno

0
52623

En este tutorial vamos a ver los distintos mecanismos que tenemos para leer propiedades con Spring Boot y usaremos la configuración por variables de entorno para hablar de algunas características importantes al configurar nuestras aplicaciones.

Índice de contenidos.

¿Por qué vamos a ver esto?

La configuración mediante propiedades de una aplicación no es nada nuevo. Debe de ser de las primeras cosas que aprendemos al empezar con Spring o Spring Boot. Sin embargo, muchas veces nos quedamos solo en las puertas. En la mayoría de los casos, inyectamos la configuración valor por valor desde un fichero xml o yaml. Muchas veces ni siquiera diferenciando según el entorno. Pero las posibilidades que tenemos son mucho más amplias.

En este tutorial vamos a dar un repaso a cómo podemos leer la configuración desde Spring Boot y cómo podemos darle valores a esta configuración. Entraremos en detalle en el caso particular de las variables de entorno y las usaremos para entender mejor cómo funcionan todos esos valores que ponemos.

Léelo hasta el final, seguro que encuentras cosas interesantes. Así que sin más dilación, vamos a entrar ya en materia.

Dos anotaciones, un mismo fin

Cuando trabajamos con Spring Boot tenemos a nuestra disposición dos anotaciones con las que inyectar propiedades en nuestras clases. La primera de ella es @Value, seguramente la más conocida y usada porque también está disponible desde Spring. La segunda es @ConfigurationProperties, esta ya exclusiva de Spring Boot, y nos va a dar mucha más potencia para leer estructuras de propiedades.

Pero es mejor si las vemos con un par de ejemplos. Vamos a suponer que queremos leer un fichero application.yaml con las siguientes propiedades:

my:
    property:
        s: "Hola mundo"
        x:
            i: 5
            b: true
        numbers: 1, 2, 3

Carga individual con @Value

La primera forma que tendríamos de hacerlo sería con la anotación @Value. Gracias a ella podemos inyectar valores de los tipos básicos de Java, así como arrays, listas y conjuntos de estos, simplemente usando su clave. Además podemos usar la propia anotación para darle valores por defecto. De esta forma, inyectar las propiedades de nuestro fichero sería algo tan simple como esto:

@Value("${my.property.s:Valor por defecto}")
private String innerS;

@Value("${my.property.x.i}")
private int innerI;

@Value("${my.property.x.b}")
private boolean innerB;

// Las colecciones las podemos cargar de tres formas distintas:
@Value("${my.property.numbers}")  
private List<Integer> numbersList;

@Value("${my.property.numbers}")  
private Set<Integer> numbersSet;

@Value("${my.property.numbers}")  
private Integer[] numbersArray;

Con esto tenemos más que suficiente para la mayoría de los casos. Solamente tenemos una limitación real. @Value no puede cargar mapas, o al menos no como los expresaríamos normalmente en un fichero yaml. No voy a entrar en detalles aquí, pero podéis encontrar cómo hacerlo mediante SpEL en este hilo.

Con esto terminamos por ahora con esta anotación. Solo un pequeño disclaimer antes de continuar: en un entorno real recomendaría, siempre en términos generales, anotar los parámetros del constructor en lugar de los atributos. Así será más testeable y además podemos hacer nuestra clase inmutable poniendo sus atributos finales. Este ejemplo está así porque creo que es más fácil de leer en el contexto de un tutorial.

Carga conjunta con @ConfigurationProperties

Como hemos visto, @Value es un buen método si solo queremos usar un par de propiedades sueltas o que no tienen relación entre sí. Pero cuando queremos poder agrupar propiedades bajo estructuras enteras es mejor recurrir a @ConfigurationProperties. Con ella podemos preparar un bean que contenga estas mismas propiedades de una manera muy sencilla:

@Component
@ConfigurationProperties(prefix = "my.property")
public class MyPropertiesClass{
    private String s;
    private MyInnerClass x;
    private List<Integer> numbers;

    public MyPropertiesClass(){
        // Dentro del constructor podemos especificar los valores por defecto de cada atributo si no existe su propiedad.
        s = "Valor por defecto";
        x = null;
        numbers = Collections.emptyList();
    }

    private class MyInnerClass {
        private int i;
        private boolean b;

        public int getI(){ return i; }
        public void setI(final int i){ this.i = i; }

        public boolean isB(){ return b;}
        public void setB(final boolean b){ this.b = b; }
    }

    public String getS(){ return s; }
    public void setS(final String s){ this.s = s; }

    public void setX(final MyInnerClass x){ this.x = x; }
    public MyInnerClass getX(){ return x; }

    public List getNumbers() { return numbers; }  
    public void setNumbers(List numbers) { this.numbers = numbers; }
}

Esta clase ya podríamos inyectarla como cualquier otro bean en cualquier sitio donde necesitemos acceder a las propiedades. Puede parecer mucho código, pero lo único que hemos hecho ha sido anotar nuestro componente indicando el prefijo bajo el que están todas las propiedades y después darle a cada atributo el nombre de la propiedad concreta que representa. La mayor parte del código son los getters y setters necesarios para que funcione y con una librería como Lombok se puede reducir enormemente.

Spring Boot se encarga automáticamente de gestionar las propiedades y preparar el componente para su inyección donde sea necesario. Además ofrece la anotación @EnableConfigurationProperties para permitirnos inyectar clases que no hayan sido anotadas con @Component. Nuestro objetivo aquí no es entrar en tanto nivel, pero si queréis más información podéis encontrarla en la propia documentación de Spring Boot.

Hay dos desventajas principales de esta anotación. La primera es que no entiende SpEL, aunque es un caso de uso bastante acotado. Y la segunda es que, al necesitar de los getters y setters para funcionar, obliga a que la clase sea mutable. Algo que solo se puede mitigar controlando bien el acceso.

Por otro lado, sí que es capaz de leer mapas directamente desde un fichero yaml. Esto lo veremos en detalle un poco más adelante.

¿Con cuál nos quedamos?

Las dos anotaciones que hemos visto son igualmente útiles. En muchos casos habremos aprendido a configurar aplicaciones con @Value y seguramente nos sintamos más cómodos con esta. Sin embargo, debemos entender las ventajas que nos ofrece tratar nuestra propiedades como un conjunto para valorarlo como alternativa.

Al cargar las propiedades una sola vez y en un único sitio, estamos aplicando el principio DRY. Cualquier cambio que tengamos que hacer implicará tocar en un solo sitio en lugar de por toda la aplicación. Por ejemplo cambiar el nombre de una propiedad, el prefijo de todo el grupo o los valores por defecto. Por esto cobra especial importancia saber dividir correctamente las propiedades y que cada grupo siga el principio de responsabilidad única lo más estrictamente posible.

Eso sí, hay que decir que este mismo efecto que tenemos con @ConfigurationProperties lo podemos lograr con @Value al crear clases cuya única función sea gestionar las propiedades.

Cada anotación tiene sus propias características. Antes hemos ido mencionado algunas ventajas y desventajas, pero podemos ver más en la propia documentación de Spring. Como todo, dependerá del caso concreto que decidamos usar una u otra.

Distintas fuentes para los datos

Hasta aquí hemos visto cómo usar las propiedades dentro de nuestra aplicación, pero aún no hemos hablado de dónde podemos definir los valores de dichas propiedades.

Por lo general estamos acostumbrados a crear un fichero application.yaml para almacenar todas las propiedades. También es bastante común ir un paso más allá, usar este fichero solo para los valores por defecto y definir en otro los valores para un perfil de Spring concreto. Por defecto, Spring buscará las propiedades en un fichero application-{profile}.yaml.

Esta es la forma más común de dar valores a nuestras variables y, sin embargo, no es la única. Los ficheros que acabamos de comentar son solo algunas de las múltiples fuentes en la que Spring va a buscar propiedades para sus aplicaciones. Todas ellas están ordenadas de forma que cada propiedad se busca en un nivel antes de pasar al siguiente, exactamente lo que hemos visto que ocurría.

No vamos a entrar en detalle de cada una de estas formas, pero podéis entrar la lista y su orden concreto en la propia documentación oficial. Nosotros nos vamos a centrarnos en el uso de variables de entorno para explorar más a fondo cómo entiende Spring las propiedades.

Configuración por variables de entorno

¿Por qué nos interesan las variables de entorno para esto?

Esto será lo primero que os estaréis preguntando. ¿Por qué esta fuente y no otra? Usar variables de entorno tiene algunas características fundamentales frente al uso de ficheros, lo que les da unos casos de uso bastante particulares.

  1. Tienen un orden superior de preferencia, por lo que Spring buscará en ellas antes que en los ficheros. Es decir, podemos sobrescribir la configuración que se le ha dado sin tener que modificar el contenido del proyecto. Gracias a esto podemos, por ejemplo, testear nuestra aplicación en local con unas propiedades concretas sin riesgos de subir cambios sin querer que afecten a todo el mundo.
  2. Su volatilidad, al no tener valores fijados que se puedan consultar desde el código fuente, ayuda a la confidencialidad. Esto es muy útil, por ejemplo, para configurar credenciales de acceso y cualquier otra información sensible. Solamente quien vaya a desplegar sabrá los valores concretos, o podrías incluso delegarlo en algún almacén y nadie necesitará saber los valores concretos.
  3. Por último, el hecho de que trabajar con variables de entorno sea muy fácil y se puedan compartir entre las distintas herramientas y aplicaciones favorece la interacción en un ecosistema. Es relativamente sencillo hacer una herramienta que recupere los valores concretos de algún sitio y se los pase a la aplicación antes de iniciarla.

Por supuesto, entre las múltiples fuentes de datos con las que contamos las variables de entorno no son las únicas con estas características. Pero al ser una herramienta ampliamente usada en la gran mayoría de entornos software creo que es de mayor importancia que el resto, quizá a la par que las propiedades del sistema que se pueden mandar con Maven.

Empezamos por lo básico

Lo primero que debemos tener en cuenta son los nombres de nuestras variables. Las claves con las que estamos acostumbrados a trabajar son conjuntos de expresiones separados por puntos. Además esas expresiones pueden estar en camel case o contener guiones. Sin embargo, las variables de entorno no admiten ni puntos ni guiones. ¿Cómo podemos hacerlo entonces?

Spring Boot ya tiene un mecanismo para esto llamado Relaxed Binding (que podríamos traducir como vinculación relajada). A grandes rasgos, permite reconocer propiedades aunque no sus nombres no coincidan al 100%. Uno de sus casos de uso es precisamente el que nos ocupa a nosotros. Solo tenemos que seguir tres sencillos pasos para obtener el nombre de la variable de entorno a partir de la propiedad de Spring:

  1. Reemplazamos los puntos por guiones bajos.
  2. Eliminamos los guiones medios.
  3. Convertimos todo a mayúsculas.

Siguiendo estas normas, las propiedades que teníamos antes quedarían así:

MY_PROPERTY_S
MY_PROPERTY_X_I
MY_PROPERTY_X_B
MY_PROPERTY_NUMBERS

Solo quedaría darles valores, lo cual dependerá en gran medida del sistema operativo que estemos usando. Los ejemplos a partir de ahora los haré con las instrucciones necesarias para un entorno UNIX, ya que estoy trabajando con un MacOS.

En mi caso, si quisiera darles valores a las propiedades anteriores solo necesitaría que ejecutar:

export MY_PROPERTY_S="Hola"
export MY_PROPERTY_X_I=93
export MY_PROPERTY_X_B=true
export MY_PROPERTY_NUMBERS="5, 30, 10"

En caso de no encontrar la propiedad aquí, Spring buscará en las fuentes con menor prioridad y acabará llegando a nuestro application.yaml.

Con esto sabemos cómo inyectar valores tanto de tipos primitivos como de objetos. Ya hemos cubierto la configuración de gran parte de las aplicaciones. Sin embargo, aún nos queda por aprender cómo tratar algunos casos que podrían darnos algún que otro dolor de cabeza.

Todas las variables de entorno son Strings

Una de las limitaciones fundamentales de las variables de entorno es que no se pueden definir con tipos. Por defecto siempre van a ser cadenas de caracteres y será la aplicación que las lea quien deberá entenderlas como otro tipo de dato.

En nuestro caso, es la declaración de la variable en Java quien le da forma. En la inicialización Spring leerá el String y lo intentará parsear para adecuar su valor al tipo declarado. Si por ejemplo se ha declarado como un int el resultado será el mismo que si ejecutásemos Integer.parseInt(valorDeLaPropiedad). Por este mismo motivo, si la declaramos como un Object, incluso aunque tenga un valor numérico, no aplicará ningún parseo y el resultado siempre será un String. Y, por supuesto, en cualquier colección de Object ocurrirá lo mismo con sus elementos.

Esto nos lleva a preguntarnos cómo declaramos una propiedad como null. La respuesta es que no podemos. En las variables de entorno no existe ese concepto, así que lo único que obtendríamos sería un literal con el valor «null». Esto es un String para Java y no se va a poder parsear como querríamos.

Declaración de arrays, listas y conjuntos

Vamos a seguir ahora con las colecciones, o más bien todas las estructuras a las que podemos inyectar una propiedad expresada como my.property.numbers: 4, 5, 6. Es decir, afecta a los arrays, las listas y los conjuntos. Y es que su particularidad está relacionada por cómo se representan en yaml.

En el ejemplo de arriba hemos visto que en una sola línea podemos definir una lista de tipos simples. Pero cuando sus elementos son objetos completos tenemos que hacerlo de otra forma. Digamos que tenemos esta clase:

public class ComplexList{
    private List<InnerClass> list;
    private class InnerClass {
        private int x;
        private int y;
    }
}

La forma de inicializarla en yaml sería:

list:
    -   x: 1
        y: 3
    -   x: 2
        y: 4

Esto mismos traducido a variables de entorno implica que vamos a tener que especificar, para cada posición de la lista, el elemento concreto usando una variable distinta para cada atributo. Si quisiéramos crear la misma lista mediante variables de entorno deberíamos ejecutar:

export LIST_0_X=1
export LIST_0_Y=3

export LIST_1_X=2
export LIST_1_Y=4

Como vemos, la posición del elemento en la lista se especifica con un número justo después del nombre de la colección (empezando en cero, como es lógico). Es importante que declaremos todos los elementos en orden y sin que falte ninguno. Si por ejemplo intentásemos crear el elemento en la posición 3 sin haber antes creado el de la posición 2 solo obtendríamos un error.

Así pues, lo único distinto al tratar con listas de elementos complejos es a la hora de definirlas. El comportamiento, sin embargo, entra dentro de lo esperado. Si falta un atributo en un elemento se le da el valor por defecto y si falta un elemento la lista no se puede inicializar.

Los mapas son la oveja negra

Ya a la hora de inyectarlos, hemos visto que los mapas han sido problemáticos. Sólo con @ConfigurationProperties podemos leer valores de un fichero yaml, y es en este caso en el que nos vamos a centrar ahora. Al fin y al cabo con @Value solo estamos leyendo un String y eso no difiere en nada del uso habitual de las variables de entorno.

Tratar con mapas en las propiedades de Spring es una historia completamente distinta a lo que estamos acostumbrados. Vamos a ver cómo la propia naturaleza del framework cambia el comportamiento que esperaríamos y qué debemos tener en cuenta cuando los usemos.

Antes de nada vamos a recordar cómo declaramos un Map en nuestro application.yaml:

mi:
    mapa:
        clave1: "valor1"
        clave2: "valor2"

Siguiendo las normas que conocemos, la declaración mediante variables de entorno de este mismo mapa sería:

export MI_MAPA_CLAVE1="valor1"
export MI_MAPA_CLAVE2="valor2"

De momento no hemos visto nada excesivamente extraño, ¿no? Quizá os parezca curioso que la forma de definir un mapa sea similar a la de definir un conjunto de propiedades normales. Luego entraremos en esto, pero vamos a seguir un poco más.

¿Cómo haríais para sobrescribir el mapa anterior si lo tenemos declarado en un application.yaml? No parece complicado. Por seguir la temática vamos a hacerlo con variables de entorno, pero lo que vamos a ver es aplicable a todos los formatos de propiedades. ¿Qué creéis que ocurrirá si ponemos estas propiedades?

export MI_MAPA_CLAVE0="Solo en variables de entorno"
export MI_MAPA_CLAVE1="Nuevo valor"

Al contrario de lo que podríamos esperar si pensamos en los ejemplos que hemos visto hasta ahora, no vamos a obtener un mapa con las dos entradas de arriba. El resultado, expresado en yaml, sería este:

mi:
    mapa:
        clave0: "Solo en variables de entorno"
        clave1: "Nuevo valor"
        clave2: "valor2"

¿Qué ha ocurrido? El mapa que ha inyectado Spring ha sido una mezcla de los dos. Primero ha creado un mapa con todas las claves en las variables de entorno y luego ha recorrido nuestro application.yaml y ha añadido todas las claves que no existían.

¿Qué quiere decir esto? Que cuando trabajamos con mapas no podemos sobrescribir por completo otro declarado mediante otra fuente de propiedades, aunque para Spring tenga una prioridad inferior. Lo único que podemos hacer es sobrescribir las entradas.

Este comportamiento también lo tiene recogido Spring en su documentación y, sin duda, difiere de lo que esperaríamos habitualmente. Así que hay que tener mucho cuidado cuando trabajemos con mapas para no tener comportamientos inesperados.

Vamos a llegar al fondo de todo esto

Si únicamente querías saber cómo usar los mapas no hace falta que sigas leyendo. Sin embargo, a mí me gusta entender por qué las cosas funcionan cómo lo hacen. Vamos a ver qué está ocurriendo detrás de este comportamiento. Así será más fácil evitar problemas y quizá podamos sacar partido de sus peculiaridades.

Os habéis fijado antes, ¿verdad? Cada entrada de un mapa se define exactamente igual que una propiedad individual. Es más, podemos incluso cargarlas de forma separada con las anotaciones que ya hemos visto.

¿Pero por qué? Sencillamente porque para Spring son lo mismo. A grandes rasgos, siempre está tratando el conjunto de todas sus propiedades como un mapa de mapas. Cada nivel de indentación (o cada guión bajo o el separador que corresponda al formato de propiedades) implica que estamos en un nuevo mapa, que forma parte del mapa de nivel inmediatamente superior. Pongamos como ejemplo el application.yaml del inicio:

my:
    property:
        s: "Hola mundo"
        numbers: 1, 2, 3
        x:
            i: 5
            b: true

En el primer nivel tendremos el mapa que engloba todas las propiedades y que en este caso tiene una sola entrada cuya clave es «my» y cuyo valor es otro mapa. Este también tiene una sola entrada con clave «property» y cuyo valor es otro mapa más. Este, por fin, ya tiene tres entradas cuyas claves son «s», «numbers» y «x». Los valores de las dos primeras son el String y la colección de enteros. El valor de «x» es un último mapa, cuyo contenido será un entero y un booleano.

¿Y qué pasa cuando trabajamos con distintas fuentes de datos? Vamos al caso más simple de querer sobrescribir una única propiedad. Con una variable de entorno sería algo así:

export MY_PROPERTY_S="NUEVO VALOR"

Si expresamos el resultado como un yaml  sería así:

my:
    property:
        s: "NUEVO VALOR"
        numbers: 1, 2, 3
        x:
            i: 5
            b: true

Lo que estábamos esperando es que, obviamente, solamente esa propiedad fuese sobrescrita y que las que no están definidas las cogiese del nivel inferior. Y eso es justamente lo que tenemos, pero no ocurre por arte de magia.

Hemos usado un mapa (definido en la variable de entorno) para sobrescribir el del fichero yaml. Pero si funcionasen igual que cualquier otra colección de datos nos sería imposible combinar propiedades de distintas fuentes de datos. Solamente tendríamos las de aquella con mayor prioridad. Ante este problema, la solución de Spring es cambiar el comportamiento de sobrescritura de los mapas por un algoritmo que mezcla los mapas. El resultado, como ya hemos visto, es un nuevo mapa con las claves de todos pero donde el valor asociado a cada clave se toma del primer mapa (en orden de preferencia) en el que se encuentra esa entrada.

Teniendo esto en cuenta, y sobre todo ahora que sabemos el comportamiento que se busca, seguro que nos resulta más fácil utilizar mapas en nuestras configuraciones. Simplemente pensad en sus entradas como propiedades independientes.

Ya hemos terminado

Ha sido denso, pero espero que haya resultado interesante. Mi objetivo al hacer este tutorial no es que a partir de ahora te pongas a usar variables de entorno a lo loco para configurar todo, ni mucho menos. Pero sí que sepas que hay mundo más allá de la forma en la que solemos trabajar. En particular, las variables de entorno son una buena forma de usar otras herramientas para configurar nuestras aplicaciones. Ahora todo lo que queda es explorar las nuevas opciones que tenemos.

En mi Github puedes encontrar un pequeño proyecto para ilustrar los ejemplos que hemos comentado. Es una prueba de concepto absurdamente simple que he usado para elaborar este tutorial. Yo la encuentro útil para abstraerme y enfocarme en lo que realmente quiero probar sin que lo demás me entorpezca. Quizá también sea de algún uso aquí.

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad