Javascript: Entendendo Async Iterators

Lucas Santos - Jul 28 '19 - - Dev Community

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))
Enter fullscreen mode Exit fullscreen mode

Imagine que tenhamos um arquivo simples:

linha 1
linha 2
linha 3
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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])
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

async function read () {
  for await (const line of reader) {
    console.log(line)
  }
}

read()
Enter fullscreen mode Exit fullscreen mode

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.

Iterators

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.

Async Iterators

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
}
Enter fullscreen mode Exit fullscreen mode

Iterando assíncronamente

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 }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 do async iterator aqui ...

async function iterate () {
  const {next} = asyncIterable[Symbol.asyncIterator]() // pegamos a função next do iterator

  for (let {value, done} = await next(); !done; {value, done} = await next()) {
    console.log(value)
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .