Думаю, мы можем опять обнулить счетчик времени появления очередной JS библиотеки.


Все началось примерно 6 лет назад, когда я познакомился с node.js. Около 3 лет назад я начал использовать node.js на проектах вместе с замечательной библиотекой express.js (на wiki она названа каркасом приложений, хотя некоторые могут называть express фреймворком или даже пакетом). Express сочетает в себе node.js http сервер и систему промежуточного ПО, созданную по образу каркаса Sinatra из Ruby.


Все мы знаем о скорости создания новых библиотек и скорости развития JS. После разделения и объединения с IO.js node.js взяла себе лучшее из мира JS — ES6, а в апреле и ES7.


Об одном из этих изменений и хочу поговорить. А конкретно о async / await и Promise. Пытаясь использовать Promise в проектах на express, а после и async / await с флагом для node.js 7 --harmony, я наткнулся на интересный фреймворк нового поколения — koa.js, а конкретно на его вторую версию.


Первая версия была создана с помощью генераторов и библиотеки CO. Вторая версия обещает удобство при работе с Promise / async / await и ждет апрельского релиза node.js с поддержкой этих возможностей без флагов.


Мне стало интересно заглянуть в ядро koa и узнать, как реализована работа с Promise. Но я был удивлен, т.к. ядро осталось практически таким же, как в предыдущей версии. Авторы обеих библиотек express и koa одни и те же, неудивительно, что и подход остался таким же. Я имею ввиду структуру промежуточного ПО (middleware). Использовать подход из Ruby было полезно на этапе становления node.js, но современный node.js, как и JS, имеет свои преимущества, красоту, элегантность...


Немного теории.


Node.js http (https) сервер наследует net.Server, который реализовывает EventEmitter. И все библиотеки (express, koa...) по сути являются обработчиками события server.on('request').
Например:


const http = require('http');
const server = http.createServer((request, response) => {
    // обработка события
});

Или


const server = http.createServer();
server.on('request', (request, response) => {
      // такая же обработка события
});

И я представил, как должен выглядеть действительно "фреймворк нового поколения":


const server = http.createServer( (req, res) => {
    Promise.resolve({ req, res }).then(ctx => {

        ctx.res.writeHead(200, {'Content-Type': 'text/plain'});
        ctx.res.end('OK');

        return ctx;
    });
});

Это дает отличную возможность избавиться от callback hell и постоянной обработки ошибок на всех уровнях, как, например, реализовано в express. Также, это позволяет применить Promise.all() для "параллельного" выполнения промежуточного ПО вместо последовательного.


И так появилась еще одна библиотека: YEPS — Yet Another Event Promised Server.


Синтаксис YEPS передает всю простоту и элегантность архитектуры, основанной на обещаниях (promise based design), например, параллельная обработка промежуточного ПО:


const App = require('yeps');
const app = new App();
const error = require('yeps-error');
const logger = require('yeps-logger');

app.all([
    logger(),
    error()
]);

app.then(async ctx => {
    ctx.res.writeHead(200, {'Content-Type': 'text/plain'});
    ctx.res.end('Ok');
});

app.catch(async (err, ctx) => {
    ctx.res.writeHead(500);
    ctx.res.end(err.message);
});

Или


app.all([
    logger(),
    error()
]).then(async ctx => {
    ctx.res.writeHead(200, {'Content-Type': 'text/plain'});
    ctx.res.end('Ok');
}).catch(async (err, ctx) => {
    ctx.res.writeHead(500);
    ctx.res.end(err.message);
});

Для примера есть пакеты error, logger, redis.


Но самым удивительным была скорость работы. Можно запустить сравнительный тест производительности — yeps-benchmark, где сравнивается производительность работы YEPS с express, koa2 и даже node.js http.


Как видим, параллельное выполнение показывает интересные результаты. Хотя этого можно достичь в любом проекте, этот подход должен быть заложен в архитектуру, в саму идею — не делать ни одного шага без тестирования производительности. Например, ядро библиотеки — yeps-promisify, использует array.slice(0) — наиболее быстрый метод копирования массива.


Возможность параллельного выполнения промежуточного ПО натолкнула на мысль создания маршрутизатора (router, роутер), полностью созданного на Promise.all(). Сама идея поймать (catch) нужный маршрут (route), нужное правило и соответственно вернуть нужный обработчик лежит в основе Promise.all().


const Router = require('yeps-router');
const router = new Router();

router.catch({ method: 'GET', url: '/' }).then(async ctx => {
    ctx.res.writeHead(200);
    ctx.res.end('homepage');     
});

router.get('/test').then(async ctx => {
    ctx.res.writeHead(200);
    ctx.res.end('test');     
}).post('/test/:id').then(async ctx => {
    ctx.res.writeHead(200);
    ctx.res.end(ctx.request.params.id);
});

app.then(router.resolve());

Вместо последовательного перебора всех правил можно одновременно запустить проверку всех. Этот момент не остался без тестирования производительности и результаты не заставили себя ждать.


Поиск первого правила был на примерно 10% быстрее. Последнее правило срабатывало ровно с той же скоростью, что примерно в 4 раза быстрее остальных библиотек (здесь речь идет о 10 маршрутах). Больше не нужно собирать и анализировать статистику, думать какое правило поднять вверх,.


Но для полноценной production ready работы необходимо было решить проблему "курицы и яйца" — никто не будет использовать библиотеку без дополнительных пакетов и никто не будет писать пакеты к неиспользуемой библиотеке. Здесь помогла обертка (wrapper), позволяющая использовать промежуточное ПО от express, например body-parser или serve-favicon


const error = require('yeps-error');
const wrapper = require('yeps-express-wrapper');

const bodyParser = require('body-parser');
const favicon = require('serve-favicon');
const path = require('path');

app.then(
    wrapper(favicon(path.join(__dirname, 'public', 'favicon.ico')))
).all([
    error(),
    wrapper(bodyParser.json()),
]);

Так же есть шаблон приложения — yeps-boilerplate, позволяющий запустить новое приложение, просмотреть код, примеры…


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


P.S.: Надеюсь на советы, идеи и конструктивную критику в комментариях.

Поделиться с друзьями
-->

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


  1. zag2art
    24.02.2017 18:18
    +3

    С английского framework и есть каркас


  1. DexterHD
    24.02.2017 18:42
    +11

    например, параллельная обработка промежуточного ПО:

    А мне всегда казалось, что сама концепция middleware предполагает последовательное выполнение,
    потому что middleware по сути как pipe. Данные на выходе одного middleware используются в следующем.


    1. k12th
      24.02.2017 22:26

      Ну в веб-приложениях далеко не все миддлвари потребляют результат предыдущей, некоторые вполне можно параллелить (но не все, понятно, авторизация должна срабатывать первой и не пускать дальше).


  1. apelsyn
    24.02.2017 18:58
    +1

    Мне стало интересно заглянуть в ядро koa и узнать, как реализована работа с Promise. Но я был удивлен, т.к. ядро осталось практически таким же, как в предыдущей версии. Авторы обеих библиотек express и koa одни и те же, неудивительно, что и подход остался таким же. Я имею ввиду структуру промежуточного ПО (middleware).

    koa принципиально отличается от express и подход там совершенно другой. Работа с промисами там сводится к тому что в обработчике запроса ассинхронные функции вызываются через async/await синтакс со всеми вытекающими плюшками.


    По поводу yeps-benchmark, там koa2 запускаеться через node-cluster, что на мой взгляд, не лучшее решение. И что пытаемся доказать, что роуты обрабатываються меделенее? Не спорю, но роуты это не часть koa, koa-router это сторонний middleware, роутинг не входит в базовую функциональность koa.


    Про простоту и элегантность я б еще поспорил. А ошибки как ловить?


  1. iShatokhin
    24.02.2017 19:56

    использует array.slice(0) — наиболее быстрый метод копирования массива.

    Зависит от движка и размера массива — https://github.com/nodejs/node/pull/3976


    1. am-amotion-city
      25.02.2017 09:05
      +1

      Меня вот вообще уже очень давно интересует: как в голове у профессиональных разработчиков, использующих js, укладывается конструкция «array.slice(0) — наиболее быстрый метод копирования массива».


      Почему во всех без исключения остальных языках предпочтительный метод копирования массива называется copy, или на худой конец clone, а тут — вот.


      1. Zenitchik
        25.02.2017 13:13

        А почему бы ей не укладываться? Весь язык уложился — и такая мелочь тоже уложится. Писать на одном языке, на ходу выискивая разницу с другими — это не профессионально.


        1. am-amotion-city
          25.02.2017 21:41

          Писать на одном языке — вообще непрофессионально. Шоры, отсутствие широты взгляда, все проблемы языка кажутся естественными, и так далее.


          1. Zenitchik
            26.02.2017 02:25

            Подумал было, что мы друг друга не поняли, но нет. Вы сознательно прицепились к формулировке.
            Разъясню ещё раз: каждому языку должен быть свой подход, привычки, вырабатываемые программистом для некоторого языка, непригодны для другого языка, и не должны в него переноситься. А проблем у языков нет. Проблемы есть у программистов, которые спеку не читают.


            1. am-amotion-city
              26.02.2017 07:41
              -1

              Любому самому нелепому проявлению убогости архитектуры всегда найдутся смешные оправдания адептов из серии «так и было задумано».


              Привычки переноситься из языка в язык, разумеется, не должны. Но владея десятком языков на профессиональном уровне уже невозможно считать, что у языков проблем нет. У каждого языка полно? проблем.


              Но только у js их количество выше допустимого предела. Ну у COBOL еще, наверное.


              1. Zenitchik
                26.02.2017 15:45

                «Убогость архитектуры» любого отдельно взятого языка сводится к непривычности его для отдельно взятого программиста. Неужели трудно врубиться, что языки — разные? И если работать с умом — то на любом языке всё прекрасно пишется.


                1. am-amotion-city
                  26.02.2017 19:09
                  -2

                  Да можно, можно и шуруп утюгом всю жизнь заколачивать. Но когда банальное каррирование сводится к:


                  const curry = (
                    f, arr = []
                  ) => (...args) => (
                    a => a.length === f.length ?
                      f(...a) :
                      curry(f, a)
                  )([...arr, ...args]);

                  простите, но нормальный человек назвать это «работой с умом» не сможет даже под общим наркозом. А какой-то год назад и этого не было.


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


                  1. Zenitchik
                    26.02.2017 21:48

                    Я могу ещё страшнее каррирование написать. Что это доказывает?


                    1. am-amotion-city
                      26.02.2017 21:50

                      Это самый простой вариант, а не самый сложный. Проще никак.


                      1. Zenitchik
                        26.02.2017 22:02

                        Зато отнюдь не самый читабельный.


                      1. MikailBag
                        26.02.2017 22:27
                        +1

                        ИМХО достаточно просто снизить кол-во хипстерских конструкций.
                        Как-то так?:


                        const curry = function(f, arr = []){
                            return funciton inner(...args){
                                function inner2(a){
                                    if (a.length == f.length) {
                                       return f(...a);
                                    } else {
                                        return curry(f,a);
                                    }
                                }
                                return inner2();
                            }
                        }


                        1. am-amotion-city
                          27.02.2017 10:26
                          -1

                          Да какая разница-то? Оно что, стало читабельнее, что ли? Вон там ниже по стеку zxcabs даже не понимает, что этот код делает, полагаю, что ваш тоже не явится ему откровением.


                          Я тут дискутирую с абсурдным тезисом «если работать с умом — то на любом языке всё прекрасно пишется». Привел пример: js вроде никогда не позиционировался как императивный язык, так? Многие даже склонны называть его функциональным, так? Автокаррирование — оно, конечно, не обязано быть из коробки (хотя, почему-то, в Хаскеле, например, есть), но именно автокаррирование показывает, насколько язык внутренне готов к использованию в функциональной парадигме.


                          Написание простейшего map-reduce с вариативным числом параметров превращается без сторонних библиотек в ад. Если это не говорит о проблемах дизайна, то я и не знаю, что тогда скажет.


                          Еще раз: я не утверждаю, что это невозможно. В Hadoop чуваки все это на pure java изобразили. Просто не нужно утверждать, что профессионализм разработчика магическим образом исправляет родовые травмы языка, которые не могут (не хотят) поправить. leftpad на js писать одно удовольствие. Но как только что-то посерьезнее — начинается кошмар. Вот и все.


                          1. zxcabs
                            27.02.2017 12:39

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

                            «Банальное каррирование» сводится к функции bind о чем я ниже у упомянул.

                            А Ваше творчество я переписал в куда более понятную конструкцию

                            function curry(fn, arr = []) {
                            	return function(...args) {
                            		const a = arr.concat(args);
                            		return fn.length === a.length ? fn(...a) : curry(fn, a);
                            	}
                            }
                            


                            1. am-amotion-city
                              27.02.2017 13:47
                              -1

                              Код так отформатирован из уважения к людям, читающим со смартфона. В вебе для кода внутри pre это стандарт de facto уже лет пять. Не 80 символов, а 30–40.


                              Если вам кажется, что лишние пара returnов и пара function добавляет этому коду читаемости, я совершенно не стану спорить. Как я уже миллион раз сказал, для языка претендующего на умение в ФП, это не вариант.


                              1. zxcabs
                                27.02.2017 14:08
                                +1

                                Причем тут function и return? В моей реализации на одну функцию меньше, а Ваш способ конкатенации аргументов вносит основную путаницу в код и читать его становиться сложнее.


                              1. f0rk
                                27.02.2017 14:20

                                А что не так с кодом? Он вполне понятный, как мне кажется.


                          1. Dionis_mgn
                            28.02.2017 09:05

                            В clojure автокаррирования нет. Получается, что всё, в функциональной парадигме его не попользовать?


                            1. am-amotion-city
                              28.02.2017 11:42

                              ? в clojure автокаррирования нет принципиально, потому что функции по определению вариативны;
                              ? в clojure есть partial;
                              ? я нигде не утверждал, что автокаррирование должно быть из коробки, я утверждал, что имплементация не должна выглядеть как пляска вприсядку с тремя арбузами.


                              1. f0rk
                                28.02.2017 12:05
                                +1

                                Зачем вообще в js каррировать функции? Если хочется частичного применения, есть Function.prototype.bind.


                              1. Dionis_mgn
                                28.02.2017 12:40
                                +1

                                1. В clojure функции вариативны и поэтому автокаррирования нет принципиально. Вот JS — да, совсем другое дело.
                                2. В JS есть bind.
                                3. Реализация ЧЕГО не должна выглядеть как удивительный танец?
                                Почему Вы считаете, что реализация каррирования должна быть простой? Как по мне, это ОЧЕНЬ странная метрика.
                                Я нигде не вижу, чтобы JS называли pure functional. И сравнивать его с Хаскелем…
                                Ну давайте тогда и плюсы сравнивать с Хаскелем. На них тоже можно писать во вполне себе функциональном стиле и это даже не будет похоже на обед кактусом. Даже библиотеки есть для этого (настоящие программисты, правда, не должны бездумно пользовать библиотеки, но зато должны писать их. Благо, для C++ программиста каррирование пишется довольно просто. Хотя и много сложнее вашего варианта).


                  1. zxcabs
                    27.02.2017 02:28
                    +1

                    Вот вам каррирование в одну строчку стандартным методом в js и даже еще не es6

                    function sum(a, b) {
                      return a + b;
                    }
                    
                    var sum5 = sum.bind(null, 5);
                    
                    
                    console.log(sum5(1)); // => 6
                    


                    1. mayorovp
                      27.02.2017 09:42

                      Это не каррирование, а частичное применение. Каррирование — это все же перевод функции к каррированному виду, с пока что еще неопределенными параметрами.


                      Хотя вы правы в том смысле, что чистое каррирование на js нафиг не нужно.


      1. molnij
        25.02.2017 19:09

        Ну так случилось, что вместо нескольких методов в работы с массивами js есть один — slice
        Особенность языка, не более. К слову, не худшая…


        1. am-amotion-city
          25.02.2017 20:41

          вместо нескольких методов для работы с массивами в js есть один — slice

          Весь js как явление прекрасно описывается фразой «вместо нескольких методов для работы с массивами в js есть один — slice». Вот прямо ни прибавить, ни убавить.


      1. mayorovp
        26.02.2017 14:20
        +1

        Это в каком таком всем языке без исключения получение копии массива делается через copy? Насколько я знаю, copy всегда копировала элементы из одного массива в другой, а не копировала сам массив!


        Вот те языки, которые я знаю:


        • C++, C#, Java: надо сначала создать новый массив — потом уже делать copy.
        • Pascal: переменная объявляется в секции var. А копируется массив оператором присваивания.
        • Javascript, Python: массив копируется пустым слайсом.

        Ах да, еще в C# и Java можно сделать вызов clone — но он страшно некрасивый потому что возвращает Object, а не массив...


  1. Makeomatic
    24.02.2017 22:12
    +2

    Подождите, а как вы измеряли скорость? Почему здесь не учитыватся тот факт что нода однопоточная (не учитываем либюв и "асинхронность"). У нас есть ивент-луп и чем эффективнее ваш код — тем меньше вы его едите.


    После вводной — когда мы делаем промис.алл(мидлвари), это может дать преимущества только если внутри а) одна мидлварь не зависит от данных другой б) мидлвари выполняют ИО, то бишь асинхронную работу. В любом другом случае вы замедляете работу вашего кода, потому что добавляете дополнительную работу в ивент луп.


    Так вот, принимая во внимание вышесказанное, следовало бы сделать так чтобы в мидлварях не было ИО, а если это невозможно, то скорее всего от этой мидлвари зависят другие части. Если же независят, то эту мидлварь можно прогнать и не дождаться выпонения (пример — статсд статистика, обработка вашего роута никак не зависит от того сохранилась статистика или нет и нет смысла дожидаться выполнения операции перед переходом к следующей части пайпа).


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


    1. evheniy
      25.02.2017 10:06

      Спасибо за комментарий. Как по мне он самый интересный и показывает несовершенство yesp документации.

      По первому вопросу — как раз я и учел особенности node.js.
      Node.js не однопоточная, она работает в одном процессе (если не учитывать кластеризацию и child_process). Многопоточность и обеспечивает libuv.

      Библиотека yesp позволяет контролировать последовательность или параллельность выполнения кода.

      app.then();
      app.all();
      app.all();
      app.then();
      app.catch()
      

      фактически даст нам
      Promise.resolve()
      .then()
      .then(() => Promise.all())
      .then(() => Promise.all())
      .then()
      .catch();
      


      Поэтому можно группировать промежуточное ПО по смыслу и сделать их работу параллельной (например сервер статики и favicon запустить параллельно, если они смотрят в разные папки, затем параллельно запустить создание logger, error handler, redis client, mysql client...). Пример можно посмотреть в yeps-boilerplate.


      1. mayorovp
        26.02.2017 14:22
        +1

        Зачем сервер статики и favicon запускать параллельно, если они никогда не будут обрабатывать один и тот же запрос одновременно?


  1. zxcabs
    25.02.2017 05:24

    Вместо последовательного перебора всех правил можно одновременно запустить проверку всех. Этот момент не остался без тестирования производительности и результаты не заставили себя ждать.

    А вы как бы в курсе что оно будет все равно в одном потоке выполнятся и никакой параллельности не будет? Если вы конечно не рожаете пулл процессов для проверки роутов.
    Фишку с непоследовательным выполнением миддлеваре вообще не понял, в этом как раз и профит что запрос обрабатывается последовательно всеми миддлварями и это последовательность никак не мешает асинхронности.

    Это дает отличную возможность избавиться от callback hell и постоянной обработки ошибок на всех уровнях, как, например, реализовано в express.

    Это не избавляет вас от необходимости обрабатывать ошибки асинхронных функций. Напишите trow Error в таймауте и Promise.all его не в поймает.


    1. evheniy
      25.02.2017 10:17

      Это не избавляет вас от необходимости обрабатывать ошибки асинхронных функций. Напишите trow Error в таймауте и Promise.all его не в поймает.


      Я старался как раз и избавиться от callback, заменив их на Promise / aasync / await. Ошибки в такой реализации не теряются. А заменить setTimeout можно Promise обберткой, например promise-pause-timeout.

      Фишку с непоследовательным выполнением миддлеваре вообще не понял, в этом как раз и профит что запрос обрабатывается последовательно всеми миддлварями и это последовательность никак не мешает асинхронности.


      И да и нет. Если промежуточное ПО является, например, static server, здесь результат параллельной работы очевиден (обращение к файловой системе). Если нам нужно создать клиенты, например к  mysql / redis, и дождаться их соединения — тоже (сетевые запросы). Но если нам нужно обработать например request (body-parser), здесь особо выигрыша не будет, но мы можем этим пожертвовать ради единой архитектуры и выигрыша от предыдущих примеров. В итоге суть подхода делать неблокирующие операции везде — один из важнейших паттернов асинхронной работы node.js.


      1. uSide
        26.02.2017 00:21

        Если нам нужно создать клиенты, например к mysql / redis, и дождаться их соединения — тоже (сетевые запросы).

        Вы действительно на каждый запрос в мидлвари делаете новое подключение к db/redis/etc?
        Итог — получаем выигрыш в раздаче статики, что, мягко говоря, не рекомендуется в проде.


  1. surefire
    26.02.2017 10:54

    Суть мидлвари в том, что это не просто асинхронная функция. Они ходят не только вниз, но и вверх точно в обратном порядке, по моему у вас этот принцип нарушен.


    Это немного устаревшая гифка из koa@1, но суть та же:
    image


    1. evheniy
      26.02.2017 11:30

      Если сравнивать с  koa, то все верно.
      Но идея была все таки взглянуть по новому на архитектуру с учетом всех новых возможностей node.js.
      Здесь ближе подход Promise based.
      Пример с промежуточным ПО (middleware) я привел для сравнения с express / koa. Но это не сама суть идеи.
      Чтобы объяснить что-то новое, легче взять что-то старое и показать отличие.

      И я представил, как должен выглядеть действительно «фреймворк нового поколения»:
      const server = http.createServer( (req, res) => {
          Promise.resolve({ req, res }).then(ctx => {
      
              ctx.res.writeHead(200, {'Content-Type': 'text/plain'});
              ctx.res.end('OK');
      
              return ctx;
          });
      });
      



  1. rumkin
    26.02.2017 13:17

    Как по мне так, это можно было решить проще. Все что должно выполняться параллельно помещаем в Promise.all. Т.е. всю вашу библиотеку можно свести к небольшому врапперу для нескольких middleware.


    1. evheniy
      26.02.2017 14:10

      Все правильно.
      Это подход, реализующий здравый смысл и лучшие практики работы с node.js, оформленный в небольшую библиотеку.