Lendo um arquivo CSV em um array

Lendo um arquivo CSV em um array

Simplificando, um arquivo CSV (Comma-Separated Values - Valores Separados por Vírgula) é um formato de arquivo de texto que utiliza vírgulas para separar valores individuais. Neste tutorial, demonstrarei como utilizar Streams e Node.js para ler arquivos e convertê-los em arrays bidimensionais de strings (Array<Array<string>>). Utilizaremos como exemplo o arquivo de Pokémon.

1. Lendo um arquivo CSV

No Node.js, o módulo fs proporciona duas abordagens para interagir com arquivos: uma síncrona e outra assíncrona, através de fs e fs/promises, respectivamente. A primeira segue o modelo das funções POSIX padrão, enquanto a segunda oferece uma abordagem baseada em promessas. É crucial observar que essas operações não são sincronizadas ou "thread-safe", exigindo cautela com operações concorrentes.

1.1 Utilizando ReadStream para leitura de arquivos CSV

O ReadStream, uma implementação específica de Stream no Node.js, simplifica a manipulação de dados em streaming. Ele permite a leitura de dados de um arquivo de forma eficiente. Vejamos um exemplo prático de como utilizá-lo:

import { createReadStream } from "fs";
 
const csvData = await createReadStream(pathToFile, {
  encoding: "utf-8",
}).toArray();

Esta abordagem gera um array contendo todo o arquivo em uma única linha. Embora simples, não é a mais eficiente para arquivos volumosos, pois carrega o conteúdo inteiro na memória. Para casos de uso onde isso não representa um problema, é possível dividir o arquivo em um array de linhas utilizando o método split:

import { createReadStream } from "fs";
 
const csvData: string[] = await createReadStream(pathToFile, {
  encoding: "utf-8",
}).toArray();
 
const rows = csvData.flatMap((txt) => txt.split(NEW_LINE)).map((line) => line.split(COMMA_DELIMITER));

Para processar o arquivo em partes, ou "chunks", empregamos a classe Transform. Esta classe permite a conversão de chunks de dados do ReadStream em um formato específico. No nosso contexto, transformaremos o buffer em um array de linhas e, subsequentemente, cada linha em um array de colunas:

import { Transform, TransformCallback } from "stream";
 
class LineTransform extends Transform {
  #lastLine = "";
 
  _transform(chunk: Buffer, encoding: BufferEncoding, callback: TransformCallback) {
    let chunkAsString = (this.#lastLine + chunk.toString()).split(NEW_LINE);
    this.#lastLine = chunkAsString.pop() || "";
 
    const lines = chunkAsString.map((line) => line.split(COMMA_DELIMITER));
 
    this.push(lines, encoding);
    callback();
  }
 
  _flush(callback: TransformCallback): void {
    if (this.#lastLine.length) {
      this.push([this.#lastLine.split(COMMA_DELIMITER)]);
    }
    callback();
  }
}

O método _transform manipula um chunk de dados, convertendo-o em um array de linhas antes de encaminhá-lo ao próximo stream, enquanto _flush é chamado ao final da leitura, processando qualquer dado remanescente. É importante considerar que um chunk pode terminar no meio de uma linha, exigindo uma gestão cuidadosa para preservar a integridade dos dados.

Integrando o ReadStream com o LineTransform, é possível ler o arquivo em chunks eficientemente. Destacam-se algumas configurações específicas: habilitar o objectMode para processamento de objetos, ajustar o highWaterMark para controlar o tamanho dos chunks e utilizar o método iterator para iterar sobre os chunks processados.

const csvToRow = new LineTransform({
  objectMode: true,
});
 
const readFileWithReadStream = async (fileFolder: string) => {
  const pathToFile = join(process.cwd(), fileFolder);
  const readStreamTransform = createReadStream(pathToFile, {
    encoding: "utf-8",
    highWaterMark: 512,
  }).pipe(csvToRow);
 
  const streamIterator = readStreamTransform.iterator();
 
  for await (let chunk of streamIterator) {
    // process chunk
  }
};

Espero que este guia tenha sido esclarecedor e proveitoso para suas aplicações em Node.js. Este tutorial ilustra apenas uma faceta das possibilidades oferecidas pela manipulação de arquivos em Node.js, antecipando técnicas mais avançadas e otimizações que serão abordadas em futuros conteúdos.

No Node.js, o módulo fs oferece duas formas de interagir com arquivos: síncrona e assíncrona, através de fs e fs/promises. O primeiro adota o modelo das funções POSIX padrão, enquanto o segundo proporciona uma abordagem assíncrona baseada em promises. É importante notar que as operações não são sincronizadas ou 'thread-safe', exigindo cuidado com operações concorrentes.

1.1 Lendo um arquivo CSV usando readStream

O ReadStream é uma implementação de Stream no Node.js que facilita o trabalho com streaming de dados. Streams podem ser de leitura, escrita ou duplex. O ReadStream permite a leitura de dados de um arquivo. Eis um exemplo de uso para a leitura de arquivos:

import { createReadStream } from "fs";
 
const csvData = await createReadStream(pathToFile, {
  encoding: "utf-8",
}).toArray();

Esta operação resulta em um array contendo o arquivo inteiro em uma única linha. Apesar de ser uma abordagem simples, ela não é eficiente para arquivos grandes, devido ao carregamento de todo o conteúdo na memória. Se isso não for um problema para o seu caso de uso, você pode dividir o arquivo em um array de linhas usando o método split:

import { createReadStream } from "fs";
 
const csvData: string[] = await createReadStream(pathToFile, {
  encoding: "utf-8",
}).toArray();
 
const rows = csvData.flatMap((txt) => txt.split(NEW_LINE)).map((line) => line.split(COMMA_DELIMITER));

Para processar o arquivo em partes, ou chunks, utilizamos a classe Transform. A classe Transform recebe chunks de dados do ReadStream e os converte em um formato específico. No nosso caso, transformaremos o buffer em um array de linhas e cada linha em um array de colunas:

import { Transform, TransformCallback } from "stream";
 
class LineTransform extends Transform {
  #lastLine = "";
 
  _transform(chunk: Buffer, encoding: BufferEncoding, callback: TransformCallback) {
    let chunkAsString = (this.#lastLine + chunk.toString()).split(NEW_LINE);
    this.#lastLine = chunkAsString.pop() || "";
 
    const lines = chunkAsString.map((line) => {
      return line.split(COMMA_DELIMITER);
    });
 
    this.push(lines, encoding);
    callback();
  }
 
  _flush(callback: TransformCallback): void {
    if (this.#lastLine.length) {
      this.push([this.#lastLine.split(COMMA_DELIMITER)]);
    }
    callback();
  }
}

Basicamente, o método _transform recebe um chunk de dados, transforma em um array de linhas e o envia para o próximo stream. O método _flush é chamado quando não há mais dados para serem lidos, e envia o último chunk. Um detalhe importante é que o chunk pode ser cortado no meio de uma linha. Por isso, suponho que a última linha de cada chunk possa estar incompleta e, consequentemente, precisa ser concatenada com o início do próximo chunk para garantir a integridade dos dados. Para lidar com isso, utilizo a propriedade #lastLine para armazenar o final do chunk atual, que será então combinado com o início do chunk seguinte.

Agora, combinando o ReadStream com o LineTransform, consigo ler o arquivo em chunks. Há alguns detalhes que gostaria de ressaltar: na linha 2, defino o objectMode como true, o que indica que o stream processará objetos. Na linha 9, altero o valor do highWaterMark para 512 bytes. Isso significa que o stream receberá chunks de 512 bytes, diferentemente do valor padrão que é 16KB. Por fim, na linha 12, emprego o método iterator para iterar sobre os chunks recebidos.

const csvToRow = new LineTransform({
  objectMode: true,
});
 
const readFileWithReadStream = async (fileFolder: string) => {
  const pathToFile = join(process.cwd(), fileFolder);
  const readStreamTransform = createReadStream(pathToFile, {
    encoding: "utf-8",
    highWaterMark: 512,
  }).pipe(csvToRow);
 
  const streamIterator = readStreamTransform.iterator();
 
  for await (let chunk of streamIterator) {
    // process chunk
  }
};

Espero que este tutorial tenha sido esclarecedor e útil em suas aplicações de Node.js.