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.
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:
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:
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:
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
Share this post
Twitter
Google+
Facebook
Reddit
LinkedIn
StumbleUpon
Pinterest
Email