Ler arquivo(s) texto com Express Node.js

O objetivo deste artigo é mostrar como criar um endpoint, GET /arquivos, que retorna o conteúdo de um ou mais arquivos .txt. O framework utilizado será o Express que roda com o Node.js.

Antes de entrar no mundo do Express, se olharmos a documentação oficial do Node.js sobre como ler um arquivo, vamos encontrar a função readFile e um exemplo bem simples:

import { readFile } from 'fs';

readFile('/etc/passwd', (err, data) => {
  if (err) throw err;
  console.log(data);
});
Exemplo da documentação oficial do Node.js

Veja que o readFile trabalha com um callback, que é o segundo parâmetro que passamos para a função. Então quando o readFile finaliza a leitura do arquivo, este callback é executado e ali conseguimos pegar o conteúdo do arquivo ou o erro durante a leitura do mesmo.

Criando o projeto Express

Antes de entrarmos na implementação da leitura dos arquivos texto, vamos estruturar um projeto Express.

Para criar e configurar um projeto em Express basta rodar os comandos abaixo:

mkdir ler-arquivo-texto-express-nodejs
npm install express

Crie um arquivo chamado index.js com o seguinte conteúdo:

const express = require('express');
const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.get('/arquivos', (req, res) => {
    res.json({ ok: 'Estou funcionando' });
});

app.listen(3000);

Iniciando a aplicação acima através do comando node index.js podemos obter a resposta { ok: 'Estou funcionando' } ao efetuar um GET http://localhost:3000/arquivos.

Como ler um arquivo texto

Para efeito de teste eu criei dois arquivos no mesmo diretório do index.js:

  1. arquivo1.txt
  2. arquivo2.txt

Como primeiro objetivo vamos efetuar a leitura do arquivo1.txt utilizando a função readFile:

const { readFile } = require('fs');
const express = require('express');

const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.get('/arquivos', (req, res) => {
    console.log('1');
    const caminhoArquivo = './arquivo1.txt';

    console.log('2');
    readFile(caminhoArquivo, (err, data) /* callback */ => {
        console.log('4');
        if (err) {
            res.status(500).send(err);
            return;
        }

        res
            .set({ 'Content-Type': 'text/plain' })
            .send(data);
    });

    console.log('3');
}); /* Final do GET /arquivos */

app.listen(3000);

Deixei alguns pontos de console.log para mostrar como será a ordem de execução, visto que o readFile faz a leitura do arquivo de forma assíncrona. Então quando efetuarmos uma requisição GET /arquivos, o Node.js irá executar o código da seguinte forma:

  1. Atribuir o valor para a const caminhoArquivo
  2. Chamar a função readFile
  3. Chegará no /* Final do GET /arquivos */
  4. Depois de alguns milisegundos o callback do readFile será chamado e se tudo deu certo o conteúdo do arquivo será entregue como resposta da requisição.

Veja que as marcações dos console.log batem exatamente com a explicação acima:

Resultado dos console.log ao longo da rota /arquivos

Resultado:

Resultado da requisição GET /arquivos

Como ler vários arquivos texto com o readFile

Até este momento ler um arquivo txt e entregar seu conteúdo ficou bem simples com a função readFile. Vamos complicar um pouco mais as coisas e retornar o conteúdo concatenado do arquivo1.txt e arquivo2.txt, mas sempre mantenha em mente que o readFile é assíncrono.

Para facilitar o trabalho vamos isolar a leitura do arquivo em um função chamada lerArquivo e trabalhar com Promise. Logo a seguir vou explicar melhor os detalhes:

JavaScript é uma linguagem síncrona mas nos provê alguns mecânismos para ter um comportamento assíncrono. Uma das formas é através dos callbacks, onde passamos uma função como parâmetro de um método e a mesma é executada em um dado instante sem "travar" a execução do seu código e consequentemente sem travar a interface.

Conforme o código cresce, vamos ter um monte de callbacks e isto vira uma bagunça e daí vêm o termo "callback hell". Com a evolução surgiu a Promise que ajudou a organizar esses callbacks. Depois surgiu o async/await que é uma extensão das Promises para simplificar sua utilização.

Fonte: https://pt.stackoverflow.com/questions/413563/async-await-x-sync - trecho de uma resposta que escrevi no stackoverflow
function lerArquivo(caminhoArquivo) {
    console.log(caminhoArquivo, 1);
    
    return new Promise((resolve, reject) => {
        console.log(caminhoArquivo, 3);
        
        readFile(caminhoArquivo, (err, data) /* callback */ => {
            console.log(caminhoArquivo, 4, err ? 'Erro' : 'Sucesso');
            err ? reject(err) : resolve(data);
        });
    });
    
    console.log(caminhoArquivo, 2);
}

A primeira coisa que fizemos foi isolar a leitura do arquivo em uma função chamada lerArquivo que recebe como único parâmetro de entrada o caminho do arquivo que deve ser lido.

A segunda coisa é que esta função retorna uma "promessa", em JavaScript Promise, que indica que haverá um trabalho assíncrono. Quando esta "promessa" finaliza seu trabalho com sucesso ela chama a função resolve com o resultado do processamento ou a função reject com os detalhes do erro. Por esta razão quando o readFile retornar um erro na variável err nós executamos o reject ao invés do resolve.

Olhando as marcações dos console.log acima, a ordem de execução é: 1, 2, 3, 4

Agora vamos juntar tudo e novamente as explicações estão logo a seguir:

const { readFile } = require('fs');
const express = require('express');

const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.get('/arquivos', (req, res) => {
    const caminhoArquivos = [
        './arquivo1.txt',
        './arquivo2.txt'
    ];

    const promises = [];
    for (const caminhoArquivo of caminhoArquivos) {
        promises.push(lerArquivo(caminhoArquivo));
    }

    Promise.all(promises).then(conteudoArquivos => {
        res
            .set({ 'Content-Type': 'text/plain' })
            .send(conteudoArquivos.join('\n'));
    }).catch(err => {
        res.status(500).send(err);
    });
}); /* Final do GET /arquivos */

function lerArquivo(caminhoArquivo) {
    console.log(caminhoArquivo, 1);
    
    return new Promise((resolve, reject) => {
        console.log(caminhoArquivo, 3);
        
        readFile(caminhoArquivo, (err, data) /* callback */ => {
            console.log(caminhoArquivo, 4, err ? 'Erro' : 'Sucesso');
            err ? reject(err) : resolve(data);
        });
    });
    
    console.log(caminhoArquivo, 2);
}

app.listen(3000);

Dentro da rota app.get('/arquivos', ...), criamos um array de string com o caminho de cada arquivo que deve ser lido.

Na sequência executamos um for em cima deste array chamando a função lerArquivo. O retorno desta função é uma Promise que é salva em um outro array chamado promises.

Após o for devemos esperar todas as Promise serem concluídas, lembre-se de que elas tem comportamento assíncrono, e para isto utilizamos o Promise.all. Basicamente o Promise.all espera que todas as Promise passadas como parâmetro sejam finalizadas. Quando isto ocorre ele executa o .then no caso de sucesso ou o .catch caso haja algum erro.

O .then retorna um array onde cada posição corresponde ao conteúdo de um dos arquivos. Então para entregá-lo ao cliente concatenamos este array com o .join().

Veja abaixo os prints dos console.log e na sequência o resultado da requisição:

Prints dos console.log dentro da função lerArquivo
Resultado da requisição - conteúdo dos arquivos txt

Considerações

Ler o arquivo é razoavelmente simples, basta entender como utilizar o callback do readFile. O cenário fica mais complexo quando temos que ler vários arquivos, mas nada que não possa ser resolvido utilizando Promise.

Um bom exemplo da vida real é quando temos restrições de atualização em um frontend, por exemplo, suponha que no frontend possamos adicionar em um único momento uma referência de script: <script src="minha-api/meus-scripts"></script>. Veja que o navegador irá buscar em minha-api/meus-scripts o conteúdo, e neste momento nossa API pode ler um ou mais arquivos e entregar o resultado concatenado.

Código completo:

const { readFile } = require('fs');
const express = require('express');

const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.get('/arquivos', (req, res) => {
    const caminhoArquivos = [
        './arquivo1.txt',
        './arquivo2.txt'
    ];

    const promises = [];
    for (const caminhoArquivo of caminhoArquivos) {
        promises.push(lerArquivo(caminhoArquivo));
    }

    Promise.all(promises).then(conteudoArquivos => {
        res
            .set({ 'Content-Type': 'text/plain' })
            .send(conteudoArquivos.join('\n'));
    }).catch(err => {
        res.status(500).send(err);
    });
}); /* Final do GET /arquivos */

function lerArquivo(caminhoArquivo) {
    return new Promise((resolve, reject) => {
        readFile(caminhoArquivo, (err, data) /* callback */ => {
            err ? reject(err) : resolve(data);
        });
    });
}

app.listen(3000);

Links interessantes: