Исследуем JavaScript Generators

в 16:26, , рубрики: ecmascript harmony, harmony, javascript, node.js, promise, Веб-разработка

Исследуем JavaScript Generators

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

Источник

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


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js