En este post, veremos de forma muy simple pero detallada como podemos preparar un entorno muy básico para comenzar a desarrollar una aplicación web usando NodeJS y MongoDB. Además, usaremos Docker Compose para ayudarnos a gestionar la configuración de los servicios que usaremos.

A pesar de que el resultado obtenido al final será ridículamente simple, procuro hacer incapié en algunos aspectos básicos pero que, en base a mi experiencia, considero fundamentales para entender como construir aplicaciones más completas. Sin embargo, debo reiterar que lejos de ser un experto en el área, soy un simple entusiasta que desea compartir su aprendizaje,

Vamos a ello.

app-diagram

Figura 1: componentes de la aplicación.

Para una aplicación web generalmente requerimos de 2 servicios: servidor web y base de datos. Entonces, necesitaremos un contenedor Docker para cada uno. Podríamos perfectamente crear un contenedor que ejecute ambos servicios; sin embargo, en la industria es común que estos servicios se ejecuten en distintos hosts que pueden estar incluso en ubicaciones geográficas distintas y completamente independientes. Esta es la razón por la que es importante hacer esta distinción incluso cuando hablamos de una aplicación tan simple como la que haremos ahora.

Como podemos ver en la Figura 1, el contenedor llamado backend consume los servicios del contenedor llamado db. Imaginemos que cada contenedor es una máquina distinta; entonces, esto significa que el host backend necesita poder conectarse a el host db, y para que eso pueda ser posible en un entorno real, es necesario que backend conozca la dirección IP y puerto donde db está escuchando ¿Correcto? Sin embargo, estos no son hosts reales, son contenedores por lo que en lugar de usar la dirección IP del host db, usaremos el nombre del contenedor, que para este caso será justamente db.

Entonces, vamos a crear un directorio donde trabajaremos en nuestro proyecto, y dentro creamos nuestro archivo de variables de entorno, que es donde inicializaremos los datos que acabamos de mencionar.

mkdir test-project
cd test-project
touch .env

El contenido de .env es el siguiente.

PORT=3000
DBHOST=db
DBPORT=27017
DBNAME=test-database

Notemos que hemos indicado el puerto (DBPORT) y host (DBHOST) de la base de datos, pero del servidor web sólo hemos indicado el puerto (PORT). Esto es porque estos parámetros son justamente para el servidor web, y el servidor web sólo necesita saber el puerto donde va a ejecutarse, y la “ubicación” de sus datos.

Posteriormente, creamos un archivo Dockerfile para configurar la imagen personalizada de Node. En este caso, sólo indicamos que se use el usuario sin privilegios y que se trabaje en el directorio home de dicho usuario.

FROM node:latest
USER node
WORKDIR /home/node

Ahora ya tenemos todo lo necesario para armar nuestro archivo docker-compose.yml, que es el que se encargará de instanciar los componentes (servicios) que nuestra aplicación web necesita para funcionar.

version: '3.8'
services:
    backend:
        container_name: login-backend
        build:
            context: .
            dockerfile: Dockerfile
        env_file: .env
        depends_on:
            - db
        ports:
            - 127.0.0.1:$PORT:$PORT
        volumes:
            - .:/home/node
        command: npm run dev
    db:
        container_name: login-db
        image: mongo
        ports:
            - 127.0.0.1:$DBPORT:$DBPORT
        env_file: .env
        volumes:
            - mongo_data:/data/db
            - mongo_data_config:/data/configdb
volumes:
    mongo_data:
    mongo_data_config:

Notemos que tenemos los 2 servicios que mencionamos anteriormente: db y backend. Recordemos que estos nombres funcionan como un DNS interno dentro de la red dedicada que Docker Compose crea para nuestra aplicación. Esto justamente para facilitar la comunicación entre los contenedores como explicamos arriba. Cada servicio accede al archivo .env para obtener sus parámetros de configuración.

También podemos notar que backend mapea el directorio actual del proyecto . hacia el directorio de trabajo (WORKDIR) del contenedor, que es /home/node. Además, al contenedor db se mapean las rutas /data/db y /data/configdb hacia las etiquetas mongo_data y mongo_data_config, respectivamente. Al usar etiquetas en lugar de rutas específicas, dejamos que Docker gestione su almacenamiento (por lo general se guarda en /var/lib/docker/volumes). De este modo, el contenido de dichas rutas no se perderá si se detiene y elimina el contenedor de la base de datos (que es parte de lo que ocurre cuando paramos los servicios usando docker compose down). En un contexto de desarrollo, esto es útil por si vamos llenando nuestra base de datos con datos de prueba, y no queremos que nuestra base de datos esté limpia cada que creemos una instancia de nuestra aplicación. Aunque también puede darse el caso de que queramos eliminar la información guardada para comenzar “de cero”. Todo esto se puede hacer desde la línea de comandos de docker o desde su interfaz gráfica, pero no lo veremos en este post.

Otro aspecto que podría llamar la atención es el container_name. Este campo es el el nombre del contenedor en sí, y es útil para identificar y administrar el contenedor una vez se encuentre activo, pero no es visible ni relevante para la aplicación web.

Finalmente, en el servicio backend hemos indicado la ejecución de un script llamado dev en dicho contenedor una vez que se active: command: npm run dev. El programa npm buscará el script en un archivo package.json; este archivo nos sirve para especificar detalles de nuestra aplicación, así como dependencias y scripts.

Podemos crear package.json de manera manual, o interactiva. Usaremos la forma interactiva.

npm init

Este comando nos hará una serie de preguntas y al finalizar tendremos creado el package.json.

Ahora necesitamos agregar las dependencias de nuestro proyecto, que serían las siguientes.

  • dotenv accede al archivo .env por defecto en la raíz del proyecto ( donde esté el index.js entrypoint) y carga las variables en process.env.
  • express framework Node.
  • mongoose acceso a la base de datos.
  • nodemon ejecución en “modo desarrollo”.

Para instalar dependencias, usamos la sintaxis npm i [PAQUETE]. Nótese que el paquete nodemon lo instalamos como una dependencia de desarrollo (no se usa una vez que la app se encuentra en producción).

npm i dotenv express mongoose
npm i nodemon --save-dev

Cuando se instalan las dependencias, npm las agrega al archivo package.json seguido de la versión instalada; además se crea el directorio node_modules donde se almacena el código de dichas dependencias. Podemos eliminar node_modules sin problema, y volver a generarlo ya que las dependencias ya estan especificadas en package.json. Dicho esto, es importante que el archivo package.json sea rastreado y respaldado como parte del código de nuestra aplicación; de este modo, cuando sea necesario compartir el código con algún colaborador, éste podrá generar node_modules a partir de package.json. Para hacer esto se usa el comando npm install.

Finalmente, en el package.json agregamos los scripts start y dev en la llave scripts.

{
  ...
  "scripts": {
    "dev": "nodemon index.js",
    "start": "node index.js"
  },
  ...
}

Recordemos que docker-compose.json ejecuta por defecto el script dev. Una vez que hayamos terminado nuestra app, podríamos decidir usar el script start en su lugar. Sin embargo, durante el desarrollo nos conviene usar dev, ya que éste usa nodemon para ejecutar el punto de entrada de nuestra aplicación (index.js), y nodemon nos provee de mucha información útil para encontrar y depurar errores la aplicación.

Finalmente, procedemos a crear el punto de entrada index.js.

// dependencias
const express = require('express')
const mongoose = require('mongoose')

// instancia express
const app = express();

// variables de entorno
require('dotenv').config();

// conexión a base de datos y manejo de errores
let db_status = false;
const URI = `mongodb://${process.env.DBHOST}:${process.env.DBPORT}/${process.env.DBNAME}`;
mongoose.connect(URI, {useNewUrlParser: true});
mongoose.connection.on('connected', () => {
    console.log('MONGODB: OK');
    db_status = true;
});
mongoose.connection.on('error', (err) => {
    console.error(`MONGODB: ERROR: ${err}`);
    db_status = false;
});

// rutas
app.get('/', async (req, res) => {
    if (db_status) {
        res.send('Hello World!');
    } else {
        res.send('Database error!');
    }
})

// activar escucha del servidor
app.listen(process.env.PORT, () => {
    console.log(`NODEJS: OK (PORT: ${process.env.PORT})`);
});

El código anterior hace lo siguiente:

  1. Llama el código de las dependencias: express y mongoose. Esto nos provee acceso a todas las funcionalidades que éstas librerías ofrecen a través de las constantes definidas con esos mismos nombres.
  2. Se crea una instancia de Express, el framework de Node que usaremos para facilitarnos la vida.
  3. Se usa la función config() de la librería dotenv para cargar las variables de entorno del archivo .env en el objeto global de NodeJs process.env. De este modo, podemos acceder, por ejemplo, al puerto que definimos en .env para el backend usando process.env.PORT.
  4. Se construye una cadena de conexión (URI) para la base de datos, usando su host y puerto, seguido de el nombre de la base de datos. Si la base de datos no existe, MongoDB la creará automáticamente. Dependiendo del resultado de dicha conexión, se muestran ciertos mensajes en el log del backend, y se modifica una variable booleana para indicar el estado de la conexión.
  5. Se crea la ruta principal de la aplicación, la cual simplemente revisa el estado de la conexión y muestra Hello World en el navegador si no hubo problemas. De lo contrario, muestra Database error!
  6. Finalmente, se activa la escucha del servidor para empezar a recibir peticiones HTTP.

Ahora ya podremos iniciar los servicios.

docker compose up -d

Y posteriormente abrir nuestro navegador, e ir a la dirección http://127.0.0.1:3000/ y deberíamos ver “Hello World!”.

Por supuesto, en esta “aplicación” la única petición que estaríamos manejando es un simple GET en la ruta / sin parámetros. Y nuestro “frontend” estaría conformado por 2 posibles vistas: “Hello World!” y “Database error!”. Sin embargo, espero que este simple procedimiento sirva como punto de partida para comenzar a desarrollar una aplicación mas compleja sin perder de vista las bases de su funcionamiento.