Думаю, мы можем опять обнулить счетчик времени появления очередной 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)
DexterHD
24.02.2017 18:42+11например, параллельная обработка промежуточного ПО:
А мне всегда казалось, что сама концепция middleware предполагает последовательное выполнение,
потому что middleware по сути как pipe. Данные на выходе одного middleware используются в следующем.k12th
24.02.2017 22:26Ну в веб-приложениях далеко не все миддлвари потребляют результат предыдущей, некоторые вполне можно параллелить (но не все, понятно, авторизация должна срабатывать первой и не пускать дальше).
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.
Про простоту и элегантность я б еще поспорил. А ошибки как ловить?
iShatokhin
24.02.2017 19:56использует array.slice(0) — наиболее быстрый метод копирования массива.
Зависит от движка и размера массива — https://github.com/nodejs/node/pull/3976
am-amotion-city
25.02.2017 09:05+1Меня вот вообще уже очень давно интересует: как в голове у профессиональных разработчиков, использующих js, укладывается конструкция «
array.slice(0)
— наиболее быстрый метод копирования массива».
Почему во всех без исключения остальных языках предпочтительный метод копирования массива называется
copy
, или на худой конецclone
, а тут — вот.Zenitchik
25.02.2017 13:13А почему бы ей не укладываться? Весь язык уложился — и такая мелочь тоже уложится. Писать на одном языке, на ходу выискивая разницу с другими — это не профессионально.
am-amotion-city
25.02.2017 21:41Писать на одном языке — вообще непрофессионально. Шоры, отсутствие широты взгляда, все проблемы языка кажутся естественными, и так далее.
Zenitchik
26.02.2017 02:25Подумал было, что мы друг друга не поняли, но нет. Вы сознательно прицепились к формулировке.
Разъясню ещё раз: каждому языку должен быть свой подход, привычки, вырабатываемые программистом для некоторого языка, непригодны для другого языка, и не должны в него переноситься. А проблем у языков нет. Проблемы есть у программистов, которые спеку не читают.am-amotion-city
26.02.2017 07:41-1Любому самому нелепому проявлению убогости архитектуры всегда найдутся смешные оправдания адептов из серии «так и было задумано».
Привычки переноситься из языка в язык, разумеется, не должны. Но владея десятком языков на профессиональном уровне уже невозможно считать, что у языков проблем нет. У каждого языка полно? проблем.
Но только у js их количество выше допустимого предела. Ну у COBOL еще, наверное.
Zenitchik
26.02.2017 15:45«Убогость архитектуры» любого отдельно взятого языка сводится к непривычности его для отдельно взятого программиста. Неужели трудно врубиться, что языки — разные? И если работать с умом — то на любом языке всё прекрасно пишется.
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]);
простите, но нормальный человек назвать это «работой с умом» не сможет даже под общим наркозом. А какой-то год назад и этого не было.
Аргументы про библиотеки оставьте себе, нормальные программисты пишут библиотеки, а не бездумно их используют.
Zenitchik
26.02.2017 21:48Я могу ещё страшнее каррирование написать. Что это доказывает?
am-amotion-city
26.02.2017 21:50Это самый простой вариант, а не самый сложный. Проще никак.
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(); } }
am-amotion-city
27.02.2017 10:26-1Да какая разница-то? Оно что, стало читабельнее, что ли? Вон там ниже по стеку zxcabs даже не понимает, что этот код делает, полагаю, что ваш тоже не явится ему откровением.
Я тут дискутирую с абсурдным тезисом «если работать с умом — то на любом языке всё прекрасно пишется». Привел пример:
js
вроде никогда не позиционировался как императивный язык, так? Многие даже склонны называть его функциональным, так? Автокаррирование — оно, конечно, не обязано быть из коробки (хотя, почему-то, в Хаскеле, например, есть), но именно автокаррирование показывает, насколько язык внутренне готов к использованию в функциональной парадигме.
Написание простейшего
map-reduce
с вариативным числом параметров превращается без сторонних библиотек в ад. Если это не говорит о проблемах дизайна, то я и не знаю, что тогда скажет.
Еще раз: я не утверждаю, что это невозможно. В Hadoop чуваки все это на pure java изобразили. Просто не нужно утверждать, что профессионализм разработчика магическим образом исправляет родовые травмы языка, которые не могут (не хотят) поправить.
leftpad
наjs
писать одно удовольствие. Но как только что-то посерьезнее — начинается кошмар. Вот и все.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); } }
am-amotion-city
27.02.2017 13:47-1Код так отформатирован из уважения к людям, читающим со смартфона. В вебе для кода внутри
pre
это стандарт de facto уже лет пять. Не 80 символов, а 30–40.
Если вам кажется, что лишние пара
return
ов и параfunction
добавляет этому коду читаемости, я совершенно не стану спорить. Как я уже миллион раз сказал, для языка претендующего на умение в ФП, это не вариант.zxcabs
27.02.2017 14:08+1Причем тут function и return? В моей реализации на одну функцию меньше, а Ваш способ конкатенации аргументов вносит основную путаницу в код и читать его становиться сложнее.
Dionis_mgn
28.02.2017 09:05В clojure автокаррирования нет. Получается, что всё, в функциональной парадигме его не попользовать?
am-amotion-city
28.02.2017 11:42? в clojure автокаррирования нет принципиально, потому что функции по определению вариативны;
? в clojure естьpartial
;
? я нигде не утверждал, что автокаррирование должно быть из коробки, я утверждал, что имплементация не должна выглядеть как пляска вприсядку с тремя арбузами.f0rk
28.02.2017 12:05+1Зачем вообще в js каррировать функции? Если хочется частичного применения, есть Function.prototype.bind.
Dionis_mgn
28.02.2017 12:40+11. В clojure функции вариативны и поэтому автокаррирования нет принципиально. Вот JS — да, совсем другое дело.
2. В JS есть bind.
3. Реализация ЧЕГО не должна выглядеть как удивительный танец?
Почему Вы считаете, что реализация каррирования должна быть простой? Как по мне, это ОЧЕНЬ странная метрика.
Я нигде не вижу, чтобы JS называли pure functional. И сравнивать его с Хаскелем…
Ну давайте тогда и плюсы сравнивать с Хаскелем. На них тоже можно писать во вполне себе функциональном стиле и это даже не будет похоже на обед кактусом. Даже библиотеки есть для этого (настоящие программисты, правда, не должны бездумно пользовать библиотеки, но зато должны писать их. Благо, для C++ программиста каррирование пишется довольно просто. Хотя и много сложнее вашего варианта).
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
mayorovp
27.02.2017 09:42Это не каррирование, а частичное применение. Каррирование — это все же перевод функции к каррированному виду, с пока что еще неопределенными параметрами.
Хотя вы правы в том смысле, что чистое каррирование на js нафиг не нужно.
molnij
25.02.2017 19:09Ну так случилось, что вместо нескольких методов в работы с массивами js есть один — slice
Особенность языка, не более. К слову, не худшая…am-amotion-city
25.02.2017 20:41вместо нескольких методов для работы с массивами в js есть один —
slice
Весь js как явление прекрасно описывается фразой «вместо нескольких методов для работы с массивами в js есть один —
slice
». Вот прямо ни прибавить, ни убавить.
mayorovp
26.02.2017 14:20+1Это в каком таком всем языке без исключения получение копии массива делается через copy? Насколько я знаю,
copy
всегда копировала элементы из одного массива в другой, а не копировала сам массив!
Вот те языки, которые я знаю:
- C++, C#, Java: надо сначала создать новый массив — потом уже делать copy.
- Pascal: переменная объявляется в секции var. А копируется массив оператором присваивания.
- Javascript, Python: массив копируется пустым слайсом.
Ах да, еще в C# и Java можно сделать вызов clone — но он страшно некрасивый потому что возвращает Object, а не массив...
Makeomatic
24.02.2017 22:12+2Подождите, а как вы измеряли скорость? Почему здесь не учитыватся тот факт что нода однопоточная (не учитываем либюв и "асинхронность"). У нас есть ивент-луп и чем эффективнее ваш код — тем меньше вы его едите.
После вводной — когда мы делаем промис.алл(мидлвари), это может дать преимущества только если внутри а) одна мидлварь не зависит от данных другой б) мидлвари выполняют ИО, то бишь асинхронную работу. В любом другом случае вы замедляете работу вашего кода, потому что добавляете дополнительную работу в ивент луп.
Так вот, принимая во внимание вышесказанное, следовало бы сделать так чтобы в мидлварях не было ИО, а если это невозможно, то скорее всего от этой мидлвари зависят другие части. Если же независят, то эту мидлварь можно прогнать и не дождаться выпонения (пример — статсд статистика, обработка вашего роута никак не зависит от того сохранилась статистика или нет и нет смысла дожидаться выполнения операции перед переходом к следующей части пайпа).
Несколько сумбурный коммент, но важно понимать что мейн тред он один и вся "псевдо" асинхронщина (к примеру параллельное выполнение синхронных функций через промис.алл, только замедлит работу)
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.mayorovp
26.02.2017 14:22+1Зачем сервер статики и favicon запускать параллельно, если они никогда не будут обрабатывать один и тот же запрос одновременно?
zxcabs
25.02.2017 05:24Вместо последовательного перебора всех правил можно одновременно запустить проверку всех. Этот момент не остался без тестирования производительности и результаты не заставили себя ждать.
А вы как бы в курсе что оно будет все равно в одном потоке выполнятся и никакой параллельности не будет? Если вы конечно не рожаете пулл процессов для проверки роутов.
Фишку с непоследовательным выполнением миддлеваре вообще не понял, в этом как раз и профит что запрос обрабатывается последовательно всеми миддлварями и это последовательность никак не мешает асинхронности.
Это дает отличную возможность избавиться от callback hell и постоянной обработки ошибок на всех уровнях, как, например, реализовано в express.
Это не избавляет вас от необходимости обрабатывать ошибки асинхронных функций. Напишите trow Error в таймауте и Promise.all его не в поймает.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.uSide
26.02.2017 00:21Если нам нужно создать клиенты, например к mysql / redis, и дождаться их соединения — тоже (сетевые запросы).
Вы действительно на каждый запрос в мидлвари делаете новое подключение к db/redis/etc?
Итог — получаем выигрыш в раздаче статики, что, мягко говоря, не рекомендуется в проде.
surefire
26.02.2017 10:54Суть мидлвари в том, что это не просто асинхронная функция. Они ходят не только вниз, но и вверх точно в обратном порядке, по моему у вас этот принцип нарушен.
Это немного устаревшая гифка из koa@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; }); });
rumkin
26.02.2017 13:17Как по мне так, это можно было решить проще. Все что должно выполняться параллельно помещаем в Promise.all. Т.е. всю вашу библиотеку можно свести к небольшому врапперу для нескольких middleware.
evheniy
26.02.2017 14:10Все правильно.
Это подход, реализующий здравый смысл и лучшие практики работы с node.js, оформленный в небольшую библиотеку.
zag2art
С английского framework и есть каркас