No sé si un blog en español va a funcionar en esta plataforma...pero, por qué no? En diciembre del año pasado dejé mi puesto como arquitecta de soluciones con MongoDB, para irme a Microsoft, a trabajar en experiencia y herramientas de desarrollo, para desarrolladores JavaScript en Azure. Una de las cosas que me apenó bastante, era que me había comprometido a escribir sobre MongoDB en español, y no había encontrado tiempo. Sé que suena como una excusa terrible, pero realmente estoy más ocupada de lo que me gusta admitir.
Sin embargo, no quería dejar de hacer esto. Sobre todo mientras la información está fresca en mi cabeza. ¿Por qué? Bueno, realmente soy una fan del modelo documento y ahora mismo, uso la API de MongoDB con CosmosDB, constantemente. Inicialmente iba a hacer videos, pero llevan una cantidad de tiempo increíble, y era realmente inviable.
Advierto que la información es bastante y seguramente no va a caber toda en un post único. Intentaré no demorar mucho entre post y post, pero no puedo prometer nada.
Empecemos por el modelo.
MongoDB como base de datos, fue concebida realmente para solucionar un problema que sus creadores venían experimentando ellos mismos, y que era obvio era general a toda la industria. Las bases de datos SQL eran cada vez menos compatibles con nuevos casos de uso, eran (y son) difíciles de escalar, necesitan capas intermedias entre la base de datos y la de la aplicación (conocidas como ORM u Object Relational Mapping) y además, son poco idiomáticas. Con esto quiero decir que cuando escribimos aplicaciones, generalmente creamos entidades que se describen como objetos con ciertas propiedades o atributos, métodos, etc. Representar esos objetos y la relación de los mismos con otros objetos, mediante tablas, no es precisamente fácil para aplicaciones complejas.
...las bases de datos SQL eran cada vez menos compatibles con nuevos casos de uso, son difíciles de escalar, y necesitan capas intermedias entre la base de datos y la de la aplicación, conocidas como Object Relational Mapping
Y no sólo eso. Las aplicaciones modernas generalmente hacen peticiones a través de APIs (mayoritariamente REST) que típicamente devuelven los datos en formato JSON. JSON es verdaderamente el estándar abierto de intercambio de información, más utilizado en redes. Así que...¿por qué no crear un tipo de base de datos que utilizara el mismo formato? Pues los creadores se abocaron a ello, y así nació MongoDB (que es una abreviación de la palabra en inglés 'humongous', que quiere decir algo así como 'enorme', porque realmente MongoDB siempre estuvo pensada para alojar grandes volúmenes de datos y para poder escalar de manera simple).
...MongoDB siempre estuvo pensada para poder escalar de manera simple y alojar grandes volúmenes de datos
Pero, como casi todas las soluciones, habían algunos problemas. A pesar de que JSON era idiomático y fácil de entender para todos los desarrolladores, y de que los documentos eran fáciles de crear y modificar, se podían anidar subdocumentos para establecer relaciones, (hablaremos más de relaciones y consistencia cuando hablemos de patrones de diseño), y otras ventajas a nivel flexibilidad, JSON no ofrece demasiadas opciones en lo que se refiere a tipos de datos. Por lo tanto, si bien a nivel humano es fácil de escribir y entender, no es eficiente a la hora de guardar los datos en sí.
JSON es rápido a la hora de leerse y su tamaño es bastante pequeño, pero no ofrece demasiadas opciones en lo que se refiere a tipos de datos. Por lo tanto, si bien a nivel humano es fácil de escribir y entender, no es eficiente a la hora de compilar o guardar los datos en sí
BSON como estándar abierto, para guardar otros tipos de datos
Y así se creó BSON (Binary encoded JavaScript Object Notation), como una manera de codificar JSON, para extender los tipos nativos de JSON: además de tener cadena (string), boleano (boolean), numérico (number), arreglo (array), objeto (object) y null, BSON ofrece tipos binarios, decimales de 128 bits, fecha (date), id de objeto (objectId), etc.
BSON puede compararse con otros Protobuffs, o protocolos de buffer, que se usan para serializar y deserializar datos* estructurados, pero a diferencia de los mismos, no exige un schema, lo que se traduce en una mayor flexibilidad a la hora de guardar datos.
(*) El proceso de serialización es el de codificar un objeto (y el de deserialización, el de decodificarlo), para que se encuentre en un formato más adecuado ya sea para su transmisión a través de una red, lectura por parte de un sistema informático o un humano, o su almacenaje.
La diferencia más significativa es que BSON tiene más tipos de datos, y es más rápido de escribir y compilar en la base de datos, mientras que JSON es más fácil de leer para humanos y computadoras, además de tener un tamaño más pequeño
Podemos ir a la documentación de MongoDB, para obtener una comparativa entre los dos estándares.
Comparativa (o contraste) entre una base de datos SQL o relacional, y una base de datos de modelo de documento
A pesar de las diferencias, una base de datos es...bueno, eso. Es un software que opera sobre un sistema informático, para almacenar datos. Y todas las bases de datos tienen, a pesar de sus diferencias, muchas cosas en común. Por ejemplo, si queremos comparar MongoDB con una base de datos relacional clásica, el mapeo de terminología y conceptos, sería el siguiente:
Modelo Relacional | Base de datos | Tabla | Fila | Columna | Clave | Valor | Vista |
---|---|---|---|---|---|---|---|
Modelo de Documento | Base de datos | Colección | Documento | Campo | Campo | Valor | Vista |
Vamos a ver más equivalencias, a medida que progresemos.
Anatomía de un documento
Ahora que entendemos un poco más sobre datos, vamos a enfocarnos en los documentos en sí. Un documento del modelo documento (valga la redundancia), tiene estás características en su formato JSON.
{
"field1" : "valor (string)",
"field2" : "valor (boolean)",
"field3": [valor(number), valor(number), valor(number)],
"field4": { "valor": "string" } // (object) -> también conocido como subdocumento, si tiene su propio identificador
//etc
}
Donde podemos decir que el nombre del campo se corresponde a una clave o key en una base de datos relacional, y el valor corresponde al valor, que tiene un tipo. Cuando necesitamos convertir un valor a un tipo no soportado por JSON, pero soportado por BSON, generalmente efectuamos un "casting", ya sea a nivel driver (o aplicación) o durante el formateo del documento. Por ejemplo, todos los documentos guardados en una base de datos MongoDB, necesitan un identificador único, del tipo ObjectId. Al deserializar, la base de datos mostrará ese valor como un objeto anidado con esta estructura:
{
"_id": {"$oid":"61bf540215c38f38aff7f352"}
}
Cuando creamos un documento en MongoDB, sin guardar ningún tipo de datos en él, por ejemplo con el comando
db.[collection].insertOne({})
la base de datos le asignará un campo "_id", con un valor aleatorio y único, de manera automática.
Ese comando es parte de las operaciones CRUD de la API de MongoDB. No importa que ahora no entiendas lo que hace ese comando. Hablaremos de las operaciones CRUD, más adelante.
Bases de datos distribuidas y la consistencia eventual
Además de ser diseñada para grandes cantidades de datos, (y quizás por esa misma razón) MongoDB fue diseñada como una base de datos distribuida. Es decir, a diferencia de las bases de datos tabulares, que son difíciles de distribuir de manera optimizada, MongoDB fue creada específicamente con la distribución en mente.
Para entender cómo funciona MongoDB a nivel distribución y consistencia, hay que tener en mente el Teorema de CAP. En resumen, este teorema propone que cualquier almacenamiento de datos distribuido solo puede ofrecer dos de las siguientes tres garantías:
Consistencia - cada petición va a obtener la escritura más reciente o un error.
Disponibilidad - cada petición va a obtener una respuesta y nunca un error, pero sin la garantía de que esta contiene la escritura más reciente
Tolerancia a la partición - El sistema va a continuar operando a pesar de que algunos mensajes entre nodos se pierdan o se descarten. En caso de fallos el sistema debe decidir entre disminuir la disponibilidad (cancelando la operación) o mantener la disponibilidad, sacrificando así la consistencia.
MongoDB es un sistema particionado, con lo cual continúa operando, y por defecto da más prioridad a la consistencia que a la disponibilidad, ya que todas las lecturas y escrituras se realizan por defecto al nodo primario. Sin embargo esto se puede reconfigurar cambiando lo que se conoce como
write/read concerns
volviéndose así la base de datos eventualmente consistente.
MongoDB y la alta disponibilidad
La alta disponibilidad es realmente necesaria para satisfacer la mayoría de casos de uso de las aplicaciones modernas, sobre todo en un mundo donde el alto rendimiento y la inmediatez son lo mínimo que un usuario medio espera.
¿Y cómo resuelve MongoDB este requisito, a pesar de estar diseñada para distribuir nodos a través de diferentes centros de datos e incluso proveedores de nube? Bueno, vamos a empezar por un ejemplo donde NO estamos distribuyendo un sistema, sino que lo inicializamos localmente.
Para eso, vamos a instalar MongoDB en nuestros ordenadores. Lamentablemente la documentación de MongoDB está solo en inglés y no tengo capacidad para traducir las instrucciones de instalación para cada sistema operativo, pero les dirijo a la página:
https://www.mongodb.com/docs/manual/administration/install-community/
https://www.mongodb.com/products/shell
https://docs.mongodb.com/database-tools/?_ga=2.100697729.1601885193.1650825625-818649307.1634902066
Lo que vamos a estar instalando es la versión community, o gratuita, la herramienta de línea de comandos y las utilidades.
Yo voy a utilizar además la extensión oficial de MongoDB para VS Code, para cualquier operación sobre las bases de datos y para trabajar con documentos.
Trabajando con la terminal (Mongo Shell)
Como ya mencioné antes, no voy a explicar cómo instalar MongoDB. Aunque la documentación oficial está en inglés solamente, hay bastante información en la internet al respecto como para que lo puedan lograr sin problemas. Sin embargo, si por alguna razón encuentran problemas, por favor comenten abajo. Seguro que alguien de la comunidad les ayuda.
Cuando instalamos MongoDB localmente, y luego de configurarla, lo que estamos instalando es el servidor que va a correr el proceso mongod
.
Si ya saben lo que es un proceso y especialmente un proceso daemon, se pueden saltar esta parte. Esto es para quienes no lo tienen tan claro.
El proceso mongod
MongoDB corre varios procesos y uno de ellos es mongod
, el proceso daemon primario del servidor, que se encarga de todas las peticiones, el manejo y procesamiento de datos, el acceso, además de -como todo daemon- correr operaciones en una capa profunda de fondo. Es una buena práctica denominar a los procesos daemon con una d al final, que facilita su identificación como tal.
Cuando ejecutamos mongod
en la Mongo Shell, incializamos el proceso con los valores por defecto, es decir el puerto será 27017
los datos se almacenarán en /data/db
. Estos valores por defecto (así como todos los demás) son reconfigurables. Por ejemplo, si queremos inicializar el servidor en otro puerto TCP y guardar los datos en otra ubicación, podemos ejecutar mongod
con las siguientes opciones
mongod --dbpath /mi/ruta/elegida/ --port 28001
El proceso se puede parar en cualquier momento, como cualquier otro proceso, típicamente con ctrl+C
o mongod --shutdown
. Hay métodos adicionales en otros contextos, que exploraremos más delante.
MongoDB standalone o nodo único
Cuando nosotros inicializamos mongod
como proceso, estamos inicializando un proceso, es decir, un nodo único. Este nodo es un nodo primario, con todas las capacidades del nodo primario de un conjunto de réplicas, excepto, la de mudarse a otro nodo, en el caso de un fallo de sistema.
Esto lo podemos verificar, ejecutando rs.status()
en la terminal. Cuando lo hacemos, obtendremos esta información, que básicamente nos dice que no estamos ejecutando un sistema de conjunto de réplicas.
No te preocupes si no has entendido nada. Lo explicaremos. Antes de describir el proceso de instalación y lo que es mongod
, estábamos hablando de alta disponibilidad. Alta disponibilidad, en lo que refiere a sistemas en la nube, se refiere a la capacidad de un sistema de recuperarse ante un fallo, eliminando los puntos potenciales de fallo único. Y para eso, hay que implementar lo que se conoce como redundancia.
Alta disponibilidad, en lo que refiere a sistemas en la nube, se refiere a la capacidad de un sistema de recuperarse ante un fallo, eliminando los puntos potenciales de fallo único, a través de la redundancia.
Redundancia y replicación
La redundancia implica tener varias copias de un sistema, en un estado idéntico, de manera que siempre exista una copia disponible. En MongoDB, eso se logra a través del sistema de replicación y los conjuntos de réplicas.
Si bien cuando por defecto inicializamos un proceso mongod
, estamos inicializando un nodo único y primario (es decir un nodo único que recibe todas las lecturas, efectúa todas las escrituras, y todos los procesos alternativos), las buenas prácticas con MongoDB, especialmente cuando pensamos en sistemas distribuidos geográficamente y a través de varios centros de datos, exigen que optemos por una arquitectura -como mínimo- PSS
, que se entiende como Primary, Secondary, Secondary.
Esta arquitectura describe una instalación de MongoDB, donde encontramos un nodo Primario, que por defecto recibe todas las lecturas, y efectúa todas las escrituras, y las replica a dos nodos Secundarios, que van a tener una copia exacta del primario.
Es decir, no hay que confundir replicación con particionamiento de datos. La replicación es el sistema fundamental que soporta la alta disponibilidad, mientras que el particionamiento de datos (tener diferentes sets or conjuntos de datos distribuidos entre diferentes máquinas -ya sean físicas o virtuales, y lo que en MongoDB (y otros sistemas) se conoce como sharding
, son dos conceptos con aplicaciones y requerimientos diferentes).
El requerimiento de
Alta Disponibilidad
se satisface en MongoDB con laReplicación
, que es igual a redundancia, o a tener copias idénticas de un conjunto de datos único, en un conjunto de varios nodos.
Las arquitecturas de los conjuntos de réplica, admiten desde 3 hasta 50 nodos, con una recomendación de al menos 3, 5 o 7 (dependiendo del volumen y del tipo de distribución), donde un máximo de 7 nodos participarán del proceso de recuperación a través de una elección del nodo primario, en caso de que este falle.
El proceso de replicación
El proceso de replicación es un proceso asíncrono, en el que los nodos secundarios replican los datos del primario. Existe una sincronización inicial, donde el primario vuelca todos sus datos a los sencundarios. Posteriormente la replicación continúa en un proceso mediante el cual los secundarios van leyendo y reescribiendo en su propio oplog el oplog del primario, y luego efectuando los cambios en disco.
Cada vez que (y solamente cuando! Pregunta de examen!) se modifican datos, el primario escribe una entrada en su oplog, que luego se copia al oplog de cada secundario para que este pueda sincronizarse, siempre y cuando el secundario no sea del subtipo árbitro.
Subtipos de secundarios
Existen subtipos de secundarios.
- secundario de prioridad != 0 - No es propiamente un subtipo, pero es el por defecto. La prioridad establece qué tan probable sea que este secundario se convierta en nuevo primario, en el evento de un fallo del primario.
- secundario de prioridad 0 - Cuando creamos un secundario y le damos una prioridad diferente de 0, este guarda datos y puede recibir peticiones de lectura, pero nunca podrá ser elegido nuevo primario.
- secundario árbitro (arbiter) - este secundario no puede escribir datos ni recibe peticiones de lectura, ni tampoco ser primario, solamente se usa para desempatar la votación en el caso de elecciones.
- secundario escondido (hidden) - este secundario está escondido a la aplicación. mantiene una copia del primario y puede votar, pero no puede volverse primario. Se suele usar para workloads o tareas específicas, como procesamiento de análisis de datos.
- secundario con retraso (delayed) - este secundario se debe mantener escondido y con prioridad 0, para que no se vuelva un primario, ya que es un secundario que mantiene una sincronización con un cierto tiempo de retraso con respecto del primario. Se suele usar como estrategia de respaldo.
Fallo del primario y elecciones
Hemos dicho que una arquitectura MongoDB que sigue buenas prácticas, va a ser al menos PSS, es decir va a contar con 3 nodos. Pero ahora que conocemos los subtipos, tenemos que tener en cuenta que cuando decimos 3 nodos, nos referimos a 3 nodos con la capacidad de votar y ser elegidos primarios. Hemos dicho que podemos tener hasta 50 nodos, y que cuántos decidamos tener, va a depender de nuestros requerimientos. Otra vez, recordar que esos 50 nodos tienen todos exactamente la misma copia de los datos. Aunque puede ser que la latencia entre nodos afecte la replicación, en nanosegundos, milisegundos, segundos, o incluso minutos.
Sin embargo, lo habitual es tener 3, 5 o 7 nodos (que votan). La cantidad de nodos que votan, siempre tiene que ser impar, con un máximo de 7.
Tenemos que tener en cuenta que cuando decimos 3 nodos, nos referimos a 3 nodos con la capacidad de votar y con la capacidad de convertirse en primarios. La cantidad de nodos que votan, debe ser siempre impar, con un máximo de 7
¿Y por qué tiene que ser impar? Para prevenir empates si ocurre una elección.
El procesos de elección y la conmutación automática del primario
En el dibujo de arriba vemos unos corazoncitos. Estos representan un latido o heartbeat
, que es el proceso que identifica el estado de cada nodo, en todo momento. La frecuencia es configurable y lo que ocurre en el caso de no detectar un latido es lo siguiente:
si el primario no detecta latidos de uno de los secundarios, de modo que no se pueda garantizar la mayoría, se convertirá automáticamente en secundario.
si un secundario no detecta el latido del primario, se auto-nominará primario. En ese momento, cualquier otro secundario votante de la réplica intentará conectar con el primario, para verificar que el secundario que se intenta promover no ha dejado de percibir latidos por un problema de conexión o de red.
Si este secundario comprueba que el primario realmente ha caído, entonces empezará un proceso de verificación del estado del secundario que se quiere promover, para validar su estado tanto de salud como de versión de datos, con respecto al oplog del primario que falla.
Una vez han terminado las comprobaciones, el secundario que se ha promovido, es votado y pasa a ser el primario, mientras que el primario que ha fallado, se vuelve secundario y comienza su proceso de auto-reparación.
Todo este proceso dura unos 4 segundos, en condiciones óptimas.
Evitar la perdida de escrituras durante una conmutación automática
Para evitar la pérdida de datos, en el momento de una conmutación, debemos haber habilitado los reintentos de escritura a nivel aplicación o driver, con la opción
retryWrites=true
Hay que tener en cuenta que los reintentos de escritura no son posibles para los concerns
de escritura equivalentes a 0, ni a nivel individual durante una transacción (hablaremos de transacciones cuando hablemos de soporte ACID), ni en los sistema de un nodo (standalone).
Más sobre el Oplog
Hemos hablado de cómo los nodos de una réplica se sincronizan a través de la copia de operaciones que modifican los datos, que se registran en el oplog del primario, se copian a los oplogs de los secundarios, y finalmente se ejecutan a nivel de disco.
El oplog es una colección especial y capada (es decir, tiene un límite de bytes disponible) que define lo que se conoce como la ventana del oplog o retención de datos. Solo las operaciones que están dentro de la ventana son recuperables.
Para decidir el tamaño del oplog y de la ventana del oplog, hay que evaluar la intensidad y cantidad de operaciones que modifican el set de datos (inserts, updates, etc), en un determinado periodo de tiempo. El oplog se puede configurar por tamaño en bytes (generalmente MBs o GBs) o en horas. Para la configuración en bytes, se suele reservar 5% del espacio en disco para el oplog.
Para decidir el tamaño del oplog y de la ventana del oplog, hay que evaluar la intensidad y cantidad de operaciones que modifican el set de datos (inserts, updates, deletes, etc), en un determinado periodo de tiempo.
Las operaciones guardadas en el oplog, serán removidas siempre y cuando el oplog esté lleno o haya superado el tiempo ventana, first in, first out
.
Write concerns (materia de escrituras)
La verificación de escrituras de un conjunto de réplicas, se determina por su definición de write concern.
Una configuración igual a w: "majority"
, establece que el reconocimiento de las escrituras, se debe haber propagado a una mayoría calculada en base al total de nodos que guardan datos y son votantes, antes de ser efectivas.
Si w: 1
, entonces el reconocimiento del nodo primario es suficiente, para que la escritura sea efectiva. Un concern diferente de w: 1
, conlleva degradación de rendimiento, pero aumenta la consistencia.
Un concern diferente de
w: 1
, conlleva degradación de rendimiento, pero aumenta la consistencia.
Crear y configuar un set de réplicas con la terminal
Lo primero que vamos a hacer es crear cada nodo del set de réplicas, de manera independiente.
Creamos tres nodos con la siguiente configuración
mongod --replSet nataliaRS --logpath "rsnode1.log" --dbpath rsnode1 --port 27017
mongod --replSet nataliaRS --logpath "rsnode2.log" --dbpath rsnode2 --port 27018
mongod --replSet nataliaRS --logpath "rsnode3.log" --dbpath rsnode3 --port 27019
Es decir, inicializamos tres procesos mongod
, pasando la opción --replSet
a la que en mi caso doy el valor nataliaRS
que será el nombre del conjunto, le asignamos una ruta para el log y otra para los datos (ojo! que tiene que existir!), y a cada una un puerto.
Luego verificamos que los tres nodos están corriendo su proceso
Y ahora describimos la configuración con la que vamos a inicializar el conjunto de réplicas.
Primero inicializamos el REPL de la línea de comandos con el comando 'mongo'. Y luego creamos un objeto conf
de la siguiente manera:
config = {
_id: "nataliaRS",
members: [
{_id: 0, host: "localhost:27017"},
{_id: 1, host: "localhost:27018"},
{_id: 2, host: "localhost:27019"}
]
};
Este objeto se lo pasaremos luego al método de replicación rs.initiate()
del la API de esta manera.
rs.initiate(config);
Si el objeto de respuesta tiene un valor 1
para el campo ok
, podemos asumir que el conjunto de réplicas se ha creado correctamente. También podemos ejecutar rs.status()
para obtener información sobre el estado del conjunto y sus miembros.
Se pueden agregar posteriormente más miembros al conjunto, usando
rs.add(server)
(donde server tiene que ser un proceso mongod
)
Si necesitamos reconfigurar la réplica y sus miembros, podemos hacerlo de esta manera. Supongamos que queremos cambiar la prioridad del nodo secundario de _id = 1
, que sabemos que está en el ínidice 1 del array members
: lo podemos hacer especificando el _id o de esta manera.
Primero asignamos la configuración actual a una variable, en este caso la llamaremos cfg
, que es una convención en la documentación.
cfg = rs.conf();
Y luego procedemos a reasignar el valor
cfg.members[1].priority = 3;
rs.reconfig(cfg);
Podemos ver que el conjunto de réplicas funciona correctamente, y crear una base de datos, una colección y un documento que después de la sincronización, serán accesibles desde cualquiera de los nodos de la réplica, que sean de escritura.
Podemos ver que el documento es accesible desde el nodo que corre el proceso en el puerto 27017, así como en el que corre el proceso en el 27018 ( y también lo será en el que lo corre en el 27019)
Más métodos replicación, se describen en la documentación
Buenas prácticas para conjuntos de replicas
Para terminar, hay que hacer incapié en que es importante seguir buenas prácticas al diseñar nuestras arquitecturas de conjuntos de réplicas, para poder garantizar la alta disponibilidad o la consistencia eventual, dependiendo del caso.
Es importante tener una buena política de retención de datos, que gobierne nuestras decisiones en cuanto al tamaño y ventana de oplog.
https://www.mongodb.com/docs/manual/reference/method/js-replication/ Si se distribuyen a través de varios centros de datos (regiones o zonas), es importante que hayan al menos dos nodos en el mismo centro, para impedir que las interrupciones de conexión o la latencia, nos den falsos positivos.
Es también importante habilitar los reintentos de escritura y, dentro de lo posible, no abusar de los nodos árbitros y utilizarlos de manera eficiente. Tener un árbitro, un secundario y un primario, no garantiza la alta disponibilidad del sistema.
En la medida de lo posible, los nodos dedicados a tareas exclusivas (isolated workflows), deben esconderse.
En los próximos blogs
- Operaciones CRUD
- Patrones de diseño
- Marco de Agregado
- Sharding
- Wired Tiger y caching
- CosmosDB para MongoDB API
y más...
Si te ha gustado el artículo, compártelo y dale like aquí en dev.to! Una versión en inglés de este artículo estará disponible pronto en GitHub, para ser traducida a otros idiomas.
Por favor, da crédito a https://www.github.com/anfibiacreativa si lo usas! <3
Cuídense mucho!
Top comments (4)
Hola Natalia!!! 👋
Me ha gustado mucho tu Post, y sobre todo que hayas tomado la decisión de hacerlo en español. Sinceramente no existen muchos post como el tuyo, tan bien detallados, expresados y que abarque tantos aspectos.
Impaciente espero el siguiente 😊
dev.to/anfibiacreativa/bases-de-da...
Ya salió!
Hola Natalia!!
Lo leeré con mucha atención.
Muchas gracias por ese gran trabajo. 🥰
Muchas gracias, Sergio! Ya lo estoy escribiendo! :)