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

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

Алгоритм примерно такой:

  1. получаем от клиента id товара(ов) методом POST
  2. смотрим цену этих товаров в БД
  3. кладем заказ к себе в базу
  4. отправляем POST запрос платежной системе, чтобы она вернула нам уникальную ссылку для оплаты
  5. если всё ок, отдаем эту ссылку клиенту
  6. Перед тем, как писать злой коммент, надо представить что мы живем в идеальном мире и у нас не бывает ошибок от клиентов, в базе, в запросах..., так что во всём коде ниже их обработку я оставлю на волю судьбы, потому что это не интересно

Обычный код будет выглядеть как-то так:

const http = require('http');
const request = require('request');
const mysql = require('mysql')
.createConnection()
.connect()

http.createServer( function(req, res){

    // сохраним тут ссылку на объект res, чтобы потом через него можно было ответить клиенту
    let clientRes = res;

    // пришел запрос
    // ... получаем и парсим POST данные
    // предположим что пришедшие id распарсились так:
    let order = {
        ids: [10,15,17],
        phone: '79631234567'
    }

    // лезем в БД
    mysql.query('select cost from myshop where id in', order.ids, function(err, res){

        // в res у нас лежат все строки из базы. Посчитаем сумму заказа (я знаю, что можно сумму посчитать в запросе. Но у нас может быть более сложная логика обработки заказа)
        let totalPrice = 0;
        for(let i = 0; i < prices.result.length; i++){
            totalPrice += prices.result[i].price;
        }

        // сохраняем заказ у себя в базе (сохраняем как быдло, все товары в одну строку)
        mysq.query('insert into orders set client=?, ids=?, status=1', [order.phone, order.ids.join(',')], function(err, res){

            // mysql возвратит insertId, его мы укажем как номер заказа в платежной системе
            let insertId = res.insertId;
            request.post('http://api.payment.example.com', {form: {

                name: `оплата заказа ${insertId}`,
                amount: totalPrice

            }}, function(err, res, body){

                // парсим JSON ответ от платежки
                let link = JSON.parse(body).link;
                
                // отвечаем клиенту ссылкой
                clientRes.end(link);

                // обновим в базе статус заказа клиента, типа он получил ссылку на оплату
                mysql.query('update orders set status=2 where id=?', insertId, function(err, res){

                	console.log(`Статус заказа ${insertId} обновлен`);

                });

            });

        });

    });

}).listen(8080);


Сколько колбэков насчитали?

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

А как вам такой код?

const http = require('http');
const request = require('request');
const promise = require('promise');
const mysql = require('mysql')
.createConnection()
.connect()

http.createServer( async function(req, res){

    // сохраним тут ссылку на объект res, чтобы потом через него можно было ответить клиенту
    let clientRes = res;

    // пришел запрос
    // ... получаем и парсим POST данные
    // предположим что пришедшие id распарсились так:
    let order = {
        ids: [10,15,17],
        phone: '79631234567'
    }

    // первый запрос
    let selectCost = await promise(mysql, mysql.query, 'select cost from myshop where id in', order.ids);

    // оп, и у нас есть selectCost{ err: ..., res: ... }
    // считает сумму
    let totalPrice = 0;
    for(let i = 0; i < prices.result.length; i++){
        totalPrice += prices.result[i].price;
    }

    // сохраняем заказ у себя в базе
    let newOrder = await promise(mysql, mysql.query, 'insert into orders set client=?, ids=?, status=1', [order.phone, order.ids.join(',')]);

    let insertId = newOrder.res.insertId;

    // и опять без колбэка
    // кидаем запрос в платежку
    let payment = await promise(request, request.post, {form: {

                name: `оплата заказа ${insertId}`,
                amount: totalPrice

            }});

    // парсим ответ от платежки и возвращаем ссылку клиенту
    let link = JSON.parse(payment.res.body).link;
                
    // отвечаем клиенту ссылкой
    clientRes.end(link);

    // обновляем статус заказа
    let updateOrder = await promise(mysql, mysql.query, 'update orders set status=2 where id=?', insertId);

    console.log(`Статус заказа ${insertId} обновлен`);

}).listen(8080);

Ни единого колбека, Карл! Выглядит прямо как в php. Что же происходит под капотом в функции promise? Магия достаточно простая. В основном все функции передают в колбэк 2 объекта: error и result. В этом и будет заключаться универсальность использования функции:

"use strict"

function promise(context, func, ...params){

  // тут мы принимаем контекст, саму функцию (метод, если хотите) и всё что нужно передать в этот метод
  // оборачиваем вызов в промис
  return new Promise( resolve => {

    // собственно, вызов из нужного контекста 
    func.call(context, ...params, (...callbackParams) => {

      // а это наш колбэк, который мы отдаем в resolve, предварительно отпарсив результат в удобный вывод (см. ниже ф-ию promiseToAssoc);

      let returnObject = promiseToAssoc([...callbackParams]);

        resolve( returnedObject );

      })
    })
}

/* вспомогательная функция для разбора ответа от промисифицированной функции */

function promiseToAssoc(results){

  let res = {};
  // первые 3 объекта, которые приходят в колбэк мы по дефолту назовем err, res и body
  let assoc = ['err', 'res', 'body'];

  for(let i = 0; i < results.length; i++){
    // остальные объекты (если они есть) будем называть field_3, field_4 и тд.
    let field = assoc[i] || `field_${i}`;
    res[ field ] = results[i];
  }

  return res;
}

module.exports = promise;

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

let result = await promise(fs, fs.readFile, 'index.html');
// result = {err: null, res: 'содержимое файла'}

Такие дела. Засовываем последний кусок кода в файл с названием promise, потом в том месте, где нужно избавиться от колбэка пишем const promise = require('./promise');

Есть и более изящные методы использования. Можно прокидывать туда названия параметров, которые хотим получить.

Например, при проверке файла на существование (fx.exist), метод возвращает только одно значение, true или false. И при текущем коде пришлось бы использовать if ( fileExist.err ) console.log('файл найден'), что не есть хорошо. Ну и неплохо было бы повесить ошибку на reject.

Так же можно выполнять и параллельные запросы в цикле, типа

var files = ['1.csv', '2.csv', '3.csv'];
var results = [];
// всех вызываем
for(let i = 0;i<files.length;i++){
  results.push( promise(fs, fs.readFile, files[i]) );
}
// а потом дожидаемся всех (при это все 3 могут придти одновременно, тоесть не нужно ждать каждый отдельно. Если третий прочитается быстрее чем первый, он уже будет готов )
for(let i = 0;i<files.length;i++){
  results[i] = await results[i];
}

Код используется в продакшне с максимальной зафиксированной нагрузкой ~300 запросов в секунду к нашему API (нет, это не интернет магазин из примера) около полугода (как раз тогда вышла нода 8 версии с поддержкой async/await). Небыло замечено багов, утечек или потери производительности, в сравнении с теми же колбэками или промисами в чистом виде. Как часы. Причем каждый метод генерит от 0 до 10 таких await-ов.

Статья не претендует на Нобелевсую премию, и такой код даже страшно выложить в github. Голова готова для камней «костыли/говнокод/велосипед», но тем не менее, надеюсь, кому то такая идея использования может оказаться интересной.

Пишите в комменты, как вы используете async/await?

Комментарии (18)


  1. RidgeA
    19.11.2017 19:54

    1. async-await в NodeJS завезли уже достаточно давно, раньше, чем 8 версия.
    Если не ошибаюсь — еще с 6.* с --harmony было.

    2. Для оборачивания callback кода есть util.promisify

    3. Возвращать объект с ошибкой и полезными данными — ИМХО очень плохо. async-await позволяет обрабатывать ошибки в асинхронных функциях в try-catch

    4. А вот это вообще ни в какие ворота

    var files = ['1.csv', '2.csv', '3.csv'];
    var results = [];
    // всех вызываем
    for(let i = 0;i<files.length;i++){
      results.push( promise(fs, fs.readFile, files[i]) );
    }
    // а потом дожидаемся всех (при это все 3 могут придти одновременно, тоесть не нужно ждать каждый отдельно. Если третий прочитается быстрее чем первый, он уже будет готов )
    for(let i = 0;i<files.length;i++){
      results[i] = await results[i];
    }
    

    используйте developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/Promise/all

    FYI пакеты, которые предоставляют promise-api:
    www.npmjs.com/package/mysql2
    www.npmjs.com/package/request-promise-native
    www.npmjs.com/package/fs-extra


    1. axeax Автор
      19.11.2017 20:08

      2) promisify отдаст нам then и catch, что лично для меня выглядит как колбэк
      3) try/catch слишком накладно для realtime под нагрузкой
      4) ну извините) есть некоторые цели, например когда несколько разных запросов мы хотим выполнить параллельно, а результат отрабатывать постепенно. Например в случае с базой там могут использоваться транзакции, которым по какой то причине иногда нужно делать rollback. Так что по ситуации, для моего способа есть определенные "ворота"


      1. RidgeA
        19.11.2017 20:24

        2. Promisify отдаст Promise (точно так же как и ваша функция). Что мешает делать await для промиса? Более того, async-функция возвращает promise с then и catch.

        const {promisify} = require('util');
        const {readFile} = require('fs');
        const readFilePromise = promisify(readFile);
        async run() {
          const fileData = await readFilePromise('./package.json', 'utf8');
        }
        run().catch(console.error);
        

        3. Если нагрузка такова, что try-catch играет роль — это задача ИМХО не для NodeJS. При таком подходе (предложенном вами) читаемость сильно страдает.
        4. Не понял чем мешает Promise.all. Если выполняется несколько запросов к бд внутри транзакции, то что мешает сделать что-то вроде (не уверен, кстати, будет ли это вообще иметь смысл, т.к. возможно внутри транзакции все запросы выполняются последовательно)
        try {
          // только если запросы между собой независимы, что бывает далеко не всегда.
          const result = await Promise.all(arrayWithDBQueriesPromises); 
          await transaction.commit();
        } catch() {
          await transaction.rollback();
        }
        

        в сетевых запросах, в чтении файлов и множестве других сценариев, Promise.all вообще не мешает, а только помогает.


        1. axeax Автор
          19.11.2017 20:33

          4) Await в цикле чтобы не дожидаться остальных запросов (как это делает promise.all), если на каком то этапе нужно все отменить.
          3) вот тут достаточно убедительно https://m.habrahabr.ru/company/ruvds/blog/334806/
          4) внутри транзакции могут быть не только запросы к базе, но и запросы в микросервисы или сторонние апи. В ситуации, когда всё работает — ваш вариант правильнее. Если что-то отваливается внутри — то мой вариант практичнее


          1. RidgeA
            19.11.2017 20:48

            3. Цитата из приведенной вами статьи

            Однако, в Node 8.3+ вызов функции из блока try на производительность практически не влияет.

            И еще раз. Программы пишутся в первую очередь для людей. Я нигде не видел, что бы подход, приведенный вами практиковался в JS, зачем усложнять жизнь другим программистам?
            Да, try-catch может влиять на производительность, если это узкое горлышко, то задача не для NodeJS.

            4) Promise.all возвращает результат (либо в .catch(), либо в try-catch(e) {} ) если хотя бы один из промисов вернул ошибку, и не надо будет дожидаться всех остальных.


            1. axeax Автор
              19.11.2017 21:03

              8.3 еще небыло, и неизвестно как оно будет работать в дальнейшем
              Точно так же я не видел чтобы использование try/catch являлось хорошим тоном в продакшне. Если можно обойтись без него — буду обходиться без него. Зачем полагаться на исключение и ждать его, если простой проверкой можно предотвратить?
              Нет, это не узкое горлышко, но одна из оптимизаций, как в плане производительности так и в плане качества.

              Промис вернет ошибку на уровне метода, а не на уровне логики (обработки результата запроса)

              Я не говорю что лучше а что хуже. Я говорю что в каждой конкретной ситуации/проекте/компании могут применяться свои методы. И любой из вариантов имеет право на жизнь.


              1. RidgeA
                19.11.2017 21:08

                8.3 еще небыло

                Это о NodeJS?
                тогда советую проверить nodejs.org/en
                8.9.1 уже в LTS, и 9-я подоспела.


                1. axeax Автор
                  19.11.2017 21:13

                  Речь шла про пол года назад. Дальше про орфографию и пунктуацию напишите, или по факту аргументируете почему считаете использование try/catch целесообразным ВЕЗДЕ и await в цикле «ни в какие ворота»?


                  1. RidgeA
                    19.11.2017 21:16

                    Речь шла про пол года назад.

                    Статья опубликована сейчас. И комментарий вы писали сейчас, а не пол года назад.
                    1. Я не считаю try-catch целесобразным ВЕЗДЕ, но придумывать какие-то механизмы, что бы их не использовать, но возвращать ошибку в качестве второго аргумента, и не использовать механизмы, которые предоставляет язык, это я считаю неверным. Как я уже писал причина — в языке используются другие подходы для обработки ошибок и, я уверен, большая часть программистов, привыкла использовать их, а не придуманные решения только что бы не использовать try-catch т. к. это часть оптимизации (по факту — микрооптимизация, высока вероятность, что в будущем этот механизм в V8 будет оптимизирован еще сильнее).
                    2 — Приведенная вами конструкция, призванная заменить Promise.all — опять же зачем, если есть инструмент языка, зачем эти велосипеды?


  1. little-brother
    19.11.2017 20:12

    Операции с массивами принципиально не используете?
    // Вместо
    let totalPrice = 0;
    for(let i = 0; i < prices.result.length; i++){
    totalPrice += prices.result[i].price;
    }

    // Просто
    let totalPrice = 0;
    prices.result.forEach((item) => totalPrice += item.price);

    // Или чуть более изысканно
    let totalPrice = prices.result.reduce((total, item) => total + item.price, 0);


    1. axeax Автор
      19.11.2017 20:16

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


      1. little-brother
        19.11.2017 20:17

        Стрелочные функции уже давно завезли в браузеры. А forEach и reduce, еще раньше.


        1. axeax Автор
          19.11.2017 20:21

          В андроид браузере нету. А многие приложения с webview открывают ссылки именно там. А с новыми фичами типа foreach/filter/reduce еще печальнее


          1. Reon
            19.11.2017 22:23
            +1

            А что вы подразумеваете под словом «андроид браузер»?


            1. zelenin
              20.11.2017 07:21

              предположу, что нативный браузер, последний раз включенный в андроид в версии 4.4, то есть 4 года назад.


  1. xGromMx
    20.11.2017 01:12

    Почему в js сообществе пишут вещи столетней давности? Более интересных тем нет?


    1. kahi4
      20.11.2017 10:21

      Есть, но пока все крайне сырое :(


      Observable на уровне языка (сейчас можно просто использовать rxjs)


      Pattern Matching


      Я хотел на выходных написать статью о ReasonML, но у меня стандратный пример не компилировался, а просто переводить документашку не интересно, так что пока ждем.


      1. xGromMx
        20.11.2017 10:49

        Про обзервабл — с 2013 года уже не интересно мне давно
        Про паттерн матчинг — пусть покажут хоть какую-то реализацию (да и это будет актуально интересно только для deep pattern matching)
        ReasonML нафиг не нужен! Еще один способ сделать OCaml еще хуже по виду. Для жаваскриптеров же есть Elm, Purescript