[Перевод] Руководство по Node.js, часть 7: асинхронное программирование

Сегодня, в переводе седьмой части руководства по Node.js, мы поговорим об асинхронном программировании, рассмотрим такие вопросы, как использование коллбэков, промисов и конструкции async/await, обсудим работу с событиями.

hedi2j_qyfbnwfo_jqnnqd3ub6o.png

[Советуем почитать] Другие части цикла


Асинхронность в языках программирования


Сам по себе JavaScript — это синхронный однопоточный язык программирования. Это означает, что в коде нельзя создавать новые потоки, выполняющиеся параллельно. Однако компьютеры, по своей природы, асинхронны. То есть некие действия могут выполняться независимо от главного потока выполнения программы. В современных компьютерах каждой программе выделяется некое количество процессорного времени, когда это время истекает, система отдаёт ресурсы другой программе, тоже на некоторое время. Подобные переключения выполняются циклически, делается это настолько быстро, что человек попросту не может этого заметить, в результате мы думаем, что наши компьютеры выполняют множество программ одновременно. Но это иллюзия (если не говорить о многопроцессорных машинах).

В недрах программ используются прерывания — сигналы, передаваемые процессору и позволяющие привлечь внимание системы. Не будем вдаваться в детали, самое главное — помните о том, что асинхронное поведение, когда выполнение программы приостанавливается до того момента, когда ей понадобятся ресурсы процессора, это совершенно нормально. В то время, когда программа не нагружает систему работой, компьютер может решать другие задачи. Например, при таком подходе, когда программа ждёт ответа на выполненный ей сетевой запрос, она не блокирует процессор до момента получения ответа.

Как правило, языки программирования являются асинхронными, некоторые из них дают программисту возможность управлять асинхронными механизмами, пользуясь либо встроенными средствами языка, либо специализированными библиотеками. Речь идёт о таких языках, как C, Java, C#, PHP, Go, Ruby, Swift, Python. Некоторые из них позволяют программировать в асинхронном стиле, используя потоки, запуская новые процессы.

Асинхронность в JavaScript


Как уже было сказано, JavaScript — однопоточный синхронный язык. Строки кода, написанного на JS, выполняются в том порядке, в котором они присутствуют в тексте, друг за другом. Например, вот вполне обычная программа на JS, демонстрирующая такое поведение:

const a = 1
const b = 2
const c = a * b
console.log(c)
doSomething()


Но JavaScript был создан для использования в браузерах. Его основной задачей, в самом начале, была организация обработки событий, связанных с деятельностью пользователя. Например — это такие события, как onClick, onMouseOver, onChange, onSubmit, и так далее. Как решать подобные задачи в рамках синхронной модели программирования?

Ответ кроется в окружении, в котором работает JavaScript. А именно, эффективно решать подобные задачи позволяет браузер, давая в распоряжение программиста соответствующие API.

В окружении Node.js имеются средства для выполнения неблокирующих операций ввода-вывода, таких, как работа с файлами, организация обмена данными по сети и так далее.

Коллбэки


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

Обработчик события принимает функцию, которая будет вызвана при возникновении события. Выглядит это так:

document.getElementById('button').addEventListener('click', () => {
  //пользователь щёлкнул по элементу
})


Такие функции ещё называют функциями обратного вызова или коллбэками.

Коллбэк — это обычная функция, которая передаётся, как значение, другой функции. Вызвана она будет только в том случае, когда произойдёт некое событие. В JavaScript реализована концепция функций первого класса. Такие функции можно назначать переменным и передавать другим функциям (называемым функциями высшего порядка).

В клиентской JavaScript-разработке распространён подход, когда весь клиентский код оборачивают в прослушиватель события load объекта window, который вызывает переданный ему коллбэк после того, как страница будет готова к работе:

window.addEventListener('load', () => {
  //страница загружена
  //теперь с ней можно работать
})


Коллбэки используются повсеместно, а не только для обработки событий DOM. Например, мы уже встречались с их использованием в таймерах:

setTimeout(() => {
  // выполнится через 2 секунды
}, 2000)


В XHR-запросах тоже используются коллбэки. В данном случае это выглядит как назначение функции соответствующему свойству. Подобная функция будет вызвана при возникновении определённого события. В следующем примере таким событием является изменение состояния запроса:

const xhr = new XMLHttpRequest()
xhr.onreadystatechange = () => {
  if (xhr.readyState === 4) {
    xhr.status === 200 ? console.log(xhr.responseText) : console.error('error')
  }
}
xhr.open('GET', 'https://yoursite.com')
xhr.send()


▍Обработка ошибок в коллбэках


Поговорим о том, как обрабатывать ошибки в коллбэках. Существует одна распространённая стратегия обработки подобных ошибок, которая применяется и в Node.js. Она заключается в том, что первым параметром любой функции обратного вызова делают объект ошибки. При отсутствии ошибок в этот параметр будет записано значение null. В противном случае тут будет объект ошибки, содержащий её описание и дополнительные сведения о ней. Вот как это выглядит:

fs.readFile('/file.json', (err, data) => {
  if (err !== null) {
    //обработаем ошибку
    console.log(err)
    return
  }
  //ошибок нет, обработаем данные
  console.log(data)
})


▍Проблема коллбэков


Коллбэками удобно пользоваться в простых ситуациях. Однако, каждый коллбэк — это дополнительный уровень вложенности кода. Если используется несколько вложенных коллбэков, это быстро приводит к значительному усложнению структуры кода:

window.addEventListener('load', () => {
  document.getElementById('button').addEventListener('click', () => {
    setTimeout(() => {
      items.forEach(item => {
        //код, делающий что-то полезное
      })
    }, 2000)
  })
})


В этом примере показано всего лишь 4 уровня кода, но на практике можно столкнуться и с большим количеством уровней, обычно это называют «адом коллбэков». Справиться с этой проблемой можно, используя другие языковые конструкции.

Промисы и async/await


Начиная со стандарта ES6 в JavaScript появляются новые возможности, которые облегчают написание асинхронного кода, позволяя обходиться без коллбэков. Речь идёт о промисах, которые появились в ES6, и о конструкции async/await, появившейся в ES8.

▍Промисы


Промисы (promise-объекты) — это один из способов работы с асинхронными программными конструкциями в JavaScript, который, в целом, позволяет сократить использование коллбэков.

Знакомство с промисами


Промисы обычно определяют как прокси-объекты для неких значений, появление которых ожидается в будущем. Промисы ещё называют «обещаниями» или «обещанными результатами». Хотя эта концепция существует уже многие годы, промисы были стандартизированы и добавлены в язык лишь в ES2015. В ES2017 появилась конструкция async/await, которая основана на промисах, и которую можно рассматривать в качестве их удобной замены. Поэтому, даже если не планируется пользоваться обычными промисами, понимание того, как они работают, важно для эффективного использования конструкции async/await.

Как работают промисы


После вызова промиса он переходит в состояние ожидания (pending). Это означает, что функция, вызвавшая промис, продолжает выполняться, при этом в промисе производятся некие вычисления, по завершении которых промис сообщает об этом. Если операция, которую выполняет промис, завершается успешно, то промис переводится в состояние «выполнено» (fulfilled). О таком промисе говорят, что он успешно разрешён. Если операция завершается с ошибкой, промис переводится в состояние «отклонено» (rejected).

Поговорим о работе с промисами.

Создание промисов


API для работы с промисами даёт нам соответствующий конструктор, который вызывают командой вида new Promise(). Вот как создают промисы:

let done = true
const isItDoneYet = new Promise(
  (resolve, reject) => {
    if (done) {
      const workDone = 'Here is the thing I built'
      resolve(workDone)
    } else {
      const why = 'Still working on something else'
      reject(why)
    }
  }
)


Промис проверяет глобальную константу done, и, если её значение равно true, он успешно разрешается. В противном случае промис отклоняется. Используя параметры resolve и reject, являющиеся функциями, мы можем возвращать из промиса значения. В данном случае мы возвращаем строку, но тут может использоваться и объект.

Работа с промисами


Выше мы создали промис, теперь рассмотрим работу с ним. Выглядит это так:

const isItDoneYet = new Promise(
  //...

)
const checkIfItsDone = () => {
  isItDoneYet
    .then((ok) => {
      console.log(ok)
    })
    .catch((err) => {
      console.error(err)
    })
}

checkIfItsDone()


Вызов checkIfItsDone() приведёт к выполнению промиса isItDoneYet() и к организации ожидания его разрешения. Если промис разрешится успешно, сработает коллбэк, переданный методу .then(). Если возникнет ошибка, то есть промис будет отклонён, обработать её можно будет в функции, переданной методу .catch().

Объединение промисов в цепочки


Методы промисов возвращают промисы, что позволяет объединять их в цепочки. Удачным примером подобного поведения является браузерное API Fetch, представляющее собой уровень абстракции над XMLHttpRequest. Существует довольно популярный npm-пакет для Node.js, реализующий API Fetch, который мы рассмотрим позже. Это API можно использовать для загрузки неких сетевых ресурсов и, благодаря возможности объединения промисов в цепочки, для организации последующей обработки загруженных данных. Фактически, при обращении к API Fetch, выполняемом благодаря вызову функции fetch(), создаётся промис.

Рассмотрим следующий пример объединения промисов в цепочки:

const fetch = require('node-fetch')
const status = (response) => {
  if (response.status >= 200 && response.status < 300) {
    return Promise.resolve(response)
  }
  return Promise.reject(new Error(response.statusText))
}
const json = (response) => response.json()
fetch('https://jsonplaceholder.typicode.com/todos')
  .then(status)
  .then(json)
  .then((data) => { console.log('Request succeeded with JSON response', data) })
  .catch((error) => { console.log('Request failed', error) })


Здесь мы пользуемся npm-пакетом node-fetch и ресурсом jsonplaceholder.typicode.com в качестве источника JSON-данных.

В данном примере функция fetch() применяется для загрузки элемента TODO-списка с использованием цепочки промисов. После выполнения fetch() возвращается ответ, имеющий множество свойств, среди которых нас интересуют следующие:

  • status — числовое значение, представляющее собой код состояния HTTP.
  • statusText — текстовое описание кода состояния HTTP, которое представлено строкой OK в том случае, если запрос был выполнен успешно.


У объекта response есть метод json(), который возвращает промис, при разрешении которого выдаётся обработанное содержимое тела запроса, представленное в формате JSON.

Учитывая вышесказанное, опишем то, что происходит в этом коде. Первый промис в цепочке представлен объявленной нами функцией status(), которая проверяет состояние ответа, и, если он свидетельствует о том, что запрос не удался (то есть, код состояния HTTP не находится в диапазоне между 200 и 299), отклоняет промис. Эта операция приводит к тому, что другие выражения .then() в цепочке промисов не выполняются и мы сразу попадаем в метод .catch(), выводя в консоль, вместе с сообщением об ошибке, текст Request failed.

Если код состояния HTTP нас устраивает, вызывается объявленная нами функция json(). Так как предыдущий промис, при его успешном разрешении, возвращает объект response, мы используем его в качестве входного значения для второго промиса.

В данном случае мы возвращаем обработанные JSON-данные, поэтому третий промис получает именно их, после чего они, предварённые сообщением о том, что в результате запроса удалось получить нужные данные, выводятся в консоль.

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


В предыдущем примере у нас был метод .catch(), присоединённый к цепочке промисов. Если что-то в цепочке промисов идёт не так и возникает ошибка, либо если один из промисов оказывается отклонённым, управление передаётся в ближайшее выражение .catch(). Вот как выглядит ситуация, когда в промисе возникает ошибка:

new Promise((resolve, reject) => {
  throw new Error('Error')
})
  .catch((err) => { console.error(err) })


Вот пример срабатывания .catch() после отклонения промиса:

new Promise((resolve, reject) => {
  reject('Error')
})
  .catch((err) => { console.error(err) })


Каскадная обработка ошибок


Что делать, если в выражении .catch() возникнет ошибка? Для обработки такой ошибки можно включить в цепочку промисов ещё одно выражение .catch() (а потом можно присоединить к цепочке ещё столько выражений .catch(), сколько понадобится):

new Promise((resolve, reject) => {
  throw new Error('Error')
})
  .catch((err) => { throw new Error('Error') })
  .catch((err) => { console.error(err) })


Теперь рассмотрим несколько полезных методов, используемых для управления промисами.

Promise.all ()


Если вам нужно выполнить некое действие после разрешения нескольких промисов, сделать это можно с помощью команды Promise.all(). Рассмотрим пример:

const f1 = fetch('https://jsonplaceholder.typicode.com/todos/1')
const f2 = fetch('https://jsonplaceholder.typicode.com/todos/2')
Promise.all([f1, f2]).then((res) => {
    console.log('Array of results', res)
})
.catch((err) => {
  console.error(err)
})


В ES2015 появился синтаксис деструктурирующего присваивания, с его использованием можно создавать конструкции следующего вида:

Promise.all([f1, f2]).then(([res1, res2]) => {
    console.log('Results', res1, res2)
})


Тут мы, в качестве примера, рассматривали API Fetch, но Promise.all(), конечно, позволяет работать с любыми промисами.

Promise.race ()


Команда Promise.race() позволяет выполнить заданное действие после того, как будет разрешён один из переданных ей промисов. Соответствующий коллбэк, содержащий результаты этого первого промиса, вызывается лишь один раз. Рассмотрим пример:

const first = new Promise((resolve, reject) => {
    setTimeout(resolve, 500, 'first')
})
const second = new Promise((resolve, reject) => {
    setTimeout(resolve, 100, 'second')
})
Promise.race([first, second]).then((result) => {
  console.log(result) // second
})


Об ошибке Uncaught TypeError, которая встречается при работе с промисами


Если, работая с промисами, вы столкнётесь с ошибкой Uncaught TypeError: undefined is not a promise, проверьте, чтобы при создании промисов использовалась бы конструкция new Promise(), а не просто Promise().

▍Конструкция async/await


Конструкция async/await представляет собой современный подход к асинхронному программированию, упрощая его. Асинхронные функции можно представить в виде комбинации промисов и генераторов, и, в целом, эта конструкция представляет собой абстракцию над промисами.

Конструкция async/await позволяет уменьшить объём шаблонного кода, который приходится писать при работе с промисами. Когда промисы появились в стандарте ES2015, они были направлены на решение проблемы создания асинхронного кода. Они с этой задачей справились, но за два года, разделяющие выход стандартов ES2015 и ES2017, стало понятно, что считать их окончательным решением проблемы нельзя.

Одной из проблем, которую решали промисы, был знаменитый «ад коллбэков», но они, решая эту проблему, создали собственные проблемы схожего характера.

Промисы представляли собой простые конструкции, вокруг которых можно было бы построить нечто, обладающее более простым синтаксисом. В результате, когда пришло время, появилась конструкция async/await. Её использование позволяет писать код, который выглядит как синхронный, но при этом является асинхронным, в частности, не блокирует главный поток.

Как работает конструкция async/await


Асинхронная функция возвращает промис, как, например, в следующем примере:

const doSomethingAsync = () => {
    return new Promise((resolve) => {
        setTimeout(() => resolve('I did something'), 3000)
    })
}


Когда нужно вызвать подобную функцию, перед командой её вызова нужно поместить ключевое слово await. Это приведёт к тому, что вызывающий её код будет ждать разрешения или отклонения соответствующего промиса. Нужно отметить, что функция, в которой используется ключевое слово await, должна быть объявлена с использованием ключевого слова async:

const doSomething = async () => {
    console.log(await doSomethingAsync())
}


Объединим два вышеприведённых фрагмента кода и исследуем его поведение:

const doSomethingAsync = () => {
    return new Promise((resolve) => {
        setTimeout(() => resolve('I did something'), 3000)
    })
}
const doSomething = async () => {
    console.log(await doSomethingAsync())
}
console.log('Before')
doSomething()
console.log('After')


Этот код выведет следующее:

Before
After
I did something


Текст I did something попадёт в консоль с задержкой в 3 секунды.

О промисах и асинхронных функциях


Если объявить некую функцию с использованием ключевого слова async, это будет означать, что такая функция возвратит промис даже если в явном виде это не делается. Именно поэтому, например, следующий пример представляет собой рабочий код:

const aFunction = async () => {
  return 'test'
}
aFunction().then(console.log) // Будет выведен текст 'test'


Эта конструкция аналогична такой:

const aFunction = async () => {
  return Promise.resolve('test')
}
aFunction().then(console.log) // Будет выведен текст 'test'


Сильные стороны async/await


Анализируя вышеприведённые примеры, можно видеть, что код, в котором применяется async/await, оказывается проще, чем код, в котором используется объединение промисов в цепочки, или код, основанный на функциях обратного вызова. Здесь мы, конечно, рассмотрели очень простые примеры. В полной мере ощутить вышеозначенные преимущества можно, работая с гораздо более сложным кодом. Вот, например, как загрузить и разобрать JSON-данные с использованием промисов:

const getFirstUserData = () => {
  return fetch('/users.json') // загрузить список пользователей
    .then(response => response.json()) // разобрать JSON
    .then(users => users[0]) // выбрать первого пользователя
    .then(user => fetch(`/users/${user.name}`)) // загрузить данные о пользователе
    .then(userResponse => response.json()) // разобрать JSON
}
getFirstUserData()


Вот как выглядит решение той же задачи с использованием async/await:

const getFirstUserData = async () => {
  const response = await fetch('/users.json') // загрузить список пользователей
  const users = await response.json() // разобрать JSON
  const user = users[0] // выбрать первого пользователя
  const userResponse = await fetch(`/users/${user.name}`) // загрузить данные о пользователе
  const userData = await user.json() // разобрать JSON
  return userData
}
getFirstUserData()


Использование последовательностей из асинхронных функций


Асинхронные функции легко можно объединять в конструкции, напоминающие цепочки промисов. Результаты такого объединения, однако, отличаются гораздо лучшей читабельностью:

const promiseToDoSomething = () => {
    return new Promise(resolve => {
        setTimeout(() => resolve('I did something'), 10000)
    })
}
const watchOverSomeoneDoingSomething = async () => {
    const something = await promiseToDoSomething()
    return something + ' and I watched'
}
const watchOverSomeoneWatchingSomeoneDoingSomething = async () => {
    const something = await watchOverSomeoneDoingSomething()
    return something + ' and I watched as well'
}
watchOverSomeoneWatchingSomeoneDoingSomething().then((res) => {
    console.log(res)
})


Этот код выведет следующий текст:

I did something and I watched and I watched as well


Упрощённая отладка


Промисы сложно отлаживать, так как при их использовании нельзя эффективно пользоваться обычными инструментами отладчика (наподобие «шага с обходом», step-over). Код же, написанный с использованием async/await, можно отлаживать с использованием тех же методов, что и обычный синхронный код.

Генерирование событий в Node.js


Если вы работали с JavaScript в браузере, то вы знаете, что события играют огромнейшую роль в обработке взаимодействий пользователей со страницами. Речь идёт об обработке событий, вызываемых щелчками и движениями мыши, нажатиями клавиш на клавиатуре и так далее. В Node.js можно работать с событиями, которые программист создаёт самостоятельно. Здесь можно создать собственную систему событий с использованием модуля events. В частности, этот модуль предлагает нам класс EventEmitter, возможности которого можно задействовать для организации работы с событиями. Прежде чем воспользоваться этим механизмом, его нужно подключить:

const EventEmitter = require('events').EventEmitter


При работе с ним нам доступны, кроме прочих, методы on() и emit(). Метод emit используется для вызова событий. Метод on используется для настройки коллбэков, обработчиков событий, которые вызываются при вызове определённого события.

Например, давайте создадим событие start. Когда оно происходит, будем выводить что-нибудь в консоль:

eventEmitter = new EventEmitter();

eventEmitter.on('start', () => {
  console.log('started')
})


Для того чтобы вызвать это событие, используется следующая конструкция:

eventEmitter.emit('start')


В результате выполнения этой команды вызывается обработчик события и строка started попадает в консоль.

Обработчику событий можно передавать аргументы, представляя их в виде дополнительных аргументов метода emit():

eventEmitter.on('start', (number) => {
  console.log(`started ${number}`)
})
eventEmitter.emit('start', 23)


Похожим образом поступают и в случаях, когда обработчику надо передать несколько аргументов:

eventEmitter.on('start', (start, end) => {
  console.log(`started from ${start} to ${end}`)
})
eventEmitter.emit('start', 1, 100)


Объекты класса EventEmitter имеют и некоторые другие полезные методы:

  • once() — позволяет зарегистрировать обработчик события, который можно вызвать лишь один раз.
  • removeListener() — позволяет удалить переданный ему обработчик из массива обработчиков переданного ему события.
  • removeAllListeners() — позволяет удалить все обработчики переданного ему события.


Итоги


Сегодня мы поговорили об асинхронном программировании на JavaScript, в частности, обсудили коллбэки, промисы и конструкцию async/await. Здесь же мы коснулись вопроса работы с событиями, описываемыми разработчиком средствами модуля events. Нашей следующей темой будут механизмы организации сетевого взаимодействия платформы Node.js.

Уважаемые читатели! Пользуетесь ли вы, при программировании для Node.js, конструкцией async/await?

1ba550d25e8846ce8805de564da6aa63.png

© Habrahabr.ru