Desmistificando o async/await

Dentro de poucos dias a nova versão LTS do Node.js estará disponível para a utilização e com ela teremos acesso a novas ferramentas que facilitarão o nosso dia a dia com a plataforma. Nesta postagem falaremos um pouco sobre o tão aguardado async/await.

Promises

Antes de mais nada é necessário entender que os operadores async e await foram desenvolvidos especificamente para o controle de fluxo de operações assíncronas utilizando Promises. Para entender melhor vamos desenvolver agora uma pequena função que retorna uma Promise:

function exemplo(item) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(item), 500);
  });
}

Esta simples função emula um comportamento assíncrono ao resolver a promise após 500 milissegundos com o mesmo valor que fora passado para a função ao ser invocada. Podemos testar o funcionamento desta função de forma simples:

exemplo('hello')
 .then(str => console.log(str))
 .then(() => exemplo('world'))
 .then(str => console.log(str));

Se tudo estiver correto você verá duas linhas no seu console, a primeira com hello e a segunda com world. Já estamos prontos para a próxima etapa.

Utilizando suas novas ferramentas

Agora que já temos uma promise fácil de entender podemos reescrever o exemplo anterior para tirar proveito do que estamos aprendendo hoje.

async function executar() {
  const str1 = await exemplo('hello');
  console.log(str1);

  const str2 = await exemplo('world');
  console.log(str2);
};

executar()
.then(() => console.log('Programa finalizado!'));

Ao visualizar e executar o exemplo acima já podemos observar algumas características dos novos operadores:

  • Podemos declarar uma função assíncrona com o operador async
  • Utilizamos o operador await dentro de funções assíncronas
  • O operador async pausa a execução da função e espera até que a função designada à direita retorne
  • Funções assíncronas retornam uma promise ao serem executadas

A regra

Para que possamos utilizar corretamente os operadores é necessário lembrar que somente é permitida a utilização do operador await dentro de funções declaradas como assíncronas. Para fixar esta regra vamos passar por alguns exemplos contento a utilização correta e incorreta da ferramenta:

async function teste() {
  // Correto pois estamos dentro de uma função assíncrona
  await exemplo('Hello world');
}

function teste1() {
  // Incorreto pois este escopo não é assíncrono
  await exemplo('Hello world');
}

async function teste2() {
  const child = () => {
    // Incorreto pois escopos filhos não herdam
    // a característica assíncrona do escopo pai
    await exemplo('Hello world'); 
                                          
  };

  child();
}

async function teste3() {
  const child = async () => {
    // Correto pois este escopo foi devidamente
    // declarado como assíncrono
    await exemplo('Hello world');
  };

  // Correto pois esta função também é assíncrona
  await child();
}

Destructors são seus amigos!

Umas das características do operador await é que o mesmo possui uma precedência menor que o operador de associação, por este motivo podemos utilizar destructuring no retorno deste operador, facilitando nossas vidas.

async function teste() {
  const [str1, str2] = await exemplo(['hello', 'world']);
  console.log(`${str1} ${str2}`); // hello world
};

Cadeia de promises

Até o momento utilizamos o operador await de forma simples, nas próximas sessões desta postagem conheceremos as verdadeiras vantagens da nossa nova ferramenta. Uma outra característica deste operador é que o mesmo aguarda a cadeia inteira de promises acabar antes de retornar o valor final, sabendo disso podemos utilizar alguns truques como:

async function teste() {
  const resposta = await exemplo(20)
    .then(n => n * 2)
    .then(n => ++n)
    .then(n => ++n);

  console.log(`A resposta é ${resposta}`); // A resposta é 42
};

Combinando operadores

Observando todos os exemplos até agora podemos concluir que o operador await faz com que a resolução de uma promise se comporte de maneira síncrona dentro do escopo de uma função. Sabendo disso, a imaginação é o limite:

async function teste() {
  // Atribuir valores dentro de objetor, arrays, parâmetros
  const foobar = {
    foo: await exemplo('foo'),
    bar: await exemplo('bar'),
    foobar: await exemplo([
      await exemplo('foo'),
      await exemplo('bar'),
      await exemplo(await exemplo('foobar')) // Whooa!!
    ])
  };

  // Com operadores matemáticos e igualitários
  if ((await exemplo(21) + await exemplo(21)) === await exemplo(42)) {
    console.log('Temos a resposta do universo');
  }

  // Iteradores procedurais
  for (const str of await exemplo(['hello', 'world'])) {
    console.log(`String: ${await exemplo(str)}`); // Não, você não leu errado 🙂
  }
}

Quando todas estas possibilidades entrarem em sua cabeça é sempre bom lembrar que ter um código legível é muito mais importante que possuir um código enxuto de poucas linhas que só você pode decifrar. Com grandes poderes vem grandes responsabilidades.

Concorrências

Uma das coisas que poucos conseguem entender corretamente é que em conjunto com bibliotecas como Bluebird e Promise, o operador await pode aguardar pela finalização de várias promises ao mesmo tempo, e alta concorrência é o lema do Node.js!

Neste exemplo levamos em consideração o fato de que a função Promise.all recebe uma array de promises, retorna uma única promise e resolve esta promise com uma array contendo a resolução de todas as promises passadas anteriormente, todas as promises passadas para esta função são executadas em paralelo.

async function teste() {
  // Leve em consideração as duas próximas linhas
  const foo = await exemplo('foo'); // em 500ms nosso código irá continuar
  const bar = await exemplo('bar'); // em 500ms novamente o código irá continuas
  console.log('Nesta linha 1seg já se passou');
}

Sabemos que nossa promise exemplo demora 500 milissegundos para retornar, isso significa que qualquer chamada da função teste irá demorar no mínimo 1 segundo para completar. Utilizando a função Promise.all podemos cortar este tempo pela metade.

async function teste() {
  const [foo, bar] = await Promise.all([
   exemplo('foo'), // Note que não utilizamos await aqui pois queremos
   exemplo('bar')  // a simples e pura promise da função exemplo
  ]);

  console.log('Nesta linha apenas ~500ms se passaram');
}

A mesma lógica podemos aplicar para iteradores. Ao tratar um grupo de promises sempre prefira mapear o valor ao invés de iterar proceduralmente.

async function teste() {
  const valores = [1, 2];
  const resultados = [];

  // Esta iteração irá demorar ~1seg
  for (const valor of valores) {
    const resultado = await exemplo(valor + 1);
    resultados.push(resultado);
  }

  // Enquanto esta irá demorar ~500ms
  const resultadosParalelos = await Promise.all(
   valores.map(valor => exemplo(valor + 1));
  );
}

O único cuidado que precisamos tomar é que em alguns casos podemos prejudicar a infraestrutura do projeto ou até mesmo derrubar uma máquina ao executar muitas operações paralelas ao mesmo tempo. Imagine que você precisa realizar o download de 500 imagens de um website, fazer este download um por um com um iterador procedural irá remover todas as vantagens da concorrência do Node.js, porém executar todos os downloads ao mesmo tempo pode derrubar o site que estamos consultado.

Sabendo disto podemos utilizar uma ferramenta muito bacana e conhecida pela maioria dos programadores JavaScript, nosso amigo Bluebird. Sua função Bluebird.map executa as promises em paralelo porém podemos especificar a quantidade de promises concorrentes que queremos:

async function download() {
  const imagens = [
    'https://exemplo.com/nodebr.png',
    'https://exemplo.com/brazil.gif',
    // ... Imagine aqui mais 500 imagens
  ];

  // O Bluebird.map retorna uma promise que resolverá em uma
  // array, exatamente como o Promise.all
  await Bluebird.map(imagens, imagem => download(imagem), {
    concurrency: 5 // Porém podemos limitar a concorrência
  });

  console.log('Todas as imagens foram baixadas!')
}

Além do Bluebird.map esta biblioteca oferece várias outras ferramentas que podem aumentar a performance das suas funções assíncronas em conjunto com o operador await.

Tratamento de erros

Por último mas não menos importante, para interceptar erros utilizando funções assíncronas basta utilizar o famoso conjunto de operadores try e catch, por exemplo:

function pickleRick() {
  return new Promise(() => throw new Error('wubalubadubdub'));
}

async function teste() {
  try {
    await pikleRick();
  } catch (err) {
    console.error(err.message); // wubalubadubdub
  }
}

Também podemos utilizar o fato de que o operador await obedece à cadeia de promises para mapear ou tratar os erros sem a necessidade do try/catch.

async function teste() {
  const user = await db.buscarUsuario('Pickle Rick')
    .catch(err => {
      if (err === db.UsuarioNaoEncontrado) {
        return null; // Caso o usuário não exista retornamos null
      } else {
        // Caso for outro erro, passamos este erro adiante
        // e como não estamos tratando com try/catch na 
        // função principal a chamada da função teste
        // irá chamar o próximo .catch na cadeia
        throw err; 
      }
    });

  if (user === null) {
    console.log('Usuário não encontrado!'); 
  }
}

Considerações finais

Esperamos que este conhecimento possa ajudar ainda mais todos os desenvolvedores brasileiros de JavaScript. Qualquer dúvida, correção ou sugestão pode ser adicionada nos comentários abaixo.