Preparar Entorno de Desarrollo Web Local (NodeJS, MongoDB)
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.
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é elindex.js
entrypoint) y carga las variables enprocess.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:
- Llama el código de las dependencias:
express
ymongoose
. Esto nos provee acceso a todas las funcionalidades que éstas librerías ofrecen a través de las constantes definidas con esos mismos nombres. - Se crea una instancia de Express, el framework de Node que usaremos para facilitarnos la vida.
- Se usa la función
config()
de la libreríadotenv
para cargar las variables de entorno del archivo.env
en el objeto global de NodeJsprocess.env
. De este modo, podemos acceder, por ejemplo, al puerto que definimos en.env
para el backend usandoprocess.env.PORT
. - 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. - 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, muestraDatabase error!
- 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.