Асинхронные API и объект Deferred в деталях

в 18:31, , рубрики: async, deferred, javascript, node.js, promise, метки: , , ,

Большинство современных языков программирования позволяют использовать асинхронно выполняемые блоки кода. Вместе с гибкостью, получаемой при использовании асинхронного подхода, рискнувший его применить также получает более сложный для понимания и поддержки код. Однако, любое усложнение, с которым сталкиваются программисты, как правило, находит практическое решение в виде нового подхода или повышения уровня абстракции. В случае асинхронного программирования таким средством служит объект типа отложенный результат или deferred (англ. deferred — отложенный, отсроченный).

В статье будет рассказано о базовых подходах к возврату асинхронных результатов, функциях обратного вызова, объектах deferred и их возможностях. Будут приведены примеры на языке JavaScript, а также произведён разбор типового объекта deferred. Статья будет полезна программистам, начинающим постигать асинхронное программирование, а также знакомым с ним, но не владеющим объектом deferred.

Синхронный и асинхронный вызовы

Любую функцию можно описать в синхронном и асинхронном виде. Предположим, что у нас есть функция calc, выполняющая некоторое вычисление.

В случае обычного, «синхронного» подхода, результат вычисления будет передаваться через возвращаемое значение, то есть результат будет доступен сразу же после выполнения функции, и может быть использован в другом вычислении.

var result = calc();
another_calc(result * 2);

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

В случае асинхронного вызова, мы не можем получить результат на месте. Вызывая функцию calc, мы лишь укажем на необходимость выполнить вычисление и получить его результат. При этом следующая строка начнёт выполняться не дожидаясь выполнения предыдущей. Тем не менее, получить результат нам как-то надо, и тут на помощь приходит коллбек (callback) — функция, которая будет вызвана системой по приходу результата вычисления. Результат будет передан в эту функцию как аргумент.

calc(function (result) {
   another_calc(result * 2);
});
no_cares_about_result();

Из примера видно, что функция теперь имеет сигнатуру: calc(callback), а callback принимает в качестве первого параметра результат.

Так как calc выполняется асинхронно, функция no_cares_about_result не сможет обратиться к её результату, и, вообще говоря, может быть выполнена раньше чем коллбек (если говорить конкретно о JavaScript, то гарантируется, что она всегда будет выполнена раньше; об этом будет рассказано чуть ниже).

Согласитесь, такой код уже стал несколько сложнее для восприятия, при той же смысловой нагрузке, что и его «прямолиненый» синхронный аналог. В чём же выгода от использования асинхронного подхода? Прежде всего — в разумном использовании ресурсов системы. Например, если calc является трудоёмким вычислением, которое может затратить много времени, или использует какой-то внешний ресурс, на использование которого накладывается определённая задержка, то при синхронном подходе весь последующий код будет вынужден ожидать результата и не будет выполняться, пока не выполнится calc. Используя асинхронный подход можно явно указать какой участок кода зависит от некоторого результата, а какой к результату индифферентен. В примере, no_cares_about_result явно не использует результат, и, следовательно, ему не требуется его ожидать. Участок кода внутри коллбека же будет выполнен только после получения результата.

Вообще говоря, большинство API, по своей природе, являются асинхронными, но могут мимикрировать под синхронные: доступ к удалённым ресурсам, запросы к БД, даже файловое API ­— асинхронные. Если API «притворяется» синхронным, то успех такого «притворства» связан с задержками результата: чем меньше задержка, тем лучше. То же файловое API, работая с локальной машиной, показывает небольшие задержки и зачастую реализуется как синхронное. Работа с удалёнными ресурсами и доступ к БД всё чаще реализуется асинхронно.

Многоуровневые вызовы

Сложности асинхронного подхода становятся более заметными, когда необходимо не просто сделать асинхронный вызов, а, получив его результат, что-то с ним сделать и использовать в другом асинхронном вызове. Очевидно, что здесь не подходит синхронный подход в несколько последовательно выполняемых строчек кода:

var result = calc_one();
result = calc_two(result * 2);
result = calc_three(result + 42);
// using result

Код приобретёт следующий вид:

calc_one(function (result) {
   calc_two(result * 2, function (result) {
      calc_three(result + 42, function (result) {
         // using result
      });
   });
});

Во-первых, данный код стал «многоуровневым», хотя, по выполняемым действиям он аналогичен синхронному. Во-вторых, в сигнатурах функций calc_two, calc_three смешаны входные параметры и коллбек, который, по сути является местом возврата результата, то есть выходным параметром. В-третьих, каждая функция может завершиться с ошибкой, и результат не будет получен.

Упростить данный код можно определением функций-коллбеков отдельно и передаче их по имени, однако, это не решение всех проблем. Тут требуется новый уровень абстракции, а именно, мы можем подвергнуть абстрагированию асинхронный результат.

Асинхронный результат

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

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

Важной отличительной особенностью такого объекта также является специфика его состояний. Такой объект может быть в двух состояниях: 1) нет результата и 2) есть результат. Причём переход возможен только из первого состояния во второе. Когда результат будет получен, то уже невозможно перейти в состояние его отсутствия или в состояние с другим результатом.

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

function Deferred () // constructor
function on (callback)
function resolve (result)

Метод on принимает коллбек. Коллбек будет вызван как только будет доступен результат и он будет передан в качестве параметра. Здесь полная аналогия с обычным коллбеком, передаваемым в качестве параметра. На момент регистрации коллбека объект может находиться в состоянии с результатом и без. В случае, если результата ещё нет, коллбек будет вызван по его приходу. В случае, если результат уже есть, коллбек будет вызван немедленно. В обоих случаях коллбек вызывается однократно и получает результат.

Метод resolve позволяет перевести (разрезолвить) объект в состояние с результатом и указать этот результат. Этот метод является идемпотентным, то есть повторные вызовы resolve не будут изменять объект. При переходе в состояние с результатом будут вызваны все зарегистрированные коллбеки, а все коллбеки, которые будут зарегистрованы после вызова resolve станут вызываться мгновенно. В обоих случаях (регистрация до и после вызова resolve) коллбэки будут получать результат, в силу того, что объект хранит его.

Объект с таким поведением называется deferred (а также известен под именами promise и future). Перед простыми коллбеками он имеет ряд преимуществ:

1. Абстрагирование асинхронной функции от результата: теперь каждой асинхронной функции не требуется предоставлять параметры-коллбеки. Подписка на результат остаётся за клиентом кода. Например, можно вообще не подписываться на результат, если он нам не нужен (аналогичен передачи noop-функции в качестве коллбека). Интерфейс асинхронной функции становится чище: он имеет только значимые входные параметры.
2. Абстрагирование от состояния результата: клиенту кода не нужно проверять текущее состояние результата, он просто подписывает обработчик и не задумывается, пришёл результат или ещё нет.
3. Возможность множественной подписки: можно подписать более одного обработчика и все они будут вызваны по приходу результата. В схеме с коллбеками пришлось бы создавать функцию, которая вызывает группу функций, например.
4. Ряд дополнительных удобств, в числе которых, например, «алгебра» объектов deferred, которая позволяет определять отношения между ними, запускать их в цепочке или после успешного завершения группы таких объектов.

Рассмотрим следующий пример. Пусть имеется асинхронная функция getData(id, onSuccess), которая принимает два параметра: id некоторого элемента, который мы хотим получить и коллбек для получения результата. Типичный код её использования будет выглядеть так:

getData(id, function (item) {
   // do some actions with item
});

Перепишем это с использованием Deferred. Функция теперь имеет сигнатуру getData(id) и используется следующим образом:

getData(id).on(function (item) {
   // do some actions with item
});

В данном случае код практически не усложнился, скорее просто изменился подход. Результат теперь передаётся через возвращаемое значение функции в качестве deferred. Однако, как станет заметно далее, в более сложных случаях использование deferred даёт некоторое преимущество в читаемости кода.

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

Резонным будет вопрос об обработке ошибок при использовании таких объектов. В синхронном коде широко используется механизм исключений, который позволяет в случае ошибки передавать управление в вышестоящие блоки кода, где все ошибки могут быть отловлены и обработаны, существенно не усложняя «местный» код, освобождая программиста от необходимости писать проверки на каждый чих.
В асинхронном коде (и в любой схеме с коллбеками) существует некоторое затруднение при использовании исключений, потому как исключение будет приходить асинхронно, как и результат, и потому его нельзя будет просто отловить, обрамив вызов асинхронной функции в try. Если рассмотреть ошибку, то по сути, это лишь иной результат функции (можно сказать, отрицательный, но тоже результат), при этом в качестве возвращаемого значения выступает объект ошибки (исключения).

Такой результат, также как и успешный, реализуется в виде коллбека (который иногда называется errback, игра слов от error и back).

Давайте усилим наш учебный объект Deferred так, чтобы он мог предоставлять подписку отдельно на успех и на неудачу, а именно переработаем методы on и resolve.

function on (state, callback)

В качестве первого параметра можно передавать значение перечислимого типа с двумя значениями, например E_SUCCESS, E_ERROR. Для читаемости, будем использовать в примерах простые строковые значения: 'success', 'error'. Также, усилим данный метод, обязав его возвращать сам объект Deferred. Это позволит использовать цепочки подписок (приём весьма характерный конкретно для JavaScript).

Соответственно изменяется и метод resolve:

function resolve (state, result)

В качестве первого параметра передаётся состояние, в которое должен перейти объект Deferred (error, success), а в качестве второго — результат. На такой модифицированный объект по-прежнему распространяется правило состояний: после перехода в состояние с результатом, объект не может изменить своё состояние на иное. Это означает, что если объект перешёл, например, в состояние success, то все обработчики, зарегистрированные на ошибку не сработают никогда, и наоборот.

Итак, пусть наша функция getData может завершиться с некоторой ошибкой (нет данных, неправильные входные данные, сбой и т.п.).
Код примет следующий вид:

getData(id)
.on('success', function (item) {
   // do some actions with item
})
.on('error', function (err_code) {
   // deal with error
});

Рассмотрим более реалистичный пример, а именно, возьмём типовой метод fs.readFile из стандартной библиотеки Node.js. Этот метод служит для чтения файла. В начале статьи упоминалось, что практически любую функцию можно написать либо в синхронном, либо в асинхронном стиле. В стандартной библиотеке Node.js файловое API определено в обоих стилях, у каждой функции есть свой синхронный аналог.

Для примера мы используем асинхронный вариант readFile и адаптируем его под использование Deferred.

function readFileDeferred (filename, options)
{
   var result = new Deferred;
   fs.readFile(filename, options, function (err, data)
   {
      if (err)
      {
         result.resolve('error', err);
      }
      else
      {
         result.resolve('success', data);
      }
   });
   return result;
}

Такая функция несколько удобней в использовании, потому как позволяет регистрировать функции на успех и на ошибку отдельно.

Описанной функциональности вполне достаточно для преобладающего большинства случаев, но deferred имеет больший потенциал, о чём будет рассказано ниже.

Расширенные возможности объектов Deferred

1. Неограниченное количество вариантов результата. В примере был использован объект Deferred с двумя возможными результатами: success и error. Ничто не мешает использовать любые другие (кастомные) варианты. Благо, мы использовали строковое значение в качестве state, это позволяет определять любой набор результатов, не изменяя никакой перечислимый тип.
2. Возможность подписки на все варианты результата. Это может быть использовано для разного рода обобщённых обработчиков (наибольший смысл это имеет вкупе с пунктом 1.).
3. Создание суб-объекта promise. Из интерфейса объекта Deferred видно, что клиентский код имеет доступ к методу resolve, хотя, по сути, ему требуется только возможность подписки. Суть данного улучшения состоит во введении метода promise, который возвращает «подмножество» объекта Deferred, из которого доступна только подписка, но не установка результата.
4. Передача состояния от одного deferred к другому, опционально, подвергая результат преобразованию. Это бывает очень полезным при многоуровневых вызовах.
5. Создание deferred, который зависит от результата набора других deferred. Суть данного улучшения в том, чтобы подписываться на результат группы асинхронных операций.
Пусть нам нужно зачитать два файла и сделать с обоими что-нибудь интересное. Используем нашу функцию readFileDeferred для этого:

var r1 = readFileDeferred('./session.data'),
    r2 = readFileDeferred('./data/user.data');

var r3 = Deferred.all(r1, r2);

r3.on('success', function (session, user) {
   session = JSON.parse(session);
   user = JSON.parse(user);
   console.log('All data recieved', session, user);
}).on('error', function (err_code) {
   console.error('Error occured', err_code);
});

Deferred.all создаёт новый объект Deferred, который перейдёт в состояние success, если все переданные аргументы перейдут в это состояние. При этом он также получит результаты всех deferred в качестве аргументов. Если хотя бы один аргумент перейдёт в состояние error, то и результат Deferred.all также перейдёт в это состояние, и получит в качестве результата результат аргумента, перешедшего в состояние error.

Особенности deferred в JavaScript

Стоит отметить тот момент, что в JavaScript отсутствует многопоточность. Если коллбек был установлен по setInterval / setTimeout или по событиям, он не может прервать выполнение текущего кода, или выполняться параллельно с ним. Это означает, что даже если результат асинхронной функции придёт мгновенно, он всё равно будет получен лишь после завершения выполнения текущего кода.

В JavaScript функции могут вызываться с любым числом параметров, а также с любым контекстом. Это позволяет передавать в коллбеки столько параметров, сколько потребуется. Например, если асинхронная функция возвращает пару значений (X, Y), то их можно передать в виде объекта с двумя полями, или списка с двумя значениями (импровизированный аналог кортежа), а можно использовать два первых аргумента коллбека для этой цели.

Вызов коллбека в таком случае может принимать следующий вид:

callback.call(this, X, Y);

В JavaScript используются ссылки, а освобождение памяти контролируется сборщиком мусора. Объект deferred нужен как внутри асинхронной функции (чтобы просигнализировать о приходе результата), так и снаружи (чтобы получить результат), в языках с более строгими моделями работы с памятью следует озаботиться правильной обработкой времени жизни такого объекта.

Существующие deferred

1. В jQuery существует объект $.Deferred (документация). Поддерживает подписку на success, error, также поддерживаются progress-нотификации: промежуточные события, генерируемые до прихода результата; можно передавать состояние другому Deferred (метод then), можно регистрировать Deferred по результату списка Deferred ($.when), можно создавать promise.
Все ajax-методы библиотеки возвращают promise такого объекта.
2. Dojo Toolkit содержит объект Deferred (документация).
3. В братском языке Python, в event-driven фреймворке Twisted есть объект Deferred со схожими возможностями (документация).
Поддерживает подписку на success, error и на оба результата. Можно ставить объект на паузу.
4. Охваченный интересом к Deferred, я написал свой вариант данного объекта (документация, исходный код,тесты). Поддерживается ряд возможностей, описанных в данной статье.

На этом всё, благодарю за внимание.

Автор: StreetStrider

Источник

Поделиться

* - обязательные к заполнению поля