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 oferece 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:

TYPESCRIPT
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 grandes, pois carrega todo o conteúdo 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:

TYPESCRIPT
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:

TYPESCRIPT
1import { Transform, TransformCallback } from "stream";
2
3class LineTransform extends Transform {
4 #lastLine = "";
5
6 _transform(chunk: Buffer, encoding: BufferEncoding, callback: TransformCallback) {
7 let chunkAsString = (this.#lastLine + chunk.toString()).split(NEW_LINE);
8 this.#lastLine = chunkAsString.pop() || "";
9
10 const lines = chunkAsString.map((line) => line.split(COMMA_DELIMITER));
11
12 this.push(lines, encoding);
13 callback();
14 }
15
16 _flush(callback: TransformCallback): void {
17 if (this.#lastLine.length) {
18 this.push([this.#lastLine.split(COMMA_DELIMITER)]);
19 }
20 callback();
21 }
22}

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.

TYPESCRIPT
1const csvToRow = new LineTransform({
2 objectMode: true,
3});
4
5const readFileWithReadStream = async (fileFolder: string) => {
6 const pathToFile = join(process.cwd(), fileFolder);
7 const readStreamTransform = createReadStream(pathToFile, {
8 encoding: "utf-8",
9 highWaterMark: 512,
10 }).pipe(csvToRow);
11
12 const streamIterator = readStreamTransform.iterator();
13
14 for await (let chunk of streamIterator) {
15 // process chunk
16 }
17};

Espero que este guia tenha sido esclarecedor e útil 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:

TYPESCRIPT
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:

TYPESCRIPT
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:

TYPESCRIPT
1import { Transform, TransformCallback } from "stream";
2
3class LineTransform extends Transform {
4 #lastLine = "";
5
6 _transform(chunk: Buffer, encoding: BufferEncoding, callback: TransformCallback) {
7 let chunkAsString = (this.#lastLine + chunk.toString()).split(NEW_LINE);
8 this.#lastLine = chunkAsString.pop() || "";
9
10 const lines = chunkAsString.map((line) => {
11 return line.split(COMMA_DELIMITER);
12 });
13
14 this.push(lines, encoding);
15 callback();
16 }
17
18 _flush(callback: TransformCallback): void {
19 if (this.#lastLine.length) {
20 this.push([this.#lastLine.split(COMMA_DELIMITER)]);
21 }
22 callback();
23 }
24}

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.

TYPESCRIPT
1const csvToRow = new LineTransform({
2 objectMode: true,
3});
4
5const readFileWithReadStream = async (fileFolder: string) => {
6 const pathToFile = join(process.cwd(), fileFolder);
7 const readStreamTransform = createReadStream(pathToFile, {
8 encoding: "utf-8",
9 highWaterMark: 512,
10 }).pipe(csvToRow);
11
12 const streamIterator = readStreamTransform.iterator();
13
14 for await (let chunk of streamIterator) {
15 // process chunk
16 }
17};

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