Hace mucho que no escribo nada por aquí... No quiere decir que no lo haga en otros lados, pero ya sabes lo que pasa en casa del herrero.
Estoy metido en mil temas, a cada cual más interesante, al menos para mí. Resulta que uno de esos temas es un Mastermind dentro de la comunidad de Web Reactiva, en el cual me han pedido amablemente que cuente algún progreso sobre un proyecto que se está cociendo dentro del Mastermind.
Así que se ha juntado "el mucho tiempo sin escribir por aquí" con la "petición" desde la comunidad Malandriner y me he dicho, "al blog de cabeza!" y heme aquí resucitando el blog.
Comunicación cliente - servidor
Antes de ponerme a explicar de qué va el proyecto me gustaría hablar un momento sobre mi enfoque en lo relativo a comunicación cliente - servidor, ya que ha sido la raíz por la que me decidí a crear esta solución.
Pertenezco a esa "escuela de pensamiento" en la que la velocidad de respuesta al usuario es clave, y todo proceso de desarrollo se lleva a cabo con esa idea en mente. Puede sonar a maníaco (pero buena persona), pero en el entorno de desarrollo todos las llamadas a cualquier endpoint deben completarse en menos de 100ms. Si tardan más tiempo hay algo mal en algún sitio.
Ojo, esto teniendo en cuenta que tanto front como back y DB están en local y no hay llamadas al exterior. Llámame "especialito" pero es mi forma de optimizar procesos. Puedo hablar sobre mi postura largo y tendido, pero no es el momento así que quédate con "el tiempo de respuesta es prioridad absoluta".
Con esa idea en mente cree hace tiempo una solución para evitar demoras en las respuestas cuando el backend se enfrentaba a tareas más "pesadas" de lo normal, como puede ser enviar un correo electrónico. Básicamente se trata de delegar las tareas pesadas a una cola de tareas almacenada en Redis.
A esta cola de tareas se pueden enviar trabajos desde cualquier backend que esté conectado al Redis. Obviamente hay que tener en cuenta temas de seguridad para evitar accesos no autorizados... Ya sabes como está el patio.
La pelota ahora está en Redis, que puede almacenar varias colas de trabajos en su base de datos. Desde el o los microservicios, porque podemos tener tantos como queramos, estamos a la escucha de que lleguen nuevos trabajos a las colas de tareas que este microservicio gestiona.
En el momento que llega un nuevo trabajo, BullMQ lo saca de cola y lo procesa. En caso de fallo... Las cosas fallan, lo intenta de nuevo añadiendo un delay entre cada intento.
Si todo va bien, el mensaje se envía y se elimina la tarea de la cola de trabajos. En caso de error se registra en el log de eventos para revisar manualmente el motivo del fallo, como puede ser una dirección de correo que no existe o un adjunto que no ha pasado la validación de seguridad.
He olvidado comentar que cada tarea de tipo "manda un email" incluye unas propiedades como la plantilla a usar o el idioma de la plantilla. Estos datos son extraídos desde MongoDB, aunque podrían estar en formato de fichero JSON sin problema.
Yo he optado por esta aproximación con la idea de desarrollar más adelante un front en vue que se conecte a la base de datos y permita gestionar las plantillas y todos los datos asociados a éstas. Me parece más versátil que tirar de JSONs, aunque he de admitir que la versión primigenia de esta solución funcionaba con ficheros estáticos sin base de datos.
Semejante armatoste tiene actualmente la finalidad enviar correos transaccionales como "verificar registro de usuarios", "avisos random a usuarios" o "sistema de login por magic links".
Ingredientes para armar un microservicio
En mi caso, ya es sabido, uso el ecosistema de Node.js, aunque este proyecto se puede replicar con cualquier otra tecnología... Incluso con PHP!
Librerías o paquetes que he usado:
- "bullmq": gestor de tareas
- "nodemailer": motor de envío de correos
- "handlebars": motor de renderizado de plantillas HTML
- "dotenv": gestor de variables de entorno
Instalar las dependencias: npm install bullmq dotenv nodemailer handlebars
Fichero de configuración de Nodemailer
Partimos por la parte más profunda de la aplicación, el fichero de configuración del paquete Nodemailer
. Recordemos que este paquete es el que nos permitirá enviar correos electrónicos.
En esta primera parte añadimos las dependencia de Nodemailer
y la función de renderizado que crearemos después.
// email.js
const { getRender } = require('./templates');
const nodemailer = require('nodemailer');
SAhora creamos el objeto de configuración transporter
y le pasamos los datos de conexión necesarios. Recuerda que las variables existentes en process.env
vienen desde la configuración de dotenv
, aunque eso para más tarde.
// email.js
let transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST, // dirección IP o nombre del servidor de correo
port: process.env.EMAIL_PORT, // puerto de conexión al servidor
secure: process.env.EMAIL_SSL, // si vamos a usar conexión segura o no
auth: {
user: process.env.EMAIL_USERNAME, // nombre de usuario
pass: process.env.EMAIL_PASSWORD // contraseña
},
tls: {
rejectUnauthorized: process.env.EMAIL_REJECT_UNAUTHORIZED // si rechazamos la conexión por fallo del certificado digital
}
});
Seguimos añadiendo configuración a Nodemailer
, en este caso hacemos uso de la función verify
para asegurarnos que la conexión con el servidor de correo se realiza correctamente o existe algún error.
// email.js
transporter.verify( (error) => {
if (error) {
console.log(error);
} else {
console.log('smtp email server is ready');
}
});
El último paso en este fichero es exportar la función que nos permitirá invocar a Nodemailer
para enviar un correo. Te puedes complicar tanto como quieras, pero teniendo en cuenta que la función sendMail
devuelve una promesa, la aproximación más sencilla es algo así:
// email.js
exports.email = async (data) => {
try {
const email = {
from: `"${data.name}" <${data.email}>`,
to: data.to,
subject: data.subject,
html: await getRender(data.context)
};
return await transporter.sendMail(email);
} catch (error) {
return Promise.reject(error);
}
};
Vemos que se definen las propiedades from
para indicar el remitente del correo, to
para el destinatario, el asunto con subject
y html donde tendremos un fichero de este tipo renderizado con las propiedades enviadas en el contexto.
Después de definir el objeto, se le pasa a la función send
de Nodemailer
para que lo envíe.
Con esto ya tenemos toda la lógica para enviar un correo electrónico desde Nodemailer
, seguimos...
Fichero para renderizar HTML
Aquí he optado por lo sencillo. Esto tiene mucho margen de mejor, pero lo he creado así por facilidad a la hora de entenderlo.
Comenzamos importando handlebars
que es un motor de renderizado de plantillas HTML muy interesante.
// templates.js
const Handlebars = require('handlebars');
Proseguimos definiendo un fichero HTML que servirá de plantilla. Este código HTML sse puede sacar de un fichero JSON, de una base de datos o leerlo directamente de disco, al gusto. Lo importante es que sea un código HTML compatible con las exigencias de los clientes de correo, que ya te aviso son muy especialitos.
Además deberemos asegurarnos de que usamos la sintaxis de Handlebars para realizar las interpolaciones de la información que le pasemos desde el objeto de contexto.
// templates.js
const TEMPLATE = `<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bienvenido</title>
</head>
<body>
<p>Gracias por tu registro </p>
</body>
</html>`
En este ejemplo voy a reemplazar el bloque `` del HTML por el valor de name
que le pase en el context
.
// templates.js
exports.getRender = (context) => {
return new Promise( (resolve, reject) => {
try {
let template = Handlebars.compile(TEMPLATE);
let render = template(context);
resolve(render);
} catch(error) {
reject(error);
}
});
}
Definiendo un Worker
Se viene lo bueno. Vamos con la parte donde creamos una conexión con Redis a través de BullMQ y empezamos a leer tareas... ¡Emoción!
Comenzamos por añadir las dependencias de BullMQ
y la función email
que exportamos anteriormente.
// worker.js
const { Worker } = require('bullmq');
const { email } = require('../email');
Ahora importamos la configuración de la conexión a Redis y definimos el objeto de conexión para BullMQ
.
// worker.js
const _options = { connection: { host: process.env.REDIS_HOST, port: process.env.REDIS_PORT } };
Creamos una función que envolverá la creación e inicialización del worker
. Atentos que vienen curvas... 🚘
El Worker
recibe como primer parámetro el nombre de la cola de tareas a la que va a estar escuchando, o a la que se va a suscribir, en "palabros" técnicos. Como segundo parámetro recibe una función anónima donde pasa los datos de la tarea a través del job
, y es en esa función anónima donde se ejecutan las tareas. En nuestro caso se invoca a la función email
, pasándole los datos de la tarea a través de job.data
. Observa que como tercer parámetro a Worker
se la pasan los datos de conexión al servidor Redis.
// worker.js
exports.initWorker = () => {
const currela = new Worker('email', async (job) => {
await email(job.data)
}, _options);
}
Para finalizar con esta parte del worker, al crear una nueva instancia de Worker
, como hemos visto arriba, se crea un objeto que representa al estado del worker, que en mi caso he llamado currela
para evitar confusiones. Observa que currela
tiene dos eventos, uno para cuando la tarea termina exitosamente y otro para cuando termina en desastre y hay que llamar a los bomberos... O no.
// worker.js
currela.on('completed', (job) => {
console.log('TASK_FINISHED', job?.id)
});
currela.on('failed', (job) => {
console.log('TASK_FAILED', job?.id, 'TRIES': job?.attemptsMade)
});
El fichero principal
El fichero index.js actuará de punto de inicio de la aplicación y será el encargado de crear al worker y que comience a hacer tareas... ¡Que no se hacen solas!
Comenzamos importando dotenv
para que se carguen todas las variables de entorno que hemos definido en el fichero de configuración .env
de la carpeta raíz. Si la carga falla por algún motivo, nos saltará un aviso, que nunca viene mal.
// index.js
const result = require('dotenv').config();
if (result.error) {
throw result.error;
}
Ahora importamos la función que crea y arranca el worker, y que definimos en el fichero worker.js
.
// index.js
const { initWorker } = require('./config/worker');
Seguidamente creamos la función que inicia todo el tinglado y la invocamos... esto no es nada nuevo, espero.
// index.js
async function init() {
try {
initWorker();
console.log('APP_INIT');
} catch (error) {
console.log('ERROR_APP_INIT', error);
}
}
init();
Configuración
Será necesario que edites el fichero sample.env
con los datos de conexión de tu instancia de Redis y de tu proveedor de correo electrónico. Una vez configurado, guarda el fichero con el nombre .env
en la raíz del proyecto. Tampoco estaría nada mal que le dieras un vistazo al readme.md
... como consejo desinteresado, eh.
Añadir tareas a la cola de trabajos
Hasta ahora hemos visto cómo se procesan los trabajos enviados a la cola de tareas, pero nada acerca de añadir tareas. Para esto he creado un fichero llamado addTask.js
que permite añadir una tarea del tipo "envía un correo, por favor te lo pido" cada vez que se ejecuta.
Para que el invento funcione es necesario que arranques el proyecto del microservicio en una terminal con un simple node app/index.js
y en otra terminal distinta ejecutes node app/addTask.js
.
Hay un tiempo de 3000 milisegundos de espera entre cada tarea, así que no seas ansias. Cada vez que ejecutes node app/addTask.js
se enviará un correo electrónico... el resto lo dejo a tu imaginación.
Conclusiones
Lo que he expuesto en esta entrada es un resumen ligero de una aplicación mucho más compleja y que sería imposible de resumir en un blog, aunque quizá en vídeo... 🤔, pero el resultado es el mismo, una gestión de tareas asíncronas, envío de correos en este caso, usando Redis a través de la gestión de colas de BullMQ.
Tienes el código completo en GitHub para que lo analices y mejores si te complace. Recuerda que para cualquier duda tienes el hilo del post en Twitter... Nos leemos asíncronamente.
Descarga el código del articulo desde su repositorio en GitHub