Когда я начинал писать на 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.