Subir un archivo con NodeJS y Express

Subir un archivo es una tareas básica que a veces cuesta más de lo normal en aprender cuando estamos iniciando. Aquí un pequeño tutorial.

David Lay

14 minute read

No sé ustedes, pero siempre hay un par de cosas que nunca recuerdo exactamente como hacer independiente de que framework o lenguaje esté usando. Una de ellas es subida de archivos. Siempre depende de alguna librería o de algún patrón o de permisos especiales o algo que se me olvida.

Además, es un ejemplo muy útil para demostrar algunas funcionalidades básicas del framework, en este caso, de NodeJS con Express, como el manejo de promesas y al lectura de directorios.

El Código

El código de este ejercicio lo pueden encontrar en mi GitLab o bien, puedes clonar el repositorio directamente con la siguiente instrucción

$ git clone https://gitlab.com/davidlaym.com/blog-examples/nodejs_upload_file.git
Cloning into 'nodejs_upload_file'...

Asegúrate de instalar las dependencias luego con:

$ npm install
added 76 packages from 59 contributors in 2.485s

Para ejecutarlo, utiliza:

$ npm start
node ./bin/www

Este código representa al proyecto ya terminado. A continuación iremos paso por paso construyendo este proyecto.

0 - Pre-requisitos

Para este tutorial necesitas tener instaladas las siguientes dependencias:

  • NodeJS en su última versión LTS (v8 al momento de redacción de este tutorial)
  • NPM en su última versión (v5 al momento de redacción de este tutorial)
  • GIT

1 - Iniciando repositorio y proyecto

Partiremos por crear una carpeta e inicializarla como repositorio GIT:

$ mkdir nodejs-upload-file
$ cd nodejs-upload-file
$ git init .
Initialized empty Git repository in <your folder>

De ahora en adelante asumiré que todos los comandos son ejecutados desde la carpeta del proyecto

Luego generaremos un proyecto de express con la utilidad express-generator que el mismo proyecto de express ha desarrollado (es bien básica, la verdad):

$ npx express-generator --view=ejs .
...
   install dependencies:
     $ npm install

   run the app:
     $ npm start

Si te fijas, usamos npx que es una utilidad de NPM para descargar y al mismo tiempo ejecutar una herramienta CLI que está disponible en el registro NPM sin necesidad de instalarla de manera global. npx es una herramienta relativamente nueva al momento de escribir este post, por lo que me aseguro de destacarlo.

Aun no instalaremos dependencias ya que necesitamos hacer algunos ajustes, pero es un gran momento para hacer el primer commit con el proyecto recién generado

2 - Limpieza de dependencias

Si bien el código que genera express-generator es bastante mínimo, nosotros requerimos de aun menos cosas, así que eliminaremos serve-favicon y cookie-parser

Los dependencies de nuestro package.json quedan de la siguiente forma:

"dependencies": {
  "body-parser": "~1.18.2",
  "debug": "~2.6.9",
  "ejs": "~2.5.7",
  "express": "~4.15.5",
  "morgan": "~1.9.0"
}

Luego, vamos a app.js y eliminamos las referencias a estos paquetes que acabamos de descartar. Las líneas a eliminar son las siguientes:

var cookieParser = require('cookie-parser');

var favicon = require('serve-favicon');

// app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));

Finalmente, vamos a views/index.ejs y cambiamos el título por algo más adecuado para nuestro ejercicio, como Express file Upload Demo o algo por el estilo

Luego podemos hacer la instalación de dependencias con:

$ npm install
added 76 packages from 59 contributors in 2.485s

Y ya podemos ver el ejemplo en toda su magnificencia si ejecutamos nuestro proyecto:

$ npm run start
...
node ./bin/www

Y navegamos hacia https://localhost:3000 donde veremos la página de inicio que se debería ver como la siguiente imagen:

Imagen del Home

Si todo ha resultado bien, te invito a realizar otro commit.

3 - Formulario de subida

El código generado por express-generator viene con dos rutas routes/home.js y routes/users.js nosotros usaremos home para tener una página de inicio, pero no usaremos el de users ya que no tendremos usuarios en esta aplicación, por lo que le cambiaremos el nombre a upload.js

$ mv routes/users.js routes/upload.js
$

El contenido del archivo obviamente también lo cambiamos a:

var express = require('express')
var router = express.Router()

router.get('/', function (req, res, next) {
  res.render('uploadForm.ejs')
})

module.exports = router

Definimos un endpoint GET /upload que realiza un render de un formulario que crearemos más adelante, por ahora terminamos las modificaciones ajustando las referencias de app.js para que quede de la siguiente manera (se muestran las lineas antiguas comentadas sobre las nuevas)

// var users = require('./routes/users')
var upload = require('./routes/upload')

// app.use('/users', users)
app.use('/upload', upload)

Las líneas antiguas están comentadas aquí de modo ilustrativo. No seas desprolijo y elimina esas líneas de tu código! Dejar código muerto en comentarios es como que un cocinero no lavara sus platos, excepto claro, el riesgo de muerte.

Y finalmente el formulario html que debemos crear como archivo nuevo en views/uploadForm.ejs con el siguiente contenido:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link rel='stylesheet' href='/stylesheets/style.css' />
  <title>Upload form</title>
</head>
<body>
  <h1>Upload Form</h1>
  <form name="upload-form" action="upload" method="post" enctype="multipart/form-data">
    <input type="file" name="attachment" id="attachment">
    <button type="submit">Send</button>
  </form>
  <br/>
  <br/>
  <a href="/">Go Back</a>
</body>
</html>

Con esta vista definimos que el formulario enviará el archivo hacia POST /upload con el nombre de attachment, así que nos queda crear este endpoint en nuestro controlador.

Pero antes, veamos como funciona este formulario, solo para sentir algo de retribución por un trabajo bien hecho. Ejecuta el siguiente comando:

$ npm run start
...
node ./bin/www

Y navegamos hacia localhost:3000/upload donde deberías ver un formulario como el siguiente:

Imagen del formulario

4 - Acción de recepción para el formulario

Por ahora el formulario envía el archivo a ninguna parte. Arreglemos eso.

Necesitaremos crear una ruta POST /upload que es lo que habíamos definido en el HTML del formulario.

A diferencia el método GET que hicimos anteriormente, esta vez debemos recibir el archivo que envía el formulario. Esto no tiene soporte nativo en express, ya que intenta ser lo más pequeño posible y debemos ir agregando la funcionalidad a medida que la vamos necesitando mediante módulos.

Uno de los puntos más comunes de extensión para express son los Middleware que son básicamente funciones que reciben ciertos parámetros establecidos y retornan algo establecido. Estas funciones se van apilando una a otra y van modificando los objetos req y res y el último en la cadena el el método que uno especifica como manejador. Eso es una explicación sencilla de un párrafo y es muy simplista, pero es lo básico.

El módulo que utilizaremos es multer y se encarga justamente de interpretar los datos que envía el formulario y generar un archivo temporal donde nosotros le indiquemos que contiene exactamente lo que el usuario ha enviado.

Lo instalamos con la siguiente instrucción:

$ npm i- S multer
$

i es por “install” y -S por “save” para guardar el módulo en el archivo packages.config (se supone que -S ya no es necesario, pero viejas costumbres)

Una vez instalado el módulo, lo requerimos en el archivo routes/upload.js:

--- routes/upload.js
+++ routes/upload.js
@@ -1,1 +1,4 @@
var express = require('express')
+ var multer = require('multer')({
+   dest: 'public/uploads'
+ })

El código realiza un require a multer y realiza inmediatamente la configuración para que almacene los archivos temporales en public\uploads

Con eso listo, necesitamos ahora usar este middleware. Los middleware se pueden configurar de manera global, para un conjunto de rutas o para una ruta en específico. Middleware “pesados” como este que realizan escritura a disco y procesamientos es mejor configurarlos exclusivamente para las rutas que los necesiten.

Bajo nuestra ruta previa agregamos nuestra ruta nueva:

--- routes/upload.js
+++ routes/upload.js
@@ -13,2 +13,4 @@
+ router.post('/', [multer.single('attachment')], function (req, res, next) {
+   res.render('uploadOK.ejs')
+ })

module.exports = router

Aun nos falta, pero no quiero abrumar aun con muchos cambios de golpe. Veamos que es lo que agregamos.

Puedes ver que como segundo parámetro en vez de una nuestra función colocamos un arreglo con multer.single y un string que dice attachment. La segunda posición para argumentos a las rutas de express puede ser un middleware o un arreglo de middleware. Incluso cuando es uno a mi me gusta especificarlo con un arreglo para distinguirlo bien. Luego, la instrucción multer.single hace uso del módulo multer para configurar un middleware que recibe un solo archivo y finalmente el string attachment es el nombre del campo del formulario que multer deberá buscar que tendrá el contenido del archivo.

Por defecto multer dejará el archivo en la carpeta que especificamos y generará un nombre de archivo aleatorio. Luego de escrito, modificará req para agregarle una propiedad req.file que podemos usar para consultar este nombre, la ruta y otra metadata del archivo y realizar mayor procesamiento.

Notar también que estamos realizando un res.render a un formulario de OK que aun no hemos creado. Lo crearemos más adelante.

En particular, nos interesa el nombre original que el archivo tenía según lo que informe el formulario, para usar el mismo nombre en disco (ya que es un ejemplo) pero en una aplicación real puede ser utilizado para guardar un registro en la base de datos con esta metadata y mantener en disco el nombre aleatorio (que tiene ciertas garantías de ser un nombre único)

Para extraer estos datos y mover el archivo a su posición final, haremos las siguientes modificaciones. Ahora, son varias, no te asustes, las explicaremos línea a línea

--- routes/upload.js
+++ routes/upload.js
@@ -1,4 +1,6 @@
var express = require('express')
+ var fs = require('fs')
+ var path = require('path')

var multer = require('multer')({
  dest: 'public/uploads'
})

@@ -15,3 +15,4 @@
router.post('/', [multer.single('attachment')], function (req, res, next) {
+ var {fileName} = storeWithOriginalName(req.file)
-  res.render('uploadOK.ejs')
+  res.render('uploadOK.ejs', {fileName})
})

@@ -20,3 +15,4 @@
+function storeWithOriginalName (file) {
+  var fullNewPath = path.join(file.destination, file.originalname)
+  fs.renameSync(file.path, fullNewPath)
+
+  return {
+    fileName: file.originalname
+  }
+}

module.exports = router

En el primer bloque agregamos fs y path como dependencias (son módulos nativos de nodejs, no es necesario agregarlos con npm) que proveen facilidades para trabajar con archivos (fs) y rutas (path).

En el segundo bloque, agregamos una línea para extraer el nombre de archivo (notar el uso de un de-constructor, es innecesario en este ejemplo, podríamos solo retornar el string, pero me he acostumbrado a usarlos por defecto ya que en general permite una extensibilidad más alta del retorno para una función). En res.remder estamos haciendo uso de este nombre para entregarlo a la vista de OK para que pueda desplegar un mensaje de éxito.

En el tercer bloque creamos la función que extrae el nombre desde req.file y mueve el archivo con renameSync para que use el “nombre real” que el formulario informa.

Generemos nuestra página de éxito para que veamos el flujo completo de upload. Agregar un nuevo archivo en views/uploadOK.ejs con el siguiente contenido:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link rel='stylesheet' href='/stylesheets/style.css' />
  <title>File received OK</title>
</head>
<body>
  <h1>Success!</h1>
  <p>File <a href="/uploads/<%=fileName%>" target="_blank"><%=fileName%></a> was received correctly!</p>
  <br/>
  <br/>
  <a href="upload">Go Back</a>
</body>
</html>

Recuerda re-iniciar el servidor para que podamos ver los cambios

$ npm run start
...
node ./bin/www

Al subir un archivo por el formulario deberías ver el siguiente mensaje de éxito:

Imagen de éxito

Bueno, ya terminamos, no?

NO! este código es un asco! tenemos un post que envía directo una vista y además tenemos código síncrono que nos bloqueará el hilo mientras procesa el renombrado. En nodejs queremos aprovechar al máximo las capacidades asíncronas, así que continuemos

5 - Post - Redirect - Get

Arreglemos primero algo sencillo. Tenemos un post que está directamente entregando una respuesta HTTP con cuerpo. Esto es un anti-patrón debido a que los navegadores van a re-intentar el POST si el usuario hace una recarga de la página y tendremos un archivo duplicado, además que el POST quedará en el historial del navegador para ser ejecutado quizás cuando en el futuro cuando menos lo esperemos.

Para reparar esto, se implementa el patrón POST-Redirect-GET en la cual se establece que el POST en vez de responder con un status 200 y contenido HTML, responde un Redirect 302 (o 301) hacia un GET que tiene el mensaje de éxito. Cuando un navegador recibe un redirect como respuesta a un POST, el navegador no guarda en el historial el POST sino que directamente el GET hacia donde fue redirigido, lo que evita el inconveniente por completo.

En este caso, al procesar el archivo, realizaremos un redirect hacia GET /upload/success con un parámetro query fileName que contenga el nombre del archivo subido que luego podemos rescatar y desplegar en la vista. Normalmente en un sitio de verdad, este parámetro sería almacenado en al sesión en vez de usar el query y borrado una vez ejecutada la vista para que no se despliegue nuevamente, pero esto es solo un ejercicio y no estamos usando sesiones.

--- routes/upload.js
+++ routes/upload.js
@@ -20,4 +20,5 @@
router.post('/', [multer.single('attachment')], function (req, res, next) {
  var {fileName} = storeWithOriginalName(req.file)
+  var encoded = encodeURIComponent(fileName);
-  res.render('uploadOK.ejs', {fileName})
+  res.redirect(`/upload/success?fileName=${encoded}`)
}))

Notar que agregamos una línea para transformar el nombre de archivo según codificación URI para que no tengamos problemas con caracteres no-ascii en nuestro parámetro. Esto es exclusivamente debido a que lo estamos enviando por query.

Finalmente, agregamos la ruta GET /upload/success al archivo routes/upload.js para enviar la respuesta luego del redirect

router.get('/success', function (req, res, next) {
  var {fileName} = req.query
  res.render('uploadOK.ejs', {fileName})
})

Como puedes ver, aquí rescatamos el parámetro query fileName y se lo entregamos a la vista para que lo pueda desplegar

Revisa que todo esté funcionando como antes y realiza un commit.

6 - Asincronía

En nuestra función storeWithOriginalName estamos realizando un renombrado del archivo que multer genera para dejarlo con el nombre que el formulario informa como nombre original. Esto lo estamos haciendo con el método renameSync que bloquea la ejecución hasta que la operación de disco está completa, y para este caso de ejercicio está bien, pero para una aplicación de verdad sería un problema. Por eso vamos a corregirlo y usar su función hermana rename, pero para ello vamos a necesitar re-estructurar algo de nuestro código.

@@ -1,6 +1,7 @@
var express = require('express')
var fs = require('fs')
var path = require('path')
+ var util = require('util')

var multer = require('multer')({
  dest: 'public/uploads'

Primero, importamos util que es un módulo nativo de nodejs y por lo tanto no necesitamos instalarlo con NPM. Este módulo tiene varias funciones de utilidad para distintos propósitos, nosotros utilizaremos una en particular: promisify que convierte una función que acepta callbacks hacia una función que retorna promesas.

Los callbacks son la forma más antigua en nodejs de soportar asincronía, pero está en desuso debido a que hace muy compleja la escritura y lectura del código, especialmente cuando hay varias cosas ocurriendo de manera condicional en respuesta a un evento asíncrono

Las promesas son una sintaxis distinta que tiene soporte nativo en nodejs desde hace un buen tiempo, pero muchas de las funciones nativas de nodejs aun exponen la API con callbacks para otorgar retro-compatibilidad.

Una tercera sintaxis es la de async/await que no veremos en este ejemplo debido a que aun no estoy personalmente 100% cómodo con ella aun y hay algunos detalles que aun no aprendo bien (especialmente el control de errores), pero no es difícil de implementar una vez has convertido a promesas.

El siguiente paso entonces, es convertir storeWithOriginalName para que utilice rename y retorne una promesa:

function storeWithOriginalName (file) {
  var fullNewPath = path.join(file.destination, file.originalname)
  var rename = util.promisify(fs.rename)

  return rename(file.path, fullNewPath)
    .then(() => {
      return file.originalname
    })
}

Esta vez no quise utilizar el formato diff debido a que son muchas líneas las que cambian, solo reemplaza la función completa.

En la tercera línea usamos util.promisify para que fs.rename sea convertido de callbacks a promesas y en la quinta línea ejecutamos rename e inmediatamente anidamos una continuación con .then que toma el resultado de rename, lo descarta y en vez retorna nuevamente el nombre original del archivo. Esto es debido a que rename en realidad no retorna nada y es conveniente que las promesas retornen algun dato que sea relevante para el procesamiento posterior de sus resultados. En este caso el nombre original nos sienta muy bien.

Tercer paso es cambiar la acción POST que recibe el archivo para ajustarla a storeWithOriginalName y su nueva interface de promesa:

router.post('/', [multer.single('attachment')], function (req, res, next) {
  return storeWithOriginalName(req.file)
    .then(encodeURIComponent)
    .then(encoded => {
      res.redirect(`/upload/success?fileName=${encoded}`)
    })
    .catch(next)
})

Nuevamente, reemplazar la función completa, muchas líneas cambian como para hacer un diff.

Luego de terminar storeWithOriginalName anidamos una continuación que recibe el nombre original del archivo y se lo entrega a encodeURIComponent quien retorna el nombre codificado y en una segunda continuación tomamos ese nombre codificado y enviamos de vuelta inmediatamente un redirect.

La sintaxis para encodeURIComponent aquí puede ser extraña, pero considera que encodeUriComponent es una función que acepta un string y retorna un string de vuelta, y then en este caso justamente necesita una función que acepte un string. El retorno se lo pasará a la siguiente continuación

Y eso es todo, ya tenemos nuestra acción POST completamente asíncrona y podemos ir a dormir con la conciencia tranquila… antes si, hagan un commit!

7 - Bonus Track - Listado de archivos

Ya arreglados los problemas, realicemos algo entretenido para cerrar. Generemos una ruta completamente nueva para listar los archivos existentes y arreglemos un poco la navegación del sitio ya?

Bueno, eso queda como ejercicio para el lector, si tienen problemas implementándolo, está todo en el repositorio que les compartí al inicio del post para que le des una mirada

comments powered by Disqus