Очередная node.js-библиотека…

Думаю, мы можем опять обнулить счетчик времени появления очередной 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
  • +3
    С английского framework и есть каркас
    • +11
      например, параллельная обработка промежуточного ПО:

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

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

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

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


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


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

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

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

          • НЛО прилетело и опубликовало эту надпись здесь
            • 0
              А почему бы ей не укладываться? Весь язык уложился — и такая мелочь тоже уложится. Писать на одном языке, на ходу выискивая разницу с другими — это не профессионально.
              • НЛО прилетело и опубликовало эту надпись здесь
                • 0
                  Подумал было, что мы друг друга не поняли, но нет. Вы сознательно прицепились к формулировке.
                  Разъясню ещё раз: каждому языку должен быть свой подход, привычки, вырабатываемые программистом для некоторого языка, непригодны для другого языка, и не должны в него переноситься. А проблем у языков нет. Проблемы есть у программистов, которые спеку не читают.
                  • НЛО прилетело и опубликовало эту надпись здесь
                    • 0
                      «Убогость архитектуры» любого отдельно взятого языка сводится к непривычности его для отдельно взятого программиста. Неужели трудно врубиться, что языки — разные? И если работать с умом — то на любом языке всё прекрасно пишется.
                      • НЛО прилетело и опубликовало эту надпись здесь
                        • 0
                          Я могу ещё страшнее каррирование написать. Что это доказывает?
                          • НЛО прилетело и опубликовало эту надпись здесь
                            • 0
                              Зато отнюдь не самый читабельный.
                              • +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();
                                    }
                                }
                                • НЛО прилетело и опубликовало эту надпись здесь
                                  • 0
                                    Я прекрасно понимаю что ваш код делает, но то что вы его отформатировали в таком не читаемом виде это проблема не языка.

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

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

                                    function curry(fn, arr = []) {
                                    	return function(...args) {
                                    		const a = arr.concat(args);
                                    		return fn.length === a.length ? fn(...a) : curry(fn, a);
                                    	}
                                    }
                                    
                                    • НЛО прилетело и опубликовало эту надпись здесь
                                      • +1
                                        Причем тут function и return? В моей реализации на одну функцию меньше, а Ваш способ конкатенации аргументов вносит основную путаницу в код и читать его становиться сложнее.
                                        • 0

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

                                      • 0
                                        В clojure автокаррирования нет. Получается, что всё, в функциональной парадигме его не попользовать?
                                        • НЛО прилетело и опубликовало эту надпись здесь
                                          • +1

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

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

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

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


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

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

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


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


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

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

                        • +2

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


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


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


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

                          • 0
                            Спасибо за комментарий. Как по мне он самый интересный и показывает несовершенство 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

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

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

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

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

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


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

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


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

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

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


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

                              • 0
                                Если сравнивать с  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;
                                    });
                                });
                                


                              • 0

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

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

                                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.