Introducción a node.js y express
node
Aunque node.js permite la ejecución de cualquier tipo de aplicación javascript, su uso principal en la actualidad es el de servir a través de servicio http y https aplicaciones web.
El siguiente código muestra la típica aplicación “Hola mundo” ofrecido como página web a través de un servidor http que escucha en el puerto ip 8080:
const http = require('http'); const server = http.createServer((req, res) => { res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); res.end('Hola mundo '); }); server.listen(8080, "127.0.0.1", () => { console.log(`servidor funcionando en http://localhost:8080/`) })
La aplicación habrá sido creada ejecutando, en la carpeta de la aplicación y desde una consola, el comando npm init y a continuación npm install http, para añadir el paquete http al proyecto. Si en el archivo package.json en la propiedad main hemos indicado que el archivo de inicio es index.js, entonces el código anterior será el que debemos incluir en dicho archivo. Para ejecutar la aplicación no tendremos más que ejecutar node index.js.
En la primera línea del código se ve como se importa un paquete externo que previamente ha sido instalado con npm install. En la segunda línea se crea el servidor http y en la tercera se pone a escuchar en el puerto 8080. En la segunda línea es cuando se crea el servidor invocando el método createServer del paquete http. Como único argumento vemos que aparece una función anónima con dos argumentos que es llamada de callback. Javascript solo permite un hilo de ejecución (single-threaded) y la forma en que node.js emula un entorno asíncrono es vía la gestión de eventos que permiten ejecutar el código de una función denominada de callback cuando dicho evento es disparado. De esta forma el código de la función que aparece como argumento en el método createServer es ejecutado de forma automática cuando llega una petición a través del puerto indicado al servidor. Los dos argumentos de la función de callback representan a qué es lo que se pide, objeto request, y qué es lo que se responde, objeto response.
Por lo tanto, cuando llega una petición a través del puerto 8080, el servidor responde con un código de estado 200 (El código de respuesta de estado satisfactorio
HTTP 200 OK indica que la solicitud ha tenido éxito), en las cabeceras de la página indica el tipo MIME del contenido (en el ejemplo text/plain)
y el contenido de la página que es Hola mundo. El método end del objeto response indica que se ha finalizado de enviar
el contenido de la página. En la siguiente imagen se ve el proyecto en ejecución lanzado desde Visual Studio Code junto con el código de
index.js
Y en la siguiente se ve lo mostrado en un navegador en respuesta a una petición al servidor. Se ve también las cabeceras intercambiadas durante la petición en las que se puede ver tanto el tipo MIME de la información devuelta como el código de estado de la respuesta (200 OK)
O con el comando curl:
$ curl -I http://localhost:8080 HTTP/1.1 200 OK Content-Type: text/plain Date: Tue, 05 Oct 2021 10:34:26 GMT Connection: keep-alive Keep-Alive: timeout=5 Content-Length: 11
Hemos conseguido poner en marcha un servidor, pero que sirve únicamente un solo contenido. Una aplicación web está formada por varias páginas que además pueden enviar datos para su proceso al servidor y este contestar con contenido dinámico en función de estos datos recibidos. Se precisa, por lo tanto, implementar un enrutado que mostrará un contenido u otro en función de la url requerida.
const http = require('http'); const url = require('url'); const fs = require("fs"); const server = http.createServer((req, res) => { let pagina = (new URL(decodeURI(req.url), "http://localhost:8080")).pathname; console.log(pagina); switch (pagina) { case "/about": res.statusCode = 200; res.setHeader("Content-type", "text/plain"); res.end("Prueba de routing") break; case "/": res.statusCode = 200; res.setHeader("Content-type", "text/plain"); res.end("Home"); break; case "/data": fs.readFile("data.html", function(err, data) { if (err) { res.statusCode = 500; res.end(`Error al leer el archivo: ${err}.`); } else { res.setHeader('Content-type', 'text/html'); res.end(data); } }); break; default: //error res.writeHead(404, { "Content-type": "text/html" }); res.end("Página no encontrada"); } }); server.listen(8080, "127.0.0.1", () => { console.log(`servidor funcionando en http://localhost:8080/`) })
Mediante el paquete url y el constructor de la clase URL y su propiedad pathname extraemos el nombre de la página que es comparado en el bloque switch. En la entrada /data podemos ver como mediante el paquete fs, que nos da acceso al sistema de archivos de la máquina servidora, podemos cargar el contenido de un archivo y como mediante la función de callback del método readFile podemos enviar el contenido del archivo o mandar un mensaje de error si no fue posible recuperarlo. Tanto el paquete url, como el paquete fs, deben importarse mediante require al comienzo del módulo y ambos son internos de node.js lo que significa que no deben ser importados con npm import ni añadidos al archivo de dependencias package.json. El paquete url permite separar una dirección web (url) en sus partes: protocol, hostname, port, pathname, search, hash … El paquete fs nos da acceso al sistema de archivos de la máquina servidora, ofreciendo métodos para cualquier operación sobre el mismo: creación, borrado, escritura, lectura … tanto de archivos como de directorios.
Vamos a modificar el anterior para que en el caso de servir la página /data, si se pide mediante el método GET, devuelva una página de formulario formulario.html y si se pide mediante el método POST, devuelva los datos enviados por este formulario procesados
El código de la página formulario.html podría ser:
<!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>Prueba GET y POST</title> </head> <body> <form action="" method="post"> <label for="txtNombre">nombre:</label><input type="text" name="nombre" id="txtNombre"><br> <label for="txtPassword">password:</label><input type="password" name="password" id="txtPassword"><br> <input type="submit" value="enviar"> </form> </body> </html>
El de index.js quedaría:
const http = require('http'); const url = require('url'); const fs = require("fs"); const server = http.createServer((req, res) => { let pagina = (new URL(decodeURI(req.url), "http://localhost:8080")).pathname; switch (pagina) { case "/about": // código para página about break; case "/": // código para página home break; case "/data": if (req.method == "GET") { fs.readFile("formulario.html", function(err, data) { if (err) { res.writeHead(404, { "Content-type": "text/html" }); res.end("Página no encontrada"); } else { res.setHeader('Content-type', 'text/html'); res.end(data); } }); break; } else { if (req.method == "POST") { var postData = ''; req.on('data', function(bloque) { postData += bloque; }).on('end', function() { let aParams = new URLSearchParams(postData); res.writeHead(200, { "Content-type": "text/html" }); res.write("<p>Recibido:</p>"); res.write("<p>nombre:" + aParams.get("nombre") + "</p>"); res.end("<p>password:" + aParams.get("password") + "</p>"); }); break; } else { res.writeHead(502, { "Content-type": "text/html" }); res.end("método no soportado" + req.method); } } default: //error res.writeHead(404, { "Content-type": "text/html" }); res.end("Página no encontrada"); } }); server.listen(8080, "127.0.0.1", () => { console.log(`servidor funcionando en http://localhost:8080/`) })
Se usa la propiedad method del objeto req para determinar el método en la solicitud. Cuando se pide la página escribiendo la dirección en la caja de url del navegador, el servidor recibirá una solicitud GET. Cuando el formulario es enviado (submit) al servidor este recibirá una solicitud POST porque es el método que aparece en la definición de form. La petición al servidor mediante el método POST se realiza encapsulando en body los parámetros y como estos pueden tener gran tamaño, por ejemplo, si se está subiendo un archivo, se reciben por bloques.
El evento on se desencadena cada vez que un bloque ha llegado. Se ve como se va añadiendo cada bloque a la variable postData. Para evitar ataque de denegación de servicio se debería preguntar por el tamaño que va tomando esta variable para no permitir que crezca de forma descontrolada. No habría que preguntar más que por postData.length.
Se usa el objeto URLSearchParams para tener acceso a los parámetros enviados de forma fácil.
Esta es la forma básica de trabajar en node.js con una aplicación. Se ve enseguida que cuando la aplicación sea más compleja el código se complicará de forma exponencial. Más adelante veremos el paquete express que estructura y modulariza la aplicación facilitándonos mucho su desarrollo.
Una forma de estructurar el código y de permitir su reutilización es la utilización de módulos. Los paquetes que se han usado en los anteriores ejemplos son módulos de código hechos por terceros para reutilizar su código. Un módulo no es más que un archivo de código que contiene definiciones de variables, funciones y clases que son accesibles una vez importados. Para que una función declarada dentro de un módulo pueda ser usada debe ser exportada en el propio módulo. ¿Cómo? Pues simplemente añadiéndola al objeto predefinido exports.
Como ejemplo, vamos a codificar un módulo que guardaremos en un archivo filter.js que contenga una función que reciba una cadena y nos devuelva true o false según que la cadena pasada como argumento se corresponda con un nif válido de un ciudadano español.
const letras = "TRWAGMYFPDXBNJZSQVHLCKE"; exports.esNif = cadena => { if (typeof cadena != "string") throw new Error("el argumento cadena debe ser un string"); return (new RegExp("^(\\d{8})([" + letras + "])$")).test(cadena) && (letras[(RegExp.$1 % 23)] == RegExp.$2); }
Para ser usado debemos importarlo. Suponiendo que el archivo esté en la misma carpeta que el js que lo usa, el siguiente ejemplo mostraría como usarlo.
const filter = require("./filter.js"); console.log(filter.esNif(“00000000T”));
Todo lo que se ponga en exports se convertirá en propiedad del módulo cuando se importe.