Когда я начинал писать на node.js, я ненавидел две вещи: все популярные шаблонизаторы и огромное количество callbacks. Я добровольно использовал callbacks, потому что понимал всю силу событийно-ориентированных серверов, но с тех пор в JavaScript появились генераторы, и я с нетерпением жду день, когда они будут имплементированы.
И вот этот день наступает. На сегодня генераторы доступны в V8 и SpiderMonkey, имплементация следует за обновлениями спецификации — это заря новой эры!
Пока V8 скрывает за флагом командной строки новые функции Harmony, такие как генераторы — так будет некоторое время. Перед тем, как они станут доступны во всех браузерах, мы можем идти вперед и учиться писать асинхронный код с генераторами. Давайте попробуем эти подходы на ранней стадии.
Вы можете использовать их сегодня, загрузив unstable версию node 0.11 (возможно на данный момент она уже стабильна). При запуске node передайте флаг --harmony
или --harmony-generators
.
Так как же использовать генераторы для спасения от callback hell? Генератор-функции могут приостанавливать выполнение с помощью оператора yield
, и передавать результат внутрь или наружу когда возобновляются или приостанавливаются. Этим способом мы можем сделать «паузу», когда функция ждет результат другой функции, не передавая callback в нее.
Разве это невесело, когда я пытаюсь объяснить языковые конструкции на нашем языке? Как на счет того, что-бы погрузиться в код.
Основы генераторов
Давайте посмотрим на примитивный генератор перед нашим погружением в асинхронный мир. Генераторы объявляются выражением function*
:
function* foo(x) {
yield x + 1;
var y = yield null;
return x + y;
}
Ниже приведен пример вызова:
var gen = foo(5);
gen.next(); // { value: 6, done: false }
gen.next(); // { value: null, done: false }
gen.send(8); // { value: 13, done: true }
Если бы я делал заметки в классе, я бы записал:
yield
разрешен во всех выражениях.- Вызов генератора идентичен обычной функции, но он создает объект генератора. Вам нужно вызывать
next
илиsend
для возобновления генератора.send
используется когда вы хотите отправить значение обратно в него.gen.next()
эквивалентенgen.send(null)
. Так-же естьgen.throw
, который бросает исключение внутрь генератора. - Методы генератора возвращают нечистое исходное значение, а объект с двумя параметрами:
value
иdone
. Благодаряdone
становится ясно, когда генератор закончен, либо сreturn
, либо простым концом функции, взамен неудобного исключенияStopIteration
, которое было в старом API.
Асинхронное решение №1: Приостановка
Что делать с кодом в котором callback hell? Хорошо, если мы можем произвольно приостанавливать выполнение функции. Мы сможем превратить наш асинхронный callback код обратно в синхронно-выглядящий код с крошкой сахара.
Вопрос: в чем сахар?
Первое решение предложено в библиотеке suspend. Это очень просто. Только 16 строк кода, серьезно.
Вот так выглядит наш код с этой библиотекой:
var suspend = require('suspend'),
fs = require('fs');
suspend(function*(resume) {
var data = yield fs.readFile(__filename, 'utf8', resume);
if(data[0]) {
throw data[0];
}
console.log(data[1]);
})();
Функция suspend
передает ваш генератор внутрь обычной функции, которая запускает генератор. Она передает функцию resume
в генератор, функция resume
должна использоваться в качестве callback для всех асинхронных вызовов, она возобновляет генератор с аргументами содержащие флаг error и value.
Танцы resume
и генератора интересны, но есть некоторые недостатки. Во-первых, полученный обратно массив из двух элементов неудобен, даже с деструктуризацией (var [err, res] = yield foo(resume)
). Я хотел бы возвращать только значение, и бросать ошибку как исключение, если она есть. На самом деле библиотека поддерживает это, но как вариант, я думаю, это должно быть по умолчанию.
Во-вторых, неудобно всегда явно передавать resume, более того, это непригодно, когда вы ждете пока функция выше завершится. И я все еще должен добавлять callback
и вызывать его в конце функции, как это обычно делается в node.
Наконец, вы не можете использовать более сложные потоки исполнения, например c несколькими параллельными вызовами. README утверждает, что другие библиотеки управления потоком исполнения уже решают эту проблему, и вы должны использовать suspend
вместе с одной из них, но я бы предпочел видеть библиотеку управления потоком включающую в себя поддержку генераторов.
Дополнение от автора: kriskowal предложил этот gist написанный creationix, там реализован улучшенный stand-alone обработчик генератора для callback-based кода. Это очень здорово, бросать ошибки по умолчанию.
Асинхронное решение №2: Promises
Более интересный способ управления асинхронным потоком исполнения — это использовать promises. Promise это некий объект, который представляет будущее значение, и вы можете предоставлять обещания (promises) в вызывающий поток исполнения программой, представляющей асинхронное поведение.
Я не буду объяснять promises здесь, так как это займет слишком много времени и, кроме того, уже есть хорошее объяснение. В последнее время был сделан акцент на определение поведения и API promises для взаимодействия между библиотеками, но идея довольно проста.
Я собираюсь использовать библиотеку Q для promises, потому что она уже имеет предварительную поддержку генераторов, а также достаточно зрелая. task.js был ранней реализацией этой идеи, но в нем была нестандартная реализация promises.
Давайте сделаем шаг назад и посмотрим на реальный пример из жизни. Мы слишком часто используем простые примеры. Этот код создает сообщение, затем получает его обратно, и получает сообщение с такими же тегами (client
является экземпляром redis):
client.hmset('blog::post', {
date: '20130605',
title: 'g3n3rat0rs r0ck',
tags: 'js,node'
}, function(err, res) {
if(err) throw err;
client.hgetall('blog::post', function(err, post) {
if(err) throw err;
var tags = post.tags.split(',');
var posts = [];
tags.forEach(function(tag) {
client.hgetall('post::tag::' + tag, function(err, taggedPost) {
if(err) throw err;
posts.push(taggedPost);
if(posts.length == tags.length) {
// сделать что-то с post и taggedPosts
client.quit();
}
});
});
});
});
Посмотрите, как этот пример уродлив! Callbacks быстро прижимают код к правой стороне нашего экрана. Кроме того, чтобы запросить все теги мы должны вручную управлять каждым запросом и проверять когда все они будут готовы.
Давайте приведем этот код к Q promises.
var db = {
get: Q.nbind(client.get, client),
set: Q.nbind(client.set, client),
hmset: Q.nbind(client.hmset, client),
hgetall: Q.nbind(client.hgetall, client)
};
db.hmset('blog::post', {
date: '20130605',
title: 'g3n3rat0rs r0ck',
tags: 'js,node'
}).then(function() {
return db.hgetall('blog::post');
}).then(function(post) {
var tags = post.tags.split(',');
return Q.all(tags.map(function(tag) {
return db.hgetall('blog::tag::' + tag);
})).then(function(taggedPosts) {
// сделать что-то с post и taggedPosts
client.quit();
});
}).done();
Мы должны были обернуть redis функции, и тем самым превратили callback-based в promise-based, это просто. Как только мы получили promises, вы вызываете then
и ждете результата асинхронных операций. Гораздо больше деталей объясняется в спецификации promises/A+.
Q
реализует несколько дополнительных методов, таких как all
, он берет массив promises и ждет пока каждый их них завершится. К тому же есть done
, который говорит что ваш асинхронный процесс завершился и любые необработанные ошибки должны быть брошены. Согласно спецификации promises/A+, все исключения должны быть преобразованы в ошибки и переданы в обработчик ошибок. Таким образом вы можете быть уверены, что все ошибки будут проброшены, если на них нет обработчика. (Если что-то не понятно, пожалуйста прочитайте эту статью от Доминика.)
Обратите внимание, насколько глубок финальный promise. Это так, потому что сначала нам нужен доступ к post
, а затем к taggedPosts
. Здесь чувствуется callback-style код, это досадно.
А сейчас самое время оценить силу генераторов:
Q.async(function*() {
yield db.hmset('blog::post', {
date: '20130605',
title: 'g3n3rat0rs r0ck',
tags: 'js,node'
});
var post = yield db.hgetall('blog::post');
var tags = post.tags.split(',');
var taggedPosts = yield Q.all(tags.map(function(tag) {
return db.hgetall('blog::tag::' + tag);
}));
// сделать что-то с post и taggedPosts
client.quit();
})().done();
Разве это не удивительно? Как же это на самом деле происходит?
Q.async
принимает генератор и возвращает функцию, которая управляет им, как и библиотека suspend. Однако, здесь ключевая разница в том, что генератор дает (yields) promises. Q принимает каждый promise и связывает с ним генератор, делает resume когда promise выполнен, и отправляет результат обратно.
Мы не должны управлять неуклюжей функцией resume
— promises полностью ее обрабатывает, и мы получаем преимущество поведения promises.
Одно из преимуществ в том, что мы можем использовать разные Q promises когда это необходимо, например Q.all
, который запускает несколько асинхронных операций параллельно. Таким образом можно легко объединить подобные Q promises и неявные promises в генераторах для создания сложных потоков выполнения, которые будут выглядить очень чисто.
Также отметим, что у нас нет проблемы вложенности вообще. Так как post
и taggedPosts
остаются в той же области видимости, мы не должны больше заботится об обрыве цепочки областей видимости в then
, что невероятно радует.
Обработка ошибок очень хитрая, и вы действительно должны понимать как работают promises прежде чем использовать их в генераторах. Ошибки и исключения в promises всегда передаются в функцию обработки ошибки, и никогда не бросают исключений.
Любой async
генератор это promise, без исключений (exceptions). Вы можете управлять ошибками с помощью error callback: someGenerator().then(null, function(err) { ... })
.
Однако, существует особое поведение promises генераторов, которое заключается в том, что любые ошибки от promises, брошенные в генератор с помощью специального метода gen.throw
, будут брошены исключением от той точки где генератор был приостановлен. Это означает, что вы можете использовать try/catch
для обработки ошибок в генераторе:
Q.async(function*() {
try {
var post = yield db.hgetall('blog::post');
var tags = post.tags.split(',');
var taggedPosts = yield Q.all(tags.map(function(tag) {
return db.hgetall('blog::tag::' + tag);
}));
// сделать что-то с post и taggedPosts
}
catch(e) {
console.log(e);
}
client.quit();
})();
Это работает именно так, как вы ожидаете: ошибки от любого вызова db.hgetall
будут обработаны в обработчике catch
, даже если это будет ошибка в глубоком promise внутри Q.all
. Без try/catch
исключение будет передано в обработчик ошибки вызывающего promise (если нет вызывающего, то ошибка будет подавлена).
Задумайтесь — мы можем устанавливать обработчики исключений с помощью try/catch для асинхронного кода. Динамическая область видимости обработчика ошибки будет корректной; любые необработанные ошибки, которые случаются пока блок try
выполняется, будут переданы catch
. Вы можете использовать finally
для создания уверенного «cleanup» кода при запуске даже для ошибки, без присутствия обработчика ошибок.
Кроме того, используйте done
всегда, когда вы используете promises — этим вы можете по умолчанию получать брошенные ошибки взамен спокойного игнорирования, которые слишком часто случаются с асинхронным кодом. Путь использования Q.async
, как правило, выглядит так:
var getTaggedPosts = Q.async(function*() {
var post = yield db.hgetall('blog::post');
var tags = post.tags.split(',');
return Q.all(tags.map(function(tag) {
return db.hget('blog::tag::' + tag);
}));
});
Выше представлен код библиотеки, который просто создает promises и не занимается обработкой ошибок. Вы вызываете его так:
Q.async(function*() {
var tagged = yield getTaggedPosts();
// сделать что-то с массивом tagged
})().done();
Это код верхнего уровня. Как было сказано ранее, метод done
гарантированно бросает ошибку для любой необработанной ошибки как исключение. Я считаю, что этот подход обычен, но нужно вызывать лишний метод. getTaggedPosts
будет использоваться promise-generating функциями. Код выше просто код верхнего уровня который наполнен promises.
Я предложил Q.spawn в pull request, и эти изменения уже попали в Q! Это позволяет делать простой запуск кода, который использует promises, еще проще:
Q.spawn(function*() {
var tagged = yield getTaggedPosts();
// сделать что-то с массивом tagged
});
spawn
принимает генератор, немедленно запускает его, и автоматически пробрасывает все необработанные ошибки. Это в точности эквивалентно Q.done(Q.async(function*() { ... })())
.
Другие подходы
Наш promised-based generator код начинает приобретать форму. Вместе с крупинками сахара, мы можем убрать много лишнего багажа связанного с асинхронным workflow.
После некоторого времени работы с генераторами, я выделил несколько подходов.
Не стоит
Если вы имеете короткую функцию которой нужно подождать только один promise, она не стоит того, чтобы создавать генератор.
var getKey = Q.async(function*(key) {
var x = yield r.get(dbkey(key));
return x && parseInt(x, 10);
});
Воспользуйтесь этим кодом:
function getKey(key) {
return r.get(dbkey(key)).then(function(x) {
return x && parseInt(x, 10);
});
}
Я думаю, что последняя версия выглядит чище.
spawnMap
Это то, что я делал часто:
yield Q.all(keys.map(Q.async(function*(dateKey) {
var date = yield lookupDate(dateKey);
obj[date] = yield getPosts(date);
})));
Может быть, полезно иметь spawnMap
, которая выполняет Q.all(arr.map(Q.async(...)))
за вас.
yield spawnMap(keys, function*(dateKey) {
var date = yield lookupDate(dateKey);
obj[date] = yield getPosts(date);
})));
Это аналогично методу map
из библиотеки async.
asyncCallback
Последнее, что я заметил: бывают моменты, когда я хочу создать Q.async
функцию и заставить пробрасывать все ошибки. Это происходит с нормальными callbacks из разных библиотек, такими как express: app.get('/url', function() { ... })
.
Я не могу преобразовать вышеупомянутый callback в Q.async
функцию, потому что тогда все ошибки будут спокойно подавлены, я также не могу использовать Q.spawn
потому что оно не выполняется немедленно. Возможно что-то вроде asyncCallback
будет хорош:
function asyncCallback(gen) {
return function() {
return Q.async(gen).apply(null, arguments).done();
};
}
app.get('/project/:name', asyncCallback(function*(req, res) {
var counts = yield db.getCounts(req.params.name);
var post = yield db.recentPost();
res.render('project.html', { counts: counts,
post: post });
}));
В качестве резюме
Когда я исследовал генераторы, я очень надеялся, что они помогут с асинхронным кодом. И, как оказалось, они действительно это делают, хотя вы должны понимать, как работают promises, чтобы эффективно объединить их с генераторами. Создание promises делает неявное еще более неявным, поэтому я бы не стал использовать async или spawn пока не поймете promise целиком.
Сейчас у нас есть лаконичный и невероятно мощный способ кодирования асинхронного поведения и мы можем использовать его для чего-то большего, чем просто делать операции для работы с ФС красивее. Фактически, мы имеем отличный способ писать краткий, распределенный код, который может работать на разных процессорах, или даже машинах, оставаясь синхронным.
Дополнение от автора: прочитайте мою следующую статью, Взгляд на генераторы без Promise.
Автор: PhilNehaev