Desenvolvimento

26 fev, 2016

Gerenciando o fluxo assíncrono de operações em NodeJS

Publicidade

O desenvolvimento de aplicações NodeJS aumenta a cada dia, talvez pela facilidade de desenvolvimento, bibliotecas em crescente evolução ou simplesmente pelo fato de utilizar JavaScript em ambientes que exijam performance e assincronia. Como sabemos, aplicações deste tipo não resolvem todos os problemas. Conforme a aplicação vem crescendo, precisamos nos atentar à alguns detalhes. Hoje falaremos sobre como escrever um código limpo para funções assíncronas, explorando o uso dos padrões criados para aplicações deste tipo.

Callbacks     

A programação assíncrona não usa o retorno da função para informar que a função foi finalizada. Ela trabalha com o “estilo de passagem de continuação (continuation-passing style), CPS”. Em definição, uma função escrita neste estilo recebe como argumento uma “continuação explícita”. Quando a função resulta um valor, ela “retorna” pela chamada da função de continuação com este valor via argumento. As funções de continuação explícitas também são conhecidas como JavaScript Higher-order Functions (Funções de ordem superior) ou simplesmente funções de Callback. É importante saber que os callbacks não são features da linguagem, apenas uma convenção para usar funções em Javascript.

A linguagem Javascript é assíncrona, ou seja, a partir dos callbacks as funções informam ao Runtime que ele deve continuar sua execução e, após terminar a tarefa, retornar ao ponto definido para tratar o resultado final. Portanto, a ordem em que o código é escrito é diferente da ordem em que é executado. No exemplo, a seguir temos a definição de uma função que após a execução não retorna o valor como esperado. Isto acontece pelo fato de que todas as funções que exigem manipulação de dados externos (I/O, requisições web, downloads etc), que exijam um tempo de espera para sua finalização, dependam de uma chamada de retorno para informar que o valor foi retornado. Imaginemos o seguinte cenário:

"use strict";
class Usuario {
    retornarDadosUsuario() {
        setTimeout(() => {
            return { nome: "Erick Wendel" };
        });
    }
}

let usuario = new Usuario().retornarDadosUsuario();
console.log(usuario);
/** Saida:
 * undefined
 */

Ao executar e tentar recuperar os valores, veremos que a saída para retorno será undefined, portanto, a variável usuário nunca recebeu um valor até o término de sua execução. Assim, a função retornarDadosUsuario é invocada corretamente, mas ao fazer a chamada de tempo de espera, ele “pausa a execução” deste método e continua a execução de operações menos custosas à aplicação e somente ao fim retorna o valor ao ponto definido. Para melhor entendimento, ao depurar nossa aplicação, veremos que o valor é retornado corretamente, mas em tempos de execução diferentes.

Para resolver este problema, passamos uma função de callback como argumento do método para sincronizar o retorno da função. A resolução ficaria da seguinte forma:

"use strict";
class Usuario {
    retornarDadosUsuario(callback) {
        setTimeout(() => {
            return callback({ nome: "Erick Wendel" });
        });
    }
}

let usuario = new Usuario();
usuario.retornarDadosUsuario((resultado) => {
    console.log(resultado);
});
/** Saida:
 * { nome: 'Erick Wendel' }
 */

Basicamente, a função de callback passada como argumento sincronizará os resultados de saída da operação. Neste ponto, o Runtime ainda executa nossa função como antes; a diferença é que após a execução das funções ele aguarda o retorno e somente após isso ele executa a função de callback. Não é uma exclusividade para aplicações NodeJS ou Javascript, é um paradigma de programação funcional, onde especifica o uso de funções como argumentos.

Boas práticas em Callbacks

Devemos seguir alguns princípios ao implementar nossas funções de callbacks. Veja abaixo alguns dos itens que você deve se preocupar.

  • Use funções nomeadas como argumentos para manipulação de callbacks.
"use strict";
function retornarDadosUsuario(callback) {
    setTimeout(() => callback({ nome: "Erick Wendel" }));
}
 function callbackDados(usuario) {
    console.log(usuario);
}

retornarDadosUsuario(callbackDados);
/**
 * Saida 
 * { nome: 'Erick Wendel' }
 */
  • Envie parâmetros para a chamada dos callbacks. Por convenção, em primeiro argumento, erros; e no segundo, o valor.
"use strict";
function retornarDadosUsuario(callback) {
    setTimeout(() => {
        //sucesso;
        callback(null, { nome: "Erick Wendel" });
        
//erro;
        callback(new Error("Erro Interno da Aplicacao"), null);
    });
}
function callbackDados(erro, resultados) {
    console.log(erro || resultados);
}

retornarDadosUsuario(callbackDados);
/**
 * Saida 
 * { nome: 'Erick Wendel' }
    [Error: Erro Interno da Aplicacacao]
 */
  • Ao fim da operação, sempre retorne a chamada da função de callback. A chamada do callback nem sempre informa a finalização do método, o ideal é sempre retornar para evitar a continuação indevida da implementação.
"use strict";
function retornarDadosUsuario(callback) {
    setTimeout(() => {
        //sucesso;
        return callback(null, { nome: "Erick Wendel" });
        
        //evitando que sejam feitas novas chamadas
        callback(new Error("Erro Interno da Aplicacao"), null);
    });
}
function callbackDados(erro, resultados) {
    console.log(erro || resultados);
}

retornarDadosUsuario(callbackDados);
/**
 * Saida 
 * { nome: 'Erick Wendel' }
 */

Callback Hell   

Imagine, agora, que sua aplicação cresceu. Além de retornar os dados, você precisa dos seguintes requisitos:

  1. Guardar o ID de um usuário;
  2. Buscar um endereço a partir do ID;
  3. Buscar um telefone a partir do ID;
  4. Buscar um veiculo a partir do ID.
"use strict";
//CallBack HELL
class Usuario {

    retornarDadosUsuario(callback) {
        setTimeout(() => {
            return callback(null, {nome: 'Erick Wendel', id: 123 });
        });
    };

    retornarEndereco(idUser, callback) {
        setTimeout(() => {
            return callback(null, {'rua': 'dos bobos', idUser: idUser});
        });
    };

    retornarTelefone(idUser, callback) {
        setTimeout(() => {
            return callback(null, {'numero': '123123123', idUser: idUser});
        });
    };

    retornarVeiculo(idUser, callback) {
         setTimeout(() => {
            return callback(null, {'carro': 'Fuscao', idUser: idUser});
        });
    };
}



//chamada
let usuario = new Usuario();
usuario.retornarDadosUsuario(function (erro, dadosUsuario) {
    let id = dadosUsuario.id;
    usuario.retornarEndereco(id, function (erro, endereco) {
        usuario.retornarTelefone(id, function (erro, telefone) {
            usuario.retornarVeiculo(id, function (erro, veiculo) {
                let user = { nome: dadosUsuario.nome, endereco, telefone, veiculo };
                console.log(user);
            });
        });
    });
});
/**
 * Saida
 * { nome: 'Erick Wendel',
  endereco: { rua: 'dos bobos', idUser: 123 },
  telefone: { numero: '123123123', idUser: 123 },
  veiculo: { carro: 'Fuscao', idUser: 123 } } 
 */

Aparentemente, o problema de sincronizar os valores com as funções foi resolvido, mas agora temos um outro problema: toda vez que precisar chamar mais de uma função que dependa de outro resultado teremos diversas funções aninhadas. Assim, pode dificultar a manutenção e testabilidade de nosso código – repare que não fizemos validações ou manipulações mais complexas destas informações, mas ainda sim nosso código ficou grande e de difícil entendimento. Mesmo que criássemos funções nomeadas e apenas as chamassem nos callbacks ainda teríamos o problema de funções aninhadas. Veremos nas próximas seções uma forma mais elegante de resolver este problema.

Trabalhando com promises

É um recurso extremamente utilizado em aplicações Javascript, na manipulação de tarefas assíncronas. Sebastian Ferrari, em seu artigo, escreveu uma boa definição da relação entre Promise e Callbacks:

… Uma Promise (promessa) é uma abstração para programação assíncrona, que recebe algum input (dados) e retorna uma promessa do output (resultado) final. Diferente de um sistema baseado em callbacks, que recebe um input e um callback (função), que é executado passando algum output.

Alguns dos benefícios de trabalhar com Promises é escrever o código como se fosse síncrono, isto é, diferente de callbacks que possuem a chamada dentro das funções, teremos as chamadas após a resolução das promessas de valores – veremos na prática mais a frente. Temos também o controle sobre o estado da solicitação a partir do objeto. Este objeto pode estar em três estados:

  1. Pendente (pending): Estado inicial;
  2. Realizado (fulfilled): Sucesso na operação e deve retornar um valor;
  3. Rejeitado (rejected): Falha na operação e deve retornar o motivo do erro.

Ainda na definição do Ferrari:

“O método “then” é executado quando a promessa se encontra no estado “fulfilled” (realizada), passando duas funções como argumentos e retornando uma promessa, possibilitando a criação de uma cadeia de promessas (promise chaining), na qual a próxima promessa sempre poderá utilizar os resultados das promessas anteriores”. Agora que conhecemos toda a definição, vamos à implementação (finalmente).

Trabalhando com NodeJS em sua atual versão, temos o suporte ao ECMAScript 6, com as novas features (mágicas) do Javascript, que por sua vez já possui a implementação de Promises por default. Vamos agora refatorar nosso código para uma implementação com Promises.

"use strict";

function retornarDadosUsuario() {
    //passamos por parametro uma "Arrow Function"
    // basicamente é uma função anonima que possui dois parametros
    // é a mesma coisa que definir 
    //function (resolve, reject) {};
     
    //o nome dos parametros são ficticios, poderíamos 
    //definir como batata e pastel que nao daria problema, o importante é 
    //entender o que são estes parametros e qual é a função deles.
    return new Promise((resolve, reject) => {
        //para melhor entendimento dos valores retornados,
        //vamos definir valores pre definidos daqui para frente
        const usuario = { 'nome': 'Erick Wendel', 'id': 10 };
         
        //supondo que nossa base de dados esteja vazia, podemos rejeitar a solicitacao
        //e explicar o motivo do erro, descomente a linha abaixo para ver o resultado
        // return reject("Não existem usuarios cadastrados em sua base de dados.");
         
        return resolve(usuario);

    });

};

function retornarEndereco(usuario) {
    return new Promise((resolve, reject) => {
        usuario.endereco = [{ 'userId': usuario.id, 'descricao': 'Rua dos bobos, 0' }];
        return resolve(usuario);
    });
};
function retornarTelefone(usuario) {
    return new Promise((resolve, reject) => {
        usuario.telefone = [{ 'userId': usuario.id, 'numero': '(11) 9999-9999' }];
        return resolve(usuario);
    });
};

function retornarVeiculo(usuario) {
    return new Promise((resolve, reject) => {
        usuario.veiculo = { 'userId': usuario.id, 'descricao': 'Fuscão Turbo' };
        return resolve(usuario);
    });
};


/* chamadas das funcoes */
//Basicamente, as instancias de promises retornarão
//os resultados ou erro (caso em algum lugar for rejeitado)
retornarDadosUsuario()
    // a função THEN exige como argumento uma função que
    // receba um callback como argumento que será o resultado da função anterior,
    // como a assinatura de nossos métodos foram definidas para receber os resultados
    // de nosso usuario, como parametro, delegamos à função then nossa função.
    // neste ponto não estamos fazendo uma chamada, e sim enviando a função para que seja 
    // executada após o retorno deste valor.
    //A funcao só será executada no estado fulfilled (resolvido) da Promise
    .then(retornarEndereco)
    .then(retornarTelefone)
    .then(retornarVeiculo)
    
    //ao executar todas as funções desejadas para construir nosso usuario
    //temos uma ultima chamada para tratar os valores finais retornados.
    //neste ponto teremos como segundo parametro da funcao THEN uma função 
    //que tratará os valores que foram rejeitados pelas promises em nossas chamadas

    .then(
        (resultados) => {
            let mensagem = `
                                Usuario: ${resultados.nome},
                                Endereco: ${resultados.endereco[0].descricao}
                                Telefone: ${resultados.telefone[0].numero}
                                Veiculo: ${resultados.veiculo.descricao}
                            `;

            console.log(mensagem);
        },
        
        //caso alguma de nossas promises rejeite algum valor
        //todas as funções sucessoras serão canceladas e não-executadas
        //assim, o primeiro que rejeitar o valor, será a resposta para nosso erro
        //a função somente será executada no estado rejected (Rejeitado) da Promise
        (error) => {
            console.log(`deu zica !!!! [ ${error} ]`);
        });
/*
// Saída:
//         Usuario: Erick Wendel,
//         Endereco: Rua dos bobos, 0
//         Telefone: (11) 9999-9999
//         Veiculo: Fuscão Turbo
//  */

Repare que agora nosso código ficou mais legível e de fácil manutenção. Temos também a opção de executar várias funções de uma só vez sem reutilizar os resultados de suas chamadas.

// /*Promise All*/
"use strict";
function retornarUsuarioBanco1() {
    return new Promise((resolve, reject) => {
        const usuarioBancoOracle = { 'nome': 'Erick Wendel' };
        return resolve(usuarioBancoOracle);

    });
}

function retornarUsuarioBanco2() {
    return new Promise((resolve, reject) => {
        const usuarioBancoMySql = { 'nome': 'Zina da Silva' };
        return resolve(usuarioBancoMySql);
    });
}

function retornarUsuarioBanco3() {
    return new Promise((resolve, reject) => {
        const usuarioBancoSqlServer = { 'nome': 'Xuxa de Souza' };
        return resolve(usuarioBancoSqlServer);
    });
}
//enviamos em um array, a chamada de cada função que deve ser executada
Promise.all([
    retornarUsuarioBanco1(),
    retornarUsuarioBanco2(),
    retornarUsuarioBanco3()
])
// na função THEN recuperamos o resultado de cada uma em um array. 
// os valores sao retornados na ordem em que foram chamados
    .then(
        (resultados) => {

            let usuarioOracle = resultados[0];
            let usuarioMySql = resultados[1];
            let usuarioSqlServer = resultados[2];
            let mensagem = `
                            Oracle: ${usuarioOracle.nome},
                            MySQL: ${usuarioMySql.nome},
                            SQLServer: ${usuarioSqlServer.nome}
                            `;

            console.log(mensagem);
        },
        (erro) => {
            console.log(`deu zica!! [ ${erro} ]`);
        }
       );
    
   /* 
   Saida:
        Oracle: Erick Wendel, 
        MySQL: Zina da Silva,
        SQLServer: Xuxa de Souza

   */

Existem diversas bibliotecas para trabalhar com Promises. Uma que particularmente utilizo bastante, é a lib chamada Blue Bird. Ela auxilia na criação de Promises em tempo de execução. Basicamente as funções que possuem callbacks como parâmetros serão transformadas em tempo de execução. Para isto, precisaremos instalar um pacote adicional em nosso projeto.

npm install –-save-dev bluebird

Feito isto, voltamos ao código e faremos a implementação.

/*Promises com Blue Bird*/
//importamos a lib do blue bird
let Promise = require('bluebird');
function retornaDadosUsuario (callback) {
    //como informado anteriormente, por convenção, sempre o primeiro parametro
    //do retorno de nossos callbacks será o erro
    //e o segundo o sucesso da funcao
    //caso queira simular um REJECT da promise, descomente a linha abaixo
    
    // return callback('Erro em sua solicitacao', null);
    
    return callback(null, {nome: 'Erick Wendel', id: 10});  
};

let retornaDadosUsuarioAsync = Promise.promisify(retornaDadosUsuario);
retornaDadosUsuarioAsync().then((resultado) => {
    console.log(resultado);
});
/*
Saida:
    { nome: 'Erick Wendel', id: 10 }
*/

Indo um pouco mais além, podemos transformar todas as funções de uma classe em promises. Repare que agora, o bluebird em tempo de execução alterou os nomes das nossas funções, adicionando os prefixos “Async” em cada função.

"use strict";
let Promise = require('bluebird');
class Usuario {

    retornaDadosUsuario(callback) {
        return callback(null, { nome: 'Erick Wendel', id: 10 });
    };

    retornaTelefone(usuario, callback) {
        usuario.telefone = { 'idUser': usuario.id, 'numero': '(11) 9999-8888' };
        return callback(null, usuario);
    };
}
let user = new Usuario();
Promise.promisifyAll(user);
user
    .retornaDadosUsuarioAsync()
    .then(user.retornaTelefoneAsync)
    .then(
        (resultado) => {
            let mensagem = `
                        Usuario: ${resultado.nome},
                        Telefone: ${resultado.telefone.numero}
                       `;
            console.log(mensagem);
        },
        (erro) => {
            console.log(`deu zica!! [ ${erro} ]`);
        }
     );
// /*
// Saida:
//            Usuario: Erick Wendel, 
//            Telefone: (11) 9999-8888
// */

Conhecendo o módulo async

O módulo async é uma lib muito usada para gerenciar o fluxo assíncrono de operações em Javascript (back e front-end). Com este módulo, podemos controlar a ordem de execução das operações.

npm install --save-dev async

Execução sequencial/serial

No exemplo abaixo, veremos a implementação de um fluxo de execução sequêncial, ou seja, as funções serão executadas na ordem em que são chamadas. Repare que temos as chamadas para as funções timeout onde cada função tem um tempo diferente para suas respectivas chamadas.

"use strict";
class Usuario {
retornarUsuarioBanco1(callback) {
        return callback(null, { 'nome': 'Erick Wendel' });
    }

    retornarUsuarioBanco2(callback) {
        setTimeout(() => {
            return callback(null, { 'nome': 'Zina da Silva' });
        }, 2000);
    }

    retornarUsuarioBanco3(callback) {
        setTimeout(() => {
            return callback(null, { 'nome': 'Xuxa de Souza' });
        }, 1000);
    }
}

//SERIES
let async = require('async');
let usuario = new Usuario();
async.series([
    usuario.retornarUsuarioBanco2,
    usuario.retornarUsuarioBanco1,
    usuario.retornarUsuarioBanco3

], (erros, resultados) => {
    console.log(erros || resultados);
});
/**
 * Saida:
 *  [ { nome: 'Zina da Silva' },
  { nome: 'Erick Wendel' },
  { nome: 'Xuxa de Souza' } ]
 */

A função series do modulo async recebe uma lista ou objeto de funções, onde cada função deve, obrigatoriamente, receber como parâmetro um callback, seguindo a convenção (erro, sucesso). Caso uma exceção ocorrer na chamada de alguma função, a execução é finalizada, retornando ao segundo parâmetro com os erros detalhados. Caso ocorra tudo como o planejado, retornará um array com os resultados de cada função em suas respectivas ordens.

Basicamente, a próxima função só será chamada após a anterior ser concluída com sucesso. Sua ordem de execução será de acordo com o exemplo abaixo:

  • Função 1 -> finalizada
    • Função 2 -> finalizada
      • Função 3 -> finalizada -> dispara um callback com todos os resultados
        • Retorno de resultados ou erros.

Execução paralela

Com a função parallel do módulo, podemos executar diversas funções em paralelo. Sabemos que o JavaScript é single thread, portanto, ainda não é possível que suas operações rodem em paralelo. O método parallel tem um comportamento parecido com o series; a diferença é que ele não aguarda a finalização de uma função para invocar a próxima, passando assim a ilusão de paralelismo.

//PARALLEL
let async = require('async');
let usuario = new Usuario();
async.parallel({
    funcao1: usuario.retornarUsuarioBanco2,
    funcao2: usuario.retornarUsuarioBanco1,
    funcao3: usuario.retornarUsuarioBanco3

}, (erros, resultados) => {
    console.log(erros || resultados);
});

/**
 * Saida
 *  { funcao2: { nome: 'Erick Wendel' },
  funcao3: { nome: 'Xuxa de Souza' },
  funcao1: { nome: 'Zina da Silva' } }
 */

Execução em cascata

Mais conhecido como modelo waterfall, é um modelo serial usado em situações onde uma função depende do resultado da outra para completar sua tarefa. Em nosso caso, vimos alguns exemplos em que, a partir de um usuário, precisávamos de seu endereço, telefone e veículo. Cada um em sua chamada, trabalhando com callbacks. Tornou-se uma “bagunça” e quase inviável de futuras manutenções. A função waterfall resolve este problema de forma elegante e simples de implementação. Em relação às implementações de parallel e series, ele retorna apenas o valor da chamada da última função.

//WATTERFALL
"use strict";
class Usuario {
    retornarDadosUsuario(callback) {
        return callback(null, { 'nome': 'Erick Wendel' });
    }

    retornarTelefone(usuario, callback) {
        setTimeout(() => {
            usuario.telefone = { 'numero': 40028922 };
            return callback(null, usuario);
        }, 2000);
    }

    retornarVeiculo(usuario, callback) {
        setTimeout(() => {
            usuario.veiculo = { 'carro': 'Fuscão Turbo' };
            return callback(null, usuario);
        }, 1000);
    }
}

let async = require('async');
let usuario = new Usuario();
async.waterfall([
    usuario.retornarDadosUsuario,
    usuario.retornarTelefone,
    usuario.retornarVeiculo

], (erros, resultados) => {
    console.log(erros || resultados);
});

/**
 * Saida
 *  { nome: 'Erick Wendel',
  telefone: { numero: 40028922 },
  veiculo: { carro: 'Fuscão Turbo' } }
 */

Conclusão

Hoje vimos as diversas maneiras de se trabalhar com funções em aplicações JavaScript, manipular a ordem de execução e compreender como as operações assíncronas devem ser tratadas em toda plataforma. Além disto, vimos também boas práticas ao criar callbacks e cuidados ao criar funções deste tipo.

Referências

Livros:

  • Professional Node.js: Building Javascript Based Scalable Software – Teixeira, Pedro – Wrox;
  • NodeJS Design Patters – Casciaro, Mario – PACKT Publishing;
  • Pro Node.js for Developers – J. Ihring, Colin – Apress.

Links: