Entendendo Async Iterators No JavaScript

Há um tempo atrás, fiz um post em meu Medium onde falo tudo sobre o protocolo Iterator e sua interface de uso. Porém, além de APIs como Promise.finally, o ECMAScript 2018 trouxe para a gente uma outra forma de tratarmos os nossos iterators. Os async iterators.

O problema

Vamos nos colocar em uma situação bastante comum. Estamos trabalhando com Node.js e temos que ler um arquivo, linha a linha. O Node possui uma API para este tipo de função chamada readLine (veja a documentação completa aqui), esta API é um wrapper para que você possa ler dados de uma stream de entrada linha a linha ao invés de ter que fazer o parsing do buffer de entrada e quebrar o texto em pequenas partes.

Ela expõe uma API de eventos, que você pode escutar desta forma:

const fs = require('fs')
const readline = require('readline')
const reader = readline.createInterface({
  input: fs.createReadStream('./arquivo.txt'),
  crlfDelay: Infinity
})

reader.on('line', (line) => console.log(line))

Imagine que tenhamos um arquivo simples:

linha 1
linha 2
linha 3

Se rodarmos este código no arquivo que criamos, teremos um output linha a linha no nosso console. Porém, trabalhar com eventos não é uma das melhores formas de fazer código manutenível, pois eventos são completamente assíncronos e eles podem quebrar o fluxo do código, uma vez que eles são disparados fora de ordem e você só consegue atribuir uma ação através de um listener.

A Solução

Além da API de eventos, o readline também expõe um async iterator. Isso significa que, ao invés de fazermos a leitura da linha através de listeners no evento line, vamos fazer a leitura da linha através de uma nova forma de utilização da keyword for.

Hoje temos algumas opções de uso para um laço de repetição for, a primeira delas é o modelo mais comum, utilizando um contador e uma condição:

for (let x = 0; x < array.length; x++) {
  // Código aqui
}

Também podemos utilizar a notação for ... in para leitura de índices de arrays:

const a = [1,2,3,4,5,6]

for (let index in a) {
  console.log(a[index])
}

No caso anterior, vamos ter como saída no console.log, os números de 1 a 6, porém se utilizarmos console.log(index) vamos logar o índice do array, ou seja, os números de 0 a 5.

Para o próximo caso, podemos usar a notação for ... of para pegar diretamente as propriedades enumeráveis do array, ou seja, seus valores diretos:

const a = [1,2,3,4,5,6]

for (let item of a) {
  console.log(item)
}

Perceba que todas as formas de utilização que descrevi são síncronas, ou seja, como fazemos para que possamos ler uma sequencia de promises em ordem?, imagine que tenhamos uma outra interface que nos retorne sempre uma Promise, que se resolve para a nossa linha do arquivo em questão. Para resolvermos essas promises em ordem, temos de fazer algo assim:

async function readLine (files) {
  for (const file of files) {
    const line = await readFile(file) // Imagine que readFile é o nosso cursor
    console.log(line)
  }
}

Porém, graças a magia dos async iterables (como o readline) nós podemos fazer o seguinte:

async function readLine (files) {
  for (const file of files) {
    const line = await readFile(file) // Imagine que readFile é o nosso cursor
    console.log(line)
  }
}

Perceba que agora estamos usando uma nova definição de for, o for await (const x of y).

For Await e Node.js

A notação for await é nativamente suportada no runtime do Node.js da versão 10.x. Se você está usando as versões 8.x ou 9.x então você precisa iniciar o seu arquivo Javascript com a flag --harmony_async_iteration. Infelizmente os async iterators não são suportados nas versões 6 ou 7 do Node.js.

Para podermos entender o conceito de async iterators, precisamos dar uma recapitulada sobre o que são iterators em si. O meu artigo anterior é uma fonte maior de informações, mas, em suma, um Iterator é um objeto que expõe uma função next() que retorna um outro objeto com a notação {value: any, done: boolean} onde value é o valor da iteração atual e done identifica se há ou não há mais valores na sequencia. Um exemplo simples, é um iterador que passa por todos os itens de um array:

const array = [1,2,3]
let index = 0

const iterator = {
  next: () => {
    if (index >= array.length) return { done: true }
    return {
      value: array[index++],
      done: false
    }
  }
}

Sozinho, um iterator não tem nenhuma utilidade prática, para que possamos tirar algum proveito dele, precisamos de um iterable. Um iterable é um objeto que possui uma chave Symbol.iterator que retorna uma função, a qual retorna nosso iterador:

// ... Código do iterador aqui ...

const iterable = {
	[Symbol.iterator]: () => iterator
}

Agora podemos utilizar ele normalmente, com for (const x of iterable) e teremos todos os valores do array sendo iterador um a um.

Se você quiser saber um pouco mais sobre Symbols, dê uma olhada neste outro artigo que escrevi só sobre o tema

Por baixo dos panos, todos os arrays e objetor possuem um Symbol.iterator para que possamos fazer for (let x of [1,2,3]) e retornar os valores que queremos.

Como é de se esperar, um async iterator é exatamente igual a um iterator, exceto que, ao invés de um Symbol.iterator, temos um Symbol.asyncIterator em nosso iterable e, ao invés de um objeto que retorna {value, done} teremos uma Promise que resolve para um objeto com a mesma assinatura.

Vamos transformar nosso iterator acima em um async iterator:

const array = [1,2,3]
let index = 0

const asyncIterator = {
  next: () => {
    if (index >= array.length) return Promise.resolve({done: true})
    return Promise.resolve({value: array[index++], done: false})
  }
}

const asyncIterable = {
  [Symbol.asyncIterator]: () => asyncIterator
}

Iterando assincronamente

Podemos fazer a iteração de qualquer iterator de forma manual, chamando a função next():

// ... Código do async iterator aqui ...

async function manual () {
	const promise = asyncIterator.next() // Promise
  await p // Object { value: 1, done: false }
  await asyncIterator.next() // Object { value: 2, done: false }
  await asyncIterator.next() // Object { value: 3, done: false }
  await asyncIterator.next() // Object { done: true }
}

Para que possamos iterar através do nosso async iterator, temos que utilizar for await, porém, lembre-se que a keyword await só pode ser usada dentro de uma async function, ou seja, temos que ter algo deste tipo:

// ... Código acima omitido ...

async function iterate () {
  for await (const num of asyncIterable) console.log(num) 
}

iterate() // 1, 2, 3

Mas, assim como os iteradores assíncronos, não são suportadas no Node 8.x ou 9.x, para podermos utilizar um async iterator nessas versões, nós podemos simplesmente extrair o next dos seus objetos e iterar por eles manualmente:

// ... Código acima omitido ...

async function iterate () {
  for await (const num of asyncIterable) console.log(num) 
}

iterate() // 1, 2, 3

Perceba que for await é muito mais clean e muito mais conciso porque se comporta como um loop comum, mas também, além de ser muito mais simples de entender, checa pelo final do iterador sozinho, através da chave done.

Tratando erros

O que acontece se nossa promise for rejeitada dentro do nosso iterador? Bem, como qualquer promise rejeitada, podemos pegar seu erro através de um simples try/catch (já que estamos usando await ):

const asyncIterator = { next: () => Promise.reject('Error') }
const asyncIterable = { [Symbol.asyncIterator]: () => asyncIterator }

async function iterate () {
  try {
	  for await (const num of asyncIterable) {}
  } catch (e) {
    console.log(e.message)
  }
}

iterate()

Fallbacks

Algo bastante interessante dos async iterators é que eles possuem um fallback para Symbol.iterator, isso significa que você pode utilizar ele também com seus iteradores comuns, por exemplo, um array de promises:

const fetch = require('node-fetch')
const promiseArray = [
  fetch('https://lsantos.dev'),
  fetch('https://lsantos.me')
]

async function iterate () {
  for await (const response of promiseArray) console.log(response.status)
}

iterate() // 200, 200

Async Generators

Em grande parte, iterators e async iterators podem ser criados a partir de generators. Generators são funções que permitem que suas execuções sejam pausadas e retomadas, de forma que é possível realizar uma execução e depois buscar um próximo valor através de uma função next().

Esta é uma descrição muito simplificada de generators, a leitura do artigo que fala somente sobre eles é imprescindível para que você possa entender generators de forma rápida e mais profunda.

Async generators se comportam como um async iterator, porém, você deve implementar o mecanismo de parada manualmente, por exemplo, vamos construir um gerador de mensagens aleatórias para commits do git para deixar seus colegas super felizes com suas contribuições:

const fetch = require('node-fetch')
async function* gitCommitMessageGenerator () {
  const url = 'https://whatthecommit.com/index.txt'

  while (true) {
    const response = await fetch(url)
    yield await response.text() // Retornamos o valor
  }
}

Veja que não estamos em nenhum momento retornando um objeto {value, done}, então o loop não tem como saber quando a execução terminou. Podemos implementar uma função desta forma:

// Código anterior omitido
async function getCommitMessages (times) {
  let execution = 1
  for await (const message of gitCommitMessageGenerator()) {
    console.log(message)
    if (execution++ >= times) break
  }
}

getCommitMessages(5)
// I'll explain this when I'm sober .. or revert it
// Never before had a small typo like this one caused so much damage.
// For real, this time.
// Too lazy to write descriptive message
// Ugh. Bad rebase.

Caso de uso

Para exemplificar de forma mais interessante, vamos construir um async iterator para um caso de uso real. Atualmente, o driver do Oracle Database para Node.js suporta uma API de resultSet, que executa uma query no banco de dados e retorna uma stream de registros que podem ser lidos um a um através do método getRow().

Para criarmos esse resultSet precisamos executar uma query no banco, desta forma:

const oracle = require('oracledb')
const options = {
  user: 'usuario',
  password: 'senha',
  connectString: 'string'
}

async function start () {
  const connection = await oracle.getConnection(options)
  const { resultSet } = await connection.execute('query', [], { outFormat: oracle.OBJECT, resultSet: true })
  return resultSet
}

start().then(console.log)

Nosso resultSet possui um método chamado getRow() que nos retorna uma Promise da próxima linha do banco que deve ser trazida, isso é um belo caso de uso para um async iterator não é? Podemos criar um cursor que nos retorna este resultSet linha por linha. Vamos deixar um pouco mais complexo através da criação de uma classe Cursor:

class Cursor {
  constructor (resultSet) {
    this.resultSet = resultSet
  }

  getIterable () {
    return {
      [Symbol.asyncIterator]: () => this._buildIterator()
    }
  }

  _buildIterator () {
    return {
      next: () => this.resultSet.getRow().then((row) => ({ value: row, done: row === undefined }))
    }
  }
}

module.exports = Cursor

Veja que o cursor recebe o resultSet que ele deve trabalhar e o armazena em seu estado atual. Então vamos alterar nosso método anterior para que retornemos o cursor ao invés do resultSet de uma única vez:

const oracle = require('oracledb')
const options = {
  user: 'usuario',
  password: 'senha',
  connectString: 'string'
}

async function getResultSet () {
  const connection = await oracle.getConnection(options)
  const { resultSet } = await connection.execute('query', [], { outFormat: oracle.OBJECT, resultSet: true })
  return resultSet
}

async function start() {
  const resultSet = await getResultSet()
  const cursor = new Cursor(resultSet)
  
  for await (const row of cursor.getIterable()) {
    console.log(row)
  }
}

start()

Desta forma podemos fazer um loop por todas as nossas linhas retornadas sem precisar de uma resolução de Promises individual.

Conclusão

Async iterators são extremamente poderosos, especialmente em linguagens dinâmicas e assíncronas como o Javascript, com eles você pode transformar uma execução complexa em um código simples, escondendo a maioria da complexidade do usuário.

Não deixe de acompanhar mais do meu conteúdo no meu blog e se inscreva na newsletter para receber notícias semanais!