Асинхронные итераторы в JavaScript

0
153
views
javascript logo

Хочешь проверить свои знания по JS?

Подпишись на наш канал с тестами по JS в Telegram!

×

Перевод статьи «Understanding Async Iterators in JavaScript».

Некоторое время назад я опубликовал на своем Medium статью, в которой рассказал о протоколе Iterator и его пользовательском интерфейсе. Однако в дополнение к таким API, как Promise.finally, ECMAScript 2018 принес нам еще один способ работы с итераторами. Это асинхронные итераторы.

Проблема

Давайте рассмотрим довольно распространенную ситуацию. Мы работаем с Node.js, и нам нужно прочитать файл, строка за строкой. В Node есть API для такого типа функций, который называется readLine (полную документацию можно найти здесь). Этот API представляет собой обертку, позволяющую читать данные из входного потока построчно, вместо того чтобы разбирать входной буфер и разбивать текст на небольшие фрагменты.

Он предоставляет API событий, которые вы можете прослушивать следующим образом:

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

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

Представьте, что у нас есть простой файл:

line 1
line 2
line 3

Если мы запустим этот код для созданного нами файла, то получим построчный вывод на консоль. Однако работа с событиями — не самый лучший способ сделать сопровождаемый код. Дело в том, что события полностью асинхронны и могут нарушить поток кода, так как срабатывают не по порядку, а назначить действие можно только через слушателя (listener).

Решение

В дополнение к API событий readline также предоставляет асинхронный итератор. Это означает, что вместо чтения строки через слушателей в событии line мы будем читать строку при помощи нового способа использования ключевого слова for.

Сегодня у нас есть несколько вариантов использования цикла for. Первый — наиболее распространенная модель, использующая счетчик и условие:

for (let x = 0; x < array.length; x++) {
  // Code here 
}

Мы также можем использовать нотацию for ... in для чтения индексов массивов:

const a = [1,2,3,4,5,6]
for (let index in a) {
  console.log(a[index])
}

В первом случае мы получим в виде вывода в console.log числа от 1 до 6. Однако если мы используем console.log(index), то выведем в лог индекс массива, то есть числа от 0 до 5.

Для следующего случая мы можем использовать нотацию for ... of, чтобы напрямую получить перечислимые свойства массива, то есть его прямые значения:

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


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

Обратите внимание, что все описанные мною способы являются синхронными. Итак, как же нам прочитать последовательность промисов по порядку?

Представьте, что у нас есть другой интерфейс, который всегда возвращает промис, разрешенный для нашей строки рассматриваемого файла. Чтобы разрешить эти промисы по порядку, нам нужно сделать примерно следующее:

async function readLine (files) {
  for (const file of files) {
    const line = await readFile(file) // Imagine readFile is our cursor
    console.log(line)
  }
}

Однако благодаря магии асинхронных итераций (например, readline) мы можем сделать следующее:

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()

Обратите внимание, что теперь мы используем новое определение forfor await (const x of y).

for await и Node.js

Нотация for await поддерживается в среде исполнения Node.js начиная с версии 10.x. Если вы используете версии 8.x или 9.x, то вам нужно запустить ваш JavaScript-файл с флагом --harmony_async_iteration. К сожалению, асинхронные итераторы не поддерживаются в Node.js версий 6 и 7.

Чтобы понять концепцию асинхронных итераторов, нам нужно взглянуть на то, что представляют собой сами итераторы. Моя предыдущая статья — отличный источник информации, но если коротко, то итератор — это объект, раскрывающий функцию next(), которая возвращает другой объект с нотацией {value: any, done: boolean}, где value — значение текущей итерации, а done определяет, есть ли еще значения в последовательности.

Простой пример — итератор, который перебирает все элементы в массиве:

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

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

Сам по себе итератор не имеет практической пользы, поэтому для того, чтобы получить от него какую-то пользу, нам нужен iterable (итерируемый объект). Это объект, имеющий ключ Symbol.iterator, который возвращает функцию, возвращающую наш итератор:

// ... Iterator code here ...

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

Теперь мы можем использовать его обычным образом, с помощью for (const x из iterable), и у нас будут итерироваться все значения в массиве по одному.

Примечание автора. Если вы хотите узнать немного больше о Symbol, загляните в другую статью, которую я посвятил этой теме.

Под капотом у всех массивов и объектов есть Symbol.iterator, чтобы мы могли выполнять for (let x of [1,2,3]) и возвращать нужные нам значения.

Как и следовало ожидать, асинхронный итератор работает почти так же, как и обычный итератор. Только вместо Symbol.iterator у нас есть Symbol.asyncIterator, а вместо объекта, возвращающего {value, done}, у нас есть Promise, который разрешается в объект с той же сигнатурой.

Давайте превратим наш итератор, приведенный выше, в асинхронный итератор:

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
}

Асинхронная итерация

Мы можем итерировать любой итератор вручную, вызывая функцию next():

// ... Async iterator Code here ...

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 }
}

Чтобы выполнить итерацию через наш асинхронный итератор, мы должны использовать for await, но помните, что ключевое слово await можно использовать только внутри async function. Это означает, что у нас должно быть что-то вроде этого:

// ... Code above ...

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

iterate() // 1, 2, 3

Но поскольку асинхронные итераторы не поддерживаются в Node 8.x или 9.x, чтобы использовать асинхронный итератор в этих версиях, мы можем просто извлечь next из наших объектов и выполнить итерацию вручную:

// ... Async Iterator Code here ...

async function iterate () {
  const {next} = asyncIterable[Symbol.asyncIterator]() // we take the next iterator function

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

Обратите внимание, что for await намного чище и лаконичнее, потому что ведет себя как обычный цикл. Но помимо того, что он намного проще для понимания, он сам проверяет конец итератора с помощью ключа done.

Обработка ошибок

Что произойдет, если наш промис будет отклонен внутри итератора? Ну, как и в случае с любым другим отклоненным промисом, мы можем перехватить ошибку с помощью простого try/catch (поскольку мы используем 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()

Fallback

Интересная особенность асинхронных итераторов заключается в том, что у них есть fallback для Symbol.iterator. Это значит, что вы можете использовать его и с обычными итераторами, например, с массивом промисов:

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

Асинхронные генераторы

По большей части итераторы и асинхронные итераторы могут быть созданы из генераторов.

Генераторы — это функции, выполнение которых можно приостанавливать и возобновлять, что позволяет выполнить одно действие, а затем получить следующее значение с помощью функции next().

Примечание автора. Это очень упрощенное описание генераторов. Чтобы быстро и хорошо разобраться в этой тебе, необходимо прочитать статью, посвященную именно генераторам.

Асинхронные генераторы ведут себя как асинхронные итераторы, но вам придется реализовать механизм остановки вручную. Например, давайте создадим генератор случайных сообщений коммитов git, чтобы порадовать коллег:

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

  while (true) {
    const response = await fetch(url)
    yield await response.text() // We return the value
  }
}

Обратите внимание, что мы не возвращаем объект {value, done}, поэтому цикл никак не может узнать о завершении выполнения. Реализовать функцию можно следующим образом:

// Previous Code
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.

Примеры использования

Чтобы было интереснее, давайте создадим асинхронный итератор для реального случая использования. В настоящее время драйвер Oracle Database для Node.js поддерживает API resultSet. Он выполняет запрос к базе данных и возвращает поток записей, которые можно читать по одной с помощью метода getRow().

Чтобы создать такой resultSet, нам нужно выполнить запрос к базе данных, например, так:

const oracle = require('oracledb')
const options = {
  user: 'example',
  password: 'example123',
  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)

У нашего resultSet есть метод getRow(), который возвращает нам из базы данных Promise следующей строки, которую нужно извлечь. Отличный вариант использования асинхронного итератора, не так ли? Мы можем создать курсор, возвращающий этот resultSet построчно. Давайте немного усложним задачу, создав класс 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

Видим, что курсор получает resultSet, с которым он должен работать, и сохраняет его в своем текущем состоянии. Итак, давайте изменим наш предыдущий метод так, чтобы сразу возвращать курсор вместо resultSet:

const oracle = require('oracledb')
const options = {
  user: 'example',
  password: 'example123',
  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()

Таким образом мы сможем просмотреть все возвращенные строки, не прибегая к индивидуальному разрешению Promises.

Заключение

Асинхронные итераторы чрезвычайно мощны, особенно в динамических и асинхронных языках, таких как JavaScript. С их помощью вы можете превратить сложное выполнение в простой код, скрыв большую часть сложности от пользователя.

ОСТАВЬТЕ ОТВЕТ

Please enter your comment!
Please enter your name here