Escribir y leer archivos grandes con Node.js - Streams (Serie Node.js archivos grandes - Parte 1)

Ariel Alvarado | Junio 19, 2020


Un problema que me encontré hace poco es que para trabajar con archivos grandes en Node.js es necesario realizar un proceso distinto al utilizado en la mayoría de los tutoriales (incluso en mi post anterior). Por esto decidí mostrar la mejor manera de realizar estos procesos con archivos grandes

El problema

Primero demostrare el problema. Tengo un archivo de texto de 665MB aproximadamente, quiero reemplazar todas la ocurrencias del número 0 por la palabra Cero. Veamos como resolverlo:

read-large-files.js
const path = require("path");
const fs = require("fs");

const rutaArchivo = path.join(__dirname, "big-file.txt");

fs.readFile(rutaArchivo, "utf8", (err, res) => {
  if (err) {
    throw err;
  }
  // reemplazamos g por G
  const textoReemplazado = res.replace(/0/g, "Cero");
  console.log(textoReemplazado);
});

El script funciona de maravilla con archivos pequeños, pero con un archivo de texto de 500MB se presenta un terrible error (JavaScript heap out of memory):

Out of memory exception

Una solución posible es aumentar la memoria al script, esto se logra con el flasg --max-old-space-size, un ejemplo de su uso para aumentar la memoria a 4GB:

$ node --max-old-space-size=4096 mi-script.js

Sin embargo, esta solución non es escalable, si necesitamos trabajr con archivos aún más grandes necesitariamos agregarm ucha más memoria. Oh no! y ahora... Streams al rescate!!!

La Solución, streams

Según la documentación de Node.js, stream es una interfaz para trabajar con una FLUJO de datos. Estos flujos pueden ser de entrada o de salida.

Veamos como se puede utilizar un flujo de entrada (lectura) para ir reemplazando por piezas el contenido del archivo

read-large-files-1.js
const path = require("path");
const fs = require("fs");

const rutaDelArchivo = path.join(__dirname, "big-file.txt");

const streamDeLectura = fs.createReadStream(rutaDelArchivo, {
  autoClose: true,
  // start y end son utilizados cuando se quiere leer solamente una parte del archivo
  // start: 0,
  // end: 100
});
// Ahora esperamos los eventos más importantes, data, error y end

// chunk se traduciría a pedazo o pieza, es una porción de los datos leidos. chunk es un Buffer.
// data es el evento que se lanza cada que llega una pieza del archivo
streamDeLectura.on("data", chunk => {
  const textoParcial = chunk.toString();
  const textoParcialReemplazado = textoParcial.replace(/0/g, "Cero");
  console.log(textoParcialReemplazado);
});

// error es el evento que se lanza cuando se produce un error
streamDeLectura.on("error", err => {
  console.log("Ha ocurrido un error\n", { err });
});

// el evento end se lanza cuando se ha finalizado la lectura del archivo
streamDeLectura.on("end", () => {
  console.log("\nFINALIZADO\n");
});

Listo, ahora finalmente el proceso termina sin interrupciones. La diferencia es que el primer script tiene que almacenar todo en memoria y luego ejecutar el reemplazo, mientras que en el segundo ejemplo se va reemplazando por piezas.

Ahora, escribiremos el resultado a otro archivo en vez de imprimirlo con console.log.

read-write-large-files.js
const path = require("path");
const fs = require("fs");

const rutaDelArchivoParaLeer = path.join(__dirname, "big-file.txt");

const streamDeLectura = fs.createReadStream(rutaDelArchivoParaLeer, {
  autoClose: true,
});

const rutaDelArchivoParaEscribir = path.join(__dirname, "big-file-write.txt");
// creamos un stream de escritura
const streamDeEscritura = fs.createWriteStream(rutaDelArchivoParaEscribir);

streamDeLectura.on("data", chunk => {
  const textoParcial = chunk.toString();
  const textoParcialReemplazado = textoParcial.replace(/0/g, "Cero");
  // escribimos el texto reemplazado al stream de escritura
  streamDeEscritura.write(textoParcialReemplazado);
});

// manejamos los errores del stream de escritura
streamDeEscritura.on("error", err => {
  console.log("Ha ocurrido un error en la escritura del archivo\n", { err });
});

streamDeLectura.on("error", err => {
  console.log("Ha ocurrido un error en la lectura del archivo\n", { err });
});

streamDeLectura.on("end", () => {
  // cerramos el stream de escritura
  streamDeEscritura.close();
  console.log("\nFINALIZADO\n");
});

Utilizamos el método write en el stream de escritura para escribir por piezas, luego cerramos el stream de escritura cuando todo ha finalizado.

Y eso es todo por ahora, keep coding!!!