В этом месяце выходит десятая версия Node.js, в которой нас ждет изменение поведения потоков (readable-stream), вызванное появлением асинхронных циклов for-await-of. Давайте разберемся что это такое и к чему нам готовиться.


Конструкция for-await-of.


Для начала давайте разберемся с тем как работают асинхронные циклы на простом примере. Для наглядности добавим завершившиеся промисы.


const promises = [
    Promise.resolve(1),
    Promise.resolve(2),
    Promise.resolve(3),
];

Обычный цикл пройдется по массиву promises и вернет сами значения:


for (const value of promises) {
    console.log(value);
}
// > Promise({resolved: 1})
// > Promise({resolved: 2})
// > Promise({resolved: 3})

Асинхронный цикл дождется разрешения промиса и вернет возвращаемое промисом значение:


for await (const value of promises) {
    console.log(value);
}
// > 1
// > 2
// > 3

Чтобы асинхронные циклы заработали в более ранних версиях Node.js используйте флаг --harmony_async_iteration.

ReadableStream и for-await-of


Объект ReadableStream получил свойство Symbol.asyncIterator, что позволяет ему также быть переданным в for-await-of цикл. Возьмем для примера fs.createReadableStream:


const readStream = fs.createReadStream(file);

const chunks = [];
for await (const chunk of readStream) {
    chunks.push(chunk);
}

console.log(Buffer.concat(chunks));

Как видно из примера теперь мы избавились от вызовов on('data', ... и on('end', ..., а сам код стал выглядеть нагляднее и предсказуемее.


Асинхронные генераторы


В некоторых случаях может понадобится дополнительная обработка получаемых данных, для этого используются асинхронные генераторы. Мы можем реализовать поиск по файлу регулярного выражения:


async function * search(needle, chunks) {
    let pos = 0;

    for await (const chunk of chunks) {
        let string = chunk.toString();

        while (string.length) {
            const match = string.match(needle);

            if (! match) {
                pos += string.length;
                break;
            }

            yield {
              index: pos + match.index,
              value: match[0],
            };

            string = string.slice(match.index + match[0].length);
            pos += match.index;
        }
    }
}

Посмотрим что получилось:


const stream = fs.createReadStream(file);

for await (const {index, value} of search(/(a|b)c/, stream)) {
    console.log('found "%s" at %s', value, index);
}

Согласитесь, достаточно удобно, мы на лету превратили строки в объекты и нам не понадобилось использовать TransformStream и думать, как перехватывать ошибки, которые могут возникнуть в двух разных стримах и т.п.


Пример Unix-like потоков


Задача с чтением файла достаточно распространенная, но не исчерпывающая. Давайте рассмотрим случаи, когда требуется потоковая обработка вывода наподобие unix-конвейеров. Для этого воспользуемся асинхронными генераторами, через которые пропустим результат выполнения команды ls.


Сначала мы создадим дочерний процесс const subproc = spawn('ls') и затем будем читать стандартный вывод:


for await (const chunk of subproc.stdout) {
    // ...
}

А так как stdout генерирует вывод в виде объектов Buffer, то первым делом добавим генератор, который будет приводить вывод из типа Buffer в String:


async function *toString(chunks) {
    for await (const chunk of chunks) {
        yield chunk.toString();
    }
}

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


async function *chunksToLines(chunks) {
    let previous = '';
    for await (const chunk of chunks) {
        previous += chunk;
        while (true) {
            const i = previous.indexOf('\n');
            if (i < 0) {
                break;
            }
            yield previous.slice(0, i + 1);
            previous = previous.slice(i + 1);
        }
    }

    if (previous.length > 0) {
        yield previous;
    }
}

Так как каждое найденное значение все еще содержит перенос строки, создадим генератор для очистки значения от висящих пробельных символов:


async function *trim(values) {
    for await (const value of values) {
        yield value.trim();
    }
}

Последним действием будет непосредственно построчный вывод в консоль:


async function print(values) {
    for await (const value of values) {
        console.log(value);
    }
}

Объединим полученный код:


async function main() {
    const subproc = spawn('ls');

    await print(trim(chunksToLines(toString(subproc.stdout))));

    console.log('DONE');
}

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


function pipe(value, ...fns) {
    let result = value;

    for (const fn of fns) {
        result = fn(result);
    }

    return result;
}

Теперь вызов можно привести к следующему виду:


async function main() {
    const subproc = spawn('ls');

    await pipe(
        subproc.stdout,
        toString,
        chunksToLines,
        trim,
        print,
    );

    console.log('DONE');
}

Оператор |>


Нужно иметь в виду, что в скором времени в стандарт JS должен войти новый оператор конвейера |> позволяющий делать тоже самое что сейчас делает pipe:


async function main() {
    const subproc = spawn('ls');

    await subproc.stdout
    |> toString
    |> chunksToLines
    |> trim
    |> print;

    console.log('DONE');
}

Вывод


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


Основу для данной статьи составил материал Акселя Раушмайера Using async iteration natively in Node.js.

В продолжение темы


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


  1. surefire
    19.04.2018 23:20

    Но для построчного чтения не нужен велосипед. Ведь уже есть Readline


  1. amaksr
    19.04.2018 23:29

    У меня одного ощущение, что то, что приходит на смену аду коллбеков выглядит еще страшнее? Всего этого можно было избежать если бы язык проектировали не на коленке.


    1. DSolodukhin
      20.04.2018 00:09

      Язык проектировали не на коленке, просто проектировали его совсем для других задач. То, что его пытаются использовать в качестве языка общего назначения, не проблема JavaScript.


    1. mak_ufo
      20.04.2018 00:31
      +2

      Что именно вас пугает? Если вы про оператор |>, то он появился задолго до js. И люди, пишущие на F# и Elixir, например, не испытывают никакого отвращения


    1. kalininmr
      20.04.2018 02:44

      неа. синтаксис async/await очень красивый и удобный.
      генераторы вобще прелесть.

      тут пример немного неудачный


    1. BerkutEagle
      20.04.2018 05:45
      -1

      У меня одного ощущение, что ада коллбеков не существует. Что это понятие возникло в умах не очень квалифицированной толпы «программистов». И из-за них теперь JS со всех сторон засыпают сахаром. Но ведь внутри то всё тоже самое — всё те же коллбеки, всё тот же event loop в одном потоке.
      И если бывалые JS-ники видят в сахаре уменьшение количества нажатий клавиш, то новички не увидят из-за него всю суть происходящего.
      А потом мы опять все будем удивляться тормознутости современных web-приложений.


      1. faiwer
        20.04.2018 07:44

        А что вы предлагаете? Вам нужен GoScript? В угоду новичков надо перестать усложнять язык? Но ведь этот язык нынче используется для построения больших и сложных систем. А там нужен сахар, иначе начнут появляться Kotlin-ы, Scala-ы, CoffeeScript-ы и пр… Ещё года 2-3 назад немалое количество "js"-кода было написано на "кофе", и это не спроста. Теперь же вопрос стоит только в: нужна строгая типизация (Flow, TS) или нет (JS).


        1. BerkutEagle
          20.04.2018 11:28
          +1

          Я не против нововведений! Я против позиции «раньше был ад коллбеков, а сейчас наступит рай async/await'ов»


          1. faiwer
            20.04.2018 12:02
            +1

            А что не так с async/await-ми? Мне в голову приходят только две вещи:


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

            При этом:


            • 1-ый пункт касается любых усложнений, селяви.
            • 2-ой пункт это неизбежность (если отбросить fiber-ы, но там тоже есть обёртки)

            При этом код с async-ми в большинстве случаев многократно понятнее и приятнее, чем ручная работа с promise-ми. А матрёшки и прочие структуры из callback-ов так вообще кошмар из прошлого. Я успел повозиться со всеми 3 подходами и async-await это как глоток свежего воздуха.


            1. BerkutEagle
              20.04.2018 15:46

              С async/await всё отлично. Но ведь и с коллбеками не плохо.
              И знать надо и то и другое, чтоб не «нагородить фигни».
              А сейчас преподносится так: async/await — это круто, а коллбеки — фу-фу-фу.


              1. faiwer
                20.04.2018 15:50
                +1

                И знать надо и то и другое, чтоб не «нагородить фигни».

                Тут спору нет


                А сейчас преподносится так: async/await — это круто, а коллбеки — фу-фу-фу.

                Почти всегда это так. Вы ведь про старые (err, result) => {} callback-и? Они буквально всегда фу-фу-фу. Это очень неудобный подход в организации кода, в деле обработки ошибок, в совместимости с современным async-стеком и даже банальной читаемости. Это исключительный случай, когда callback-и выглядят в async-онном коде уместнее, чем promise-ы и async-функции.


                Если не согласны — приведите, пожалуйста, пример.


                Если же вы подразумеваете callback-и в .then в промисах, то на них никто бочку и не гонит. Это то же самое, что и async-await, и используется повсеместно.


      1. swandir
        22.04.2018 01:27
        +1

        Суть callback hell в двух вещах:


        1. Они не компонуются. Если для последовательного выполнения можно обойтись пирамидой из замыканий, то для параллельного — ещё хуже если смеси параллельного и последовательного — уже не обойтись без вспомогательных средств.


        2. Осложнена обработка ошибок. try/catch не работают, приходится полагаться на конвенцию по передаче ошибки в аргументах функции. Здесь очень просто что-то может пойти не так, в особенности в сочетании с первым пунктом.

        Решением были промисы. Они компонуются с помощью методов и инкапсулируют обработку ошибок, даже try/catch опять работают.


        Да, async/await это просто синтаксический сахар поверх Promise, но он позволяет значительно проще работать с последовательным выполнением с циклами и ветвленями, когда количество или последовательность асинхронных операций не заданы статически.


        for-await-of ещё интереснее, потому что это новый протокол итерирования. Вот есть в JS синхронный протокол. Это удобно, потому что всё что итерируется, можно использовать в for или вот так [...iter]. Но тут проблема в том, что информация о конце итерирования должна передаваться синхронно. Поэтому нужен новый протокол.


        Для простого итерирования по промисам он не обязателен. А вот работа со стримами или генераторами раскрывает его потенциал. Гораздо проще (и надёжнее) пройти по потоку циклом, чем ловить события data, error и end отдельно. Кстати, обработка ошибок в стриме в статье даже не затронута — опять таки, легко ошибиться, если делать вручную, а в случае со стандартными асинхронными конструкциями ошибка не потеряется, а всплывёт к месту вызова.


        Так что ад реален, и о спасении души подумать следует :D


  1. vvadzim
    19.04.2018 23:51
    +2

    toString немного не учитывает, что граница чанков может разорвать многобайтный символ на части — дефолтная кодировка всё-таки utf-8. Для объяснения концепции подойдёт, но отчасти уже наскучило выкашивать подобный код :)


    1. vmb
      20.04.2018 00:36

      Видимо, этот генератор можно просто заменить на readable.setEncoding(encoding):


      subproc.stdout.setEncoding('utf8');


  1. Gentlee
    20.04.2018 01:04

    А теперь перепишем эту кучу говнокода по простому (без генераторов, trim, pipe и даже |>):

    async function main() {
        const subproc = spawn('ls');
    
        await printStream(subproc.stdout);
    
        console.log('DONE');
    }
    
    async function printStream(stream) {
        let previous = '';
        for await (const chunk of stream) {
            let current = chunk.toString();
            while (current.length) {
                let i = current.indexOf('\n');
                if (i === -1) {
                    previous += current;
                    break;
                }
                
                previous += current.slice(0, i);
                print(previous);
                previous = '';
    
                if (i === current.length - 1) break;
                current = current.slice(i + 1);
            }
        }
    }
    


    1. faiwer
      20.04.2018 07:49

      <irony>Надо было ещё содержимое printStream в main затолкать. Показать этой глупой хипстоте, что один метод может решать сразу множество задач! И не нужoн нам ваш этот интернетне нужны нам никакие генераторы, пайпы и пр. говнокод</irony>


      1. Gentlee
        20.04.2018 08:45

        Выделить в отдельные методы toString и trim и print — по сути и так одну строчку? Да еще и придумать проблему (типа вызова всех генераторов в одной строке) и решить ее при помощи еще одного метода? Вот потом сидишь и вникнуть не можешь из за таких горе-программистов что происходит.

        Более того, в большинстве случаев лучше всего «назвать» блок кода комментарием, но никак не усложнением структуры кода созданием нового метода. Но это уже совсем непонятно для таких как вы.

        Ах да, вот ваша куча методов для сравнения с моими двумя:

        async function main() {
            const subproc = spawn('ls');
        
            await pipe(
                subproc.stdout,
                toString,
                chunksToLines,
                trim,
                print,
            );
        
            console.log('DONE');
        }
        
        async function *toString(chunks) {
            for await (const chunk of chunks) {
                yield chunk.toString();
            }
        }
        
        async function *chunksToLines(chunks) {
            let previous = '';
            for await (const chunk of chunks) {
                previous += chunk;
                while (true) {
                    const i = previous.indexOf('\n');
                    if (i < 0) {
                        break;
                    }
                    yield previous.slice(0, i + 1);
                    previous = previous.slice(i + 1);
                }
            }
        
            if (previous.length > 0) {
                yield previous;
            }
        }
        
        async function *trim(values) {
            for await (const value of values) {
                yield value.trim();
            }
        }
        
        async function print(values) {
            for await (const value of values) {
                console.log(value);
            }
        }
        
        function pipe(value, ...fns) {
            let result = value;
        
            for (const fn of fns) {
                result = fn(result);
            }
        
            return result;
        }


        Можно еще цикл for вынести в отдельный метод, а так же slice, например… лол.


        1. faiwer
          20.04.2018 09:23

          Но это уже совсем непонятно для таких как вы

          Куда уж нам, болезным. Мы почему-то считаем, что строчки экономить ? затея изначально гиблая. И разделение ответственности придумано не на ровном месте.


          Впрочем и код выше (ваш, где 1 async) вполне приемлем. Но очень уж чудно выглядит ваше негодование и все эти ярлыки про говнокодеров, "придумать проблему" и пр.). Особенно на фоне сообщения от BerkutEagle-а выше с его "не очень квалифицированной толпы «программистов»".


          Раздробление задачи на много мелких (даже однострочных) функций — нормальное явление. А если писать в "функциональном стиле", то там такое вообще на каждом шагу.


          1. Gentlee
            20.04.2018 10:38
            -3

            Он не вполне приемлем, он намного лучше вашего. Без велосипедов с генераторами и прочими ненужными вещами. И он, являясь проще для понимания, еще и сильно короче.

            За говнокодеров конечно извиняюсь, эмоции стоило отключить, наверное.


            1. faiwer
              20.04.2018 10:58
              +1

              async function main() {
                  const subproc = spawn('ls');
              
                  await subproc.stdout
                  |> toString
                  |> chunksToLines
                  |> trim
                  |> print;
              
                  console.log('DONE');
              }

              Я бы сравнил ваш код с вот этим ^. А не с цельным. И тогда картинка вырисовывается не в пользу монолита. Тот монолит хоть и краткий, но чтобы понять, что точно он делает надо вчитываться и вдумываться. Эти 4 |> строки выше же элементарны и они говорящие. Есть ненулевая вероятность того, что они будут вынесены в отдельную library/helper и их реализация вообще не будет мозолить глаза. Скажем если приложение много работает со стримами, то запросто.


              Вы же когда пишете previous.slice(i + 1) не нервничаете по поводу того, что slice это 3-10 строк кода. Так и тут. Когда я вижу код выше (с '|>') я не вижу всю эту незначащую ерунду со slice-ми, индексами, конкатенациями, циклами. Я вижу следующее:


              • берём stream stdout
              • читаем его содержимое и приводим к строчному виду
              • обрезаем строки
              • выводим на экран

              Это вполне себе JS-way. Так очень даже пишут на динамических языках высокого уровня. Сразу ясна суть. Без мишуры.


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


              1. Gentlee
                20.04.2018 13:19
                -1

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

                Вы должны сравнивать свой метод main с моим методом main, и он куда более понятен — ведь там написано ровно то что происходит — printStream. Детали того как это реализовано не так важны, и не стоит их выносить на всеобщее обозрение. И как раз таки метод printStream можно было вынести в другую библиотеку, но никак не методы toString и trim.

                Создавать уровни абстракций можно до бесконечности — и печать потока это точно не та задача которую нужно так разбивать. Я понимаю что здесь она была взята просто для примера, но надеюсь что в реальном проекте для такой задачи это будет написано примерно как у меня.


                1. faiwer
                  20.04.2018 13:40
                  -1

                  как и у многих других местных минусаторов

                  Я ни одного минуса никому не поставил в этом топике.


    1. justboris
      20.04.2018 10:46

      А зачем вы trim убрали?


      string.trim() убирает висячие пробелы с начала и конца строки. Без него программа будет печатать другой результат, не такой как в оригинале.


    1. tehSLy
      20.04.2018 11:28
      +3

      Можно и без функций, переменных, циклов, можно вообще в байткоде сразу писать, че уж там. Чай без сахара пьем, так еще и в JS этой гадости не хватало!
      (жаль, только что «говнокод» читается на раз, а эту портянку «ИДЕАЛЬНЕЙШЕГО КОДА» еще парсить энное количество времени придется)
      ((еще бы let и const научиться юзать по назначению, вообще хорошо бы было))


      1. Gentlee
        20.04.2018 14:47
        -2

        Уважаемый заплюсованный мидл программист, можете оставить свое экспертное мнение на тостере? А то там пока что ни одного мнения, совпадающего с Вашим.

        Ко всем минусаторам тоже относится. Специально сделал вопрос где нет ничего лишнего, только код! Узнаем наконец чей код «лутше».


        1. faiwer
          20.04.2018 15:08
          +1

          "О хоспади", опрос на тостере. Докатились. Да ещё и такой кривой. Gentlee неужели вы до сих пор не понимаете, что сравниваете разные вещи?


        1. tehSLy
          20.04.2018 16:20
          +1

          Да я тут оставлю, думаю никто против не будет.
          — Тестируемость
          — Удобочитаемость с тайпингами (если будет jsDoc, то можно сразу сушить весла, с TS/Flow — будет просто каша словесная)
          — Для того, чтобы понять что происходит, нужно посмотреть только в main(), а не шариться по всему циклу в поисках правды сути
          — Повышенная модульность (я легко могу заменить один из элементов логики на любой другой в любой момент времени, без потерь нервов)
          — Разделение ответственности (в принципе, это суммирует перечисленные до бенефиты)
          — Возможность быстро перейти к телу метода (почти каждая IDE сейчас умеет это, сильно ускоряет работу, при этом не мозоля глаза лишним кодом, который не нужен в данный момент)

          Да, второй код В КАКОМ ТО СМЫСЛЕ многословнее, однако нет необходимости спускаться на все уровни абстракций. Можно заглянуть в самый верхний метод и понять, что происходит, буквально за 10 строк. Мы сводим к минимуму словестность самого верхнего метода, его легко читать.

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

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


          1. Leg3nd
            22.04.2018 01:13

            Я вот, возможно, сейчас ошибаюсь, но, кажется, в исходном примере проход в цикле по одним и тем же данным выполняется 4 раза (в каждом из 4-х методов цепочки по разу), а в примере Gentlee — один раз. Я не исключаю, что я не правильно понял как работают асинхронные генераторы выше, но вроде все так.


            1. Gentlee
              22.04.2018 10:28

              Это еще одна проблема этой длинной портянки — надо знать как работают генераторы. Вы просто не знаете.


              1. Leg3nd
                22.04.2018 13:01

                Проверил, все таки я был не прав. Но я не пользуюсь генераторами, так что это нормально, что я с ходу не правильно понял. Тем не менее, сообщение выше я писал в Вашу защиту.


        1. tehSLy
          20.04.2018 16:43
          +1

          Алсо, есть мнение, что хипстеры без импортов нынче не живут (на самом деле, даже «деды» уже без них не живут), поэтому обозреваемый код можно одним движением ловко превратить в:

          import {toString, chunksToLines, trim, print} from './mySupaHelperLib';
          
          async function main() {
              const subproc = spawn('ls');
          
              await subproc.stdout
              |> toString
              |> chunksToLines
              |> trim
              |> print;
          
              console.log('DONE');
          }
          


          faiwer — уже упомянул об этом, однако я забыл к своим аргументам добавить этот пункт, соре за даблпост :D


        1. justboris
          21.04.2018 13:30
          +1

          Добавлю свои пару копеек. Монолитный код, который вы, Gentlee, написали, будет сложнее поддерживать. Представьте как вы будете вносить в код такие изменения, например:


          1. Фильтровать и не показывать строки, содержащие слово "test".
          2. Добавить номера строк в вывод
          3. Разбивать строку на несколько, если ее длина превышает некоторый максимум

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


  1. Paskin
    20.04.2018 16:16

    А в Node.js 10 уже можно написать обработчик Веб-запроса — который обращается, например, в MongoDB и возвращает как минимум статус всегда? Или до сих пор надо писать отдельные обработчики ошибок для соединения, открытия БД, поиска и т.п., т.е. на каждый чих — а если что-то забыть, то никакого Response вообще не вернется?


    1. faiwer
      20.04.2018 16:25
      +3

      Пардоньте, вы спрашиваете превратился ли nodejs в web-framework? Нет, конечно. Но всё перечисленное вами задача для всяких express.js, koa и пр., а не для самой платформы.