Una aplicación MVC en Express usando el motor de vistas Pug y base de datos con MongoDB. Todo el código disponible en GitHub.
Vamos a hacer la misma app que hicimos en hello-fetch pero con otro enfoque. En esta versión haremos un thin client (cliente flaco). Se dice que es flaco porque el navegador el único trabajo que hace es recibir y mostrar el HTML que llega del backend ya todo cocinado. Toda la carga de trabajo recae sobre la aplicación de Express.
En cambio en la otra versión teníamos una API estilo REST y el backend solo tenía que pasar los datos de los usuarios en formato JSON, el resto del trabajo como actualizar la UI lo hacía el cliente con JavaScript, aliviando la carga de trabajo en el servidor.
Los dos enfoques son válidos y tienen sus usos, ventajas y desventajas. Parte del trabajo del equipo de un proyecto es decidir qué enfoque conviene en cada situación.
MVC hace referencia a una forma de estructurar una aplicación, independientemente del lenguaje de programación, frameworks y tecnologías usadas. Si investigan sobre el tema en Internet van a encontrar que en este tipo de cosas los ingenieros y programadores no suelen ponerse de acuerdo. Así que lo que digo a continuación tomenlo como una opinión, lo que yo entiendo que es MVC, otros dirán que eso no es MVC. Esa discusión se la dejo a los académicos, yo les doy la versión de MVC sacada de este tutorial de MDN pero con una app mucho más modesta.
La idea importante es separar la aplicación en tres capas, una capa más cercana a la base de datos: los modelos, una para la interfaz de usuario: las vistas y una capa en el medio que interactúa con los modelos para traer información de la base de datos, y lleva esa información a las vistas para mostrarla al usuario: los controladores. Como interfaz entre el cliente y los controladores tenemos las rutas. Cada una de estas partes de la aplicación tienen su directorio (carpeta) en el proyecto: models
, views
, controllers
y routes
por sus nombres en inglés. La estructura del directorio del proyecto cuando esté terminado será la siguiente.
.
├── controllers
│ └── index.js
├── index.js
├── models
│ └── User.js
├── package.json
├── package-lock.json
├── Procfile
├── README.md
├── routes
│ └── index.js
└── views
├── head.pug
├── index.pug
└── layout.pug
Lo normal en desarrollo web cuando trabajamos con vistas es usar un template engine (motor de plantillas). Para esta app vamos a usar Pug que es un lenguaje y un motor de plantillas soportado por Express. Otras opciones populares son EJS, Handlebars o Hogan.
En la terminal creamos un proyecto de Node y creamos las carpetas y archivos necesarios.
$ mkdir hello-mvc
$ cd hello-mvc
$ npm init -y
$ git init
$ echo node_modules > .gitignore
$ echo web: npm start > Procfile
$ touch index.js
$ mkdir routes models views controllers
$ touch routes/index.js
$ touch controllers/index.js
$ touch models/User.js
$ touch views/layout.pug views/index.pug views/head.pug
$ npm i express mongoose pug
$ npm i -D nodemon
Agregamos los scripts de siempre al package.json
. En index.js
armamos la app de Express y nos conectamos a la base de datos.
// index.js
const express = require('express');
const mongoose = require('mongoose');
const port = process.env.PORT || 3000;
const db = process.env.MONGODB_URI || 'mongodb://localhost/hellodb';
const app = express();
// conexion a la base de datos
mongoose.set('useUnifiedTopology', true);
mongoose.set('useFindAndModify', false);
mongoose
.connect(db, { useNewUrlParser: true })
.then(() => {
console.log(`DB connected @ ${db}`);
})
.catch(err => console.error(`Connection error ${err}`));
// listen
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
Para decirle a la app que use Pug ponemos debajo de const app
y antes de la conexión a la base de datos las siguientes líneas.
// set views
app.set('view engine', 'pug');
app.set('views', './views');
La primera le dice a la app que motor de plantillas vamos a usar, la segunda le dice en qué directorio buscar las vistas.
El modelo es el archivo models/User.js
y es exactamente el mismo que en hello-database. Es la interfaz entre la base de datos y la aplicación. Particularmente los métodos User.find()
y User.findOne()
que vamos a utilizar. Aunque Mongoose nos permite hacer mucho más que eso, por el momento lo mantenemos bien simple. El modelo va a ser visible solo para el controlador.
El archivo routes/index.js
contiene una instancia de express.Router()
y va montado en el index.js
del directorio raíz. El código es sencillo, el router lo único que hace es asociar tal ruta con tal función del controlador.
// routes/index.js
const express = require('express');
const controller = require('../controllers/index');
const router = express.Router();
router.get('/', controller.home);
router.get('/search', controller.search)
module.exports = router;
Las rutas son sólo dos, una para el home y otra para cuando clickeamos en el botón de buscar. En la segunda línea importamos el controlador donde están definidas las funciones que se ejecutan para cada ruta. Al final exportamos el router para montarlo en index.js
, un buen lugar es debajo de donde seteamos las vistas.
// router
const router = require('./routes/index');
app.use('/', router);
El controlador tiene que definir las dos funciones que usamos en el router y tiene que exportarlas. Además va a hacer uso del modelo, así que lo importamos en la primera línea.
// controllers/index.js
const User = require('../models/User');
exports.home = (req, res) => {
res.send('Implementar');
};
exports.search = (req, res) => {
res.send('Implementar')
}
En este punto la aplicación responde a las rutas localhost:3000/
y localhost:3000/search
aunque con un mensaje de texto. Vamos ahora a definir las vistas para empezar a ver nuestra UI.
Si bien tenemos tres archivos dentro de views
la app tiene una sola vista. Lo separo en tres archivos distintos para mostrar un poco lo que podemos hacer con Pug, pero podríamos tener toda la plantilla en un solo archivo.
El archivo layout.pug
define la estructura básica del HTML que vamos a enviar al cliente. Acá tenemos un esqueleto básico de una página web, los elementos html
, head
y body
.
doctype html
html(lang='es')
head
include head.pug
body
div.container.mt-3
block content
En Pug la indentación es importante, los dos espacios de indentación entre html
y head
o body
indican que esos elementos están anidados. Usen espacios para indentar, yo doy dos espacios de indentación. Los elementos de HTML tienen los nombres habituales, como div
, p
o h1
.
Para indicar los atributos de un elemento lo hacemos dentro de paréntesis como en html(lang='es')
. Si el atributo es una clase podemos hacerlo con .
como en div.container.mt-3
que es equivalente al HTML <div class="container mt-3"></div>
. Podemos hacer lo mismo con el atributo id
usando #
. Por ejemplo lo que en HTML es <h1 id="title">Titulo</h1>
en Pug es h1#title Titulo
.
La palabra clave include
en Pug como en include head.pug
indica que los contenidos del archivo head.pug
se van a reemplazar debajo del elemento head
. Por último el block content
debajo del div
indica que vamos a usar herencia y el archivo layout.pug
no es más que una plantilla para posiblemente más de una página distinta.
En head.pug
tenemos las etiquetas requeridas para usar Bootstrap y el título de la página.
meta(charset='utf-8')
meta(name='viewport' content='width=device-width, initial-scale=1, shrink-to-fit=no')
link(rel='stylesheet', href='https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css')
title Hello MVC
El archivo index.pug
es nuestro contenido principal, la UI va a ser la misma que la otra vez: un input de texto, un botón y una tabla. Para heredar la estructura básica del archivo layout.pug
usamos extends layout
en la primer línea y luego especificamos el bloque de contenido.
extends layout
block content
h1 Hello MVC
p Ingresá el ID del usuario que querés buscar.
hr
table
thead
th id
th Nombre
th Email
th Cumpleaños
th Edad
tbody
hr
h4 Más info en
a(href='#') MDN
Podemos ver como quedó, pero primero tenemos que volver al controlador y actualizar la función home()
. Usamos res.render()
en Express para responder al cliente con HTML de una vista. Ya podemos ver nuestra interfaz en http://localhost:3000
.
// controllers/index.js
exports.home = (req, res) => {
res.render('index');
};
Vemos que la tabla necesita algunas clases de Bootstrap.
table.table.table-striped
thead.thead-dark
th id
th Nombre
th Email
Ahora agregamos el formulario para buscar usuarios por ID, lo hacemos entre el párrafo y el primer hr
.
form.form-inline(action='/search' method='get')
div.form-group.mr-sm-3
input.form-control(type='text' name='id' placeholder='ID')
input.form-control.btn.btn-primary(type='submit' value='Buscar')
Se trata de un elemento form
con la clase de Bootstrap form-inline
y con los atributos action
y method
.
Dentro del formulario tenemos un input de tipo texto encerrado en un div
con clases varias de Bootstrap para el formato. Es importante el atributo name
del input ya que vamos a usar el valor de ese atributo para recuperar el valor ingresado. Por último usamos un input de tipo submit
que se muestra como un botón.
Si ponemos un 1 y enviamos el formulario nos envía a la ruta localhost:3000/search?id=1
.
La ruta /search
sale del atributo action
del formulario. Lo que va desde el signo de pregunta al final (?id=1
) es lo que se conoce como un query string. Es una parte especial de la URL que podemos usar en nuestro caso para hacer la query correcta a la base de datos. En Express la query string la podemos encontrar en el objeto req
, específicamente en req.query
.
Tenemos que implementar las funciones que dejamos pendientes en controllers/index.js
. Vamos a usar el modelo User
para realizar las queries a la base de datos. La función que se ejecuta para la primera carga de la página necesita traer todos los usuarios. Podemos usar User.find()
.
// controllers/index.js
exports.home = (req, res) => {
User.find().sort('id').exec((err, users) => {
res.render('index', { users: users });
});
};
Con este código traemos todos los usuarios, los ordenamos por ID y mandamos la vista con un segundo argumento, un objeto que contiene el resultado de la query.
Podemos ver el contenido de este objeto en la vista de la siguiente manera. En index.pug
debajo del primer hr
y antes de la tabla agreguen:
pre= users
Ahora tenemos que manipular un poco el resultado de la query para poder usarlo en la tabla. La función home()
quedaría finalmente así, de esta manera le agregamos la propiedad age
a cada objeto de la colección.
// controllers/index.js
exports.home = (req, res) => {
User.find().sort('id').exec((err, users) => {
for (let user of users) {
user.age = Math.trunc((new Date() - user.birthday) / 31536000000);
}
res.render('index', { users: users });
});
};
Para terminar con el controlador implementamos la función que corresponde a la búsqueda por ID.
// controllers/index.js
exports.search = (req, res) => {
let result = null;
User.findOne({ id: req.query.id }).exec((err, user) => {
if (user != null) {
user.age = Math.trunc((new Date() - user.birthday) / 31536000000);
result = [user];
}
res.render('index', { users: result });
})
}
Esta función se ejecuta para la ruta /search?id=*
(reemplazar el asterisco por lo que haya en el input del formulario). Como dijimos antes eso está en req.query
como la clave es id
en req.query.id
tenemos lo que está a la derecha del igual. Usamos User.findOne()
y si el resultado (user
) es distinto de null
entonces calculamos la edad y lo metemos dentro de un array (result
). Por último enviamos a la vista el resultado en el objeto { users: result }
. Si el resultado de la query es null
porque pusimos algún ID inexistente, entonces la propiedad users
del objeto que se envía a la vista vale null
.
Ahora podemos volver a la vista para armar la tabla y terminar la app.
Hay dos posibilidades, o el controlador le pasa datos en users
a la vista o no le pasa nada (null
). Podemos mostrar la tabla en función de esto con un if ... else
en Pug. Entre los dos hr
de index.pug
.
hr
pre= users
if users
table.table.table-striped
thead.thead-dark
th id
th Nombre
th Email
th Cumpleaños
th Edad
tbody
else
h3 No hay resultados
hr
Que dice básicamente que si users
no es null
muestre la tabla y sino el h3
que dice que no hay resultados.
Ya casi estamos, faltan las filas de la tabla en el tbody
. Podemos usar each ... in
para iterar un array en Pug de modo similar a un ciclo for. También eliminamos el elemento pre
que pusimos antes.
hr
if users
table.table.table-striped
thead.thead-dark
th id
th Nombre
th Email
th Cumpleaños
th Edad
tbody
each user in users
tr
td= user.id
td= user.name
td= user.mail
td= user.birthday.toLocaleDateString('es-AR')
td= user.age
else
h3 No hay resultados
hr
Para cada elemento user
en el array users
creamos un tr
con los cinco td
usando los valores correspondientes. Y listo, la app está terminada. La pushean a un repo en GitHub, la conectan con Heroku configuran la variable MONGODB_URI
como hicieron para hello-database y listo. Pueden ver el resultado final en mi app de Heroku.
Ahora seguimos con hello-crud, CRUD es el acrónimo en inglés para Create Read Update Delete, las cuatro operaciones básicas en una base de datos. En esa guía vamos a hacer la API para una app de tomar notas.
Por otro lado también podemos seguir explorando tecnologías que usamos en desarrollo web como WebSockets en hello-websockets haciendo una sencilla app de chat.
Por último no dejen de ver hello-postgre si son fanáticos de las bases de datos relacionales y SQL.
Escrito el 15 de Julio de 2020 por Santiago Trini