Андрей Саломатин ( filipovskii_off)
Сегодня каждый день появляются новые языки программирования — Go, Rust, CoffeeScript — все, что угодно. Я решил, что я тоже горазд придумать свой язык программирования, что миру не хватает какого-то нового языка…
Дамы и господа, я представляю вам сегодня Schlecht!Script — чумовой язык программирования. Мы все должны начать им пользоваться прямо сейчас. В нем есть все то, к чему мы привыкли — в нем есть условные операторы, есть циклы, есть функции и функции высших порядков. В общем, в нем есть все, что нужно нормальному языку программирования.
Что в нем не очень обычно, что может даже оттолкнуть, на первый взгляд, — это то, что в Schlecht!Script функции имеют цвет.
Т.е., когда вы объявляете функцию, когда вы ее вызываете, вы явным образом указываете ее цвет.
Функции бывают красные и синие — двух цветов.
Важный момент: внутри синих функций вы можете вызывать только другие синие функции. Вы не можете вызывать красные функции внутри синих функций.
Внутри красных функций вы можете вызывать и красные, и синие функции.
Я решил, что должно быть так. В каждом языке должно быть так.
Тонкий момент: красные функции писать и вызывать больно! Что я имею в виду, когда говорю «больно»? Дело в том, что сейчас я изучаю немецкий язык, и я решил, что красные функции мы должны все называть только на немецком языке, иначе интерпретатор просто не поймет, что вы ему пытаетесь впихнуть, и он просто не будет это выполнять.
Вот так вы должны писать функции на немецком языке:
«!» обязателен — мы же на немецком пишем, в конце концов.
Как писать на таком языке? У нас есть два способа. Мы можем использовать только синие функции, в которые писать не больно, но внутри мы не можем пользоваться красными функциями. Этот подход не будет работать, потому что в порыве вдохновения я написал половину стандартной библиотеки на красных функциях, так что, простите…
Вопрос к вам — стали бы вы использовать такой язык? Продал ли я вам Schlecht!Script?
Ну, у вас, как бы, нет выбора. Простите…
JavaScript.
JavaScript — отличный язык, мы все его любим, мы все здесь собрались, потому что мы любим JavaScript. Но проблема в том, что JavaScript наследует некоторые черты Schlecht!Script, и я, конечно, не хочу хвастаться, но, по-моему, они украли пару моих идей.
Что именно они наследуют? в JavaScript есть красные и синие функции. Красные функции в JavaScript — это асинхронные функции, синие — синхронные функции. И все прослеживается, все та же цепочка… Красные функции вызывать больно в Schlecht!Script, а асинхронные функции вызывать больно в JavaScript.
И внутри синих функций мы не можем писать красные функции. Я еще скажу об этом позже.
Почему это больно? Откуда боль при вызове и при написании асинхронных функций?
У нас по-другому работают условные операторы, циклы, return. У нас не работает try/catch, и асинхронные функции ломают абстракцию.
О каждом пункте немного подробнее.
Вот так выглядит синхронный код, где shouldProcess и process — это функции синхронные, и работают условные операторы, работает for, в общем, все хорошо.
То же самое, но асинхронное, будет выглядеть вот так вот:
Там появилась рекурсия, мы передаем состояние в параметры, в функцию. В общем, смотреть прямо-таки неприятно. У нас не работает try/catch и, я думаю, мы все знаем, что если мы обернем синхронный блок кода в try/catch, исключения мы не поймаем. Нам нужно будет передать callback, перенавесить обработчик событий, в общем, у нас нет try/catch…
И асинхронные функции ломают абстракцию. Что я имею в виду? Представьте, что вы написали кэш. Вы сделали кэш пользователей в памяти. И у вас есть функция, которая читает из этого кэша, которая, естественно, синхронная, потому что все в памяти. Завтра к вам приходят тысячи, миллионы, миллиарды пользователей, и вам нужно положить этот кэш в Redis. Вы кладете кэш в Redis, функция становится асинхронной, потому что из-за Redis'а мы можем читать только асинхронно. И, соответственно, весь стек, который вызывал вашу синхронную функцию, придется переписать, потому что теперь весь стек становится асинхронным. Если какая-то функция зависела от функции чтения из кэша, она так же будет теперь асинхронной.
В общем и целом, говоря об асинхронности в JavaScript, можно сказать, что все там грустно.
Но что мы все об асинхронности? Давайте немного обо мне поговорим, наконец.
Я пришел, чтобы вас всех спасти. Ну, я попробую это сделать.
Меня зовут Андрей, я работаю в стартапе «Productive Mobile» в Берлине. Я помогаю с организацией MoscowJS и я являюсь соведущим RadioJS. Мне очень интересна тема асинхронности и не только в JavaScript, я считаю, что, в принципе, это определяющий момент языка. То, как язык работает с асинхронностью, определяет его успех и то, насколько людям приятно и удобно с ним работать.
Говоря об асинхронности конкретно в JavaScript, мне кажется, у нас есть два сценария, с которыми мы постоянно взаимодействуем. Это обработка множества событий и обработка единичных асинхронных операций.
Множество событий — это, например, событие DOM или подключение к серверу — что-то, что излучает множество нескольких типов событий.
Единичная операция — это, к примеру, чтение из базы. Единичная асинхронная операция возвращает нам либо один результат, либо возвращает ошибку. Больше никаких вариантов нет.
И, говоря об этих двух сценариях, интересно порассуждать: вот, типа, асинхронность — плохо, в общем, все грустно… А что мы на самом деле хотим? Как бы выглядел идеальный асинхронный код?
А хотим мы, мне кажется, контроля потока управления. Мы хотим, чтобы наши условные операторы, циклы работали в синхронном коде так же, как в асинхронном.
Мы хотим обработки исключений. Зачем нам try/catch, если мы не можем его использовать в асинхронных операциях? Это просто странно.
И желательно, конечно, иметь единый интерфейс. Почему асинхронная функция должна писаться и вызываться по-другому, по сравнению с синхронной? Этого не должно быть.
Вот, чего мы хотим.
А что у нас есть сегодня, и какие инструменты у нас появятся в будущем?
Если мы говорим об ECMAScript 6 (это, в принципе, то, о чем я буду говорить сегодня), для работы с множеством событий у нас есть EventEmitter и Stream, а для работы с единичными асинхронными операциями — Continuation Passing Style (они же callback'и), Promises и Coroutines.
В ECMAScript 7 у нас появятся Async Generators для работы с множеством событий и Async/Await — для работы с единичными асинхронными операциями.
Об этом и поговорим.
Начнем с того, что у нас есть в ECMAScript 6 для работы с множеством асинхронных событий. Напомню, например, это обработка событий мыши или нажатий на клавиши. У нас есть паттерн EventEmitter, который реализован в браузере в Node.js. Он встречается практически в любой API, где мы работаем с множеством событий. EventEmitter говорит нам, что мы можем создать объект, который излучает события, и навешивать обработчики на каждый тип события.
Интерфейс очень простой. Мы можем добавлять EventListener, убирать EventListener по названию event'а, передавая туда сallback.
К примеру, в XMLHttpRequest, когда я говорю о множестве событий, я имею в виду, что у нас может быть множество событий progress. Т.е. по мере того, как мы загружаем какие-то данные с помощью AJAX-запроса, нам выстреливают события progress, и по одному разу выстреливают события load, abort и error:
Error — это особенное событие, универсальное событие в EventEmitter'ах и в Stream'ах для того чтобы уведомить пользователя об ошибке.
Есть множество реализаций:
Здесь перечислены только несколько, и в конце доклада будет ссылка, где все эти реализации есть.
Важно сказать, что в Node.js EventEmitter встроен по умолчанию.
Итак, это то, что у нас есть практически по стандарту в API и браузерах в Node.js.
Что у нас еще есть для работы с множеством событий? Stream.
Stream — это поток данных. Что такое данные? Это могут быть бинарные данные, например, данные из файла, текстовые данные, либо объекты или события. Самые популярные примеры:
Есть несколько типов потоков:
Здесь мы рассматриваем цепочку преобразований из Stylus файлов в css файлы, добавляя автопрефиксер, потому что все мы любим Андрея Ситника и его автопрефиксер.
Вы видите, что у нас есть несколько типов потоков — поток-источник это gulp.src, который читает файлы и излучает объекты файлы, которые потом идут в потоки преобразования. Первый поток преобразования делает из stylus файла css, второй поток преобразования добавляет префиксы. И последний тип потоков — это поток-потребитель, который принимает эти объекты, пишет что-то куда-то на диск, и ничего не излучает.
Т.е. у нас есть 3 типа потоков — источник данных, преобразование и потребитель. И эти паттерны прослеживаются везде, не только в gulp, но и при наблюдении за DOM событиями. У нас есть потоки, которые излучают DOM-события, которые их преобразуют и что-то, что потребляя эти DOM-события, возвращает конкретный результат.
Это то, что можно назвать конвейером. С помощью потоков мы можем выстраивать такие цепочки, когда объект кладется куда-то в начало конвейера, проходит цепочку преобразований и, когда к нему подходят люди, что-то там меняют, добавляют, удаляют, и в конечном итоге у нас выходит какой-нибудь автомобиль.
Есть несколько реализаций потоков, или они же Observables:
В Node.js потоки встроенные — это Node Streams.
Итак, у нас есть EventEmitter и Stream. EventEmitter по умолчанию есть во всех API, а. Stream — это надстройка, которую мы можем использовать для того, чтобы унифицировать интерфейс обработки множества событий.
Когда мы говорим о тех критериях, по которым мы сравниваем асинхронные API, у нас не работают, по большому счету, операторы return, операторы-циклы, у нас не работают try/catch, естественно, и до единого интерфейса с синхронными операциями нам еще далеко.
В общем, для работы с множеством событий в ECMAScript 6 все не очень хорошо.
Когда мы говорим об единичных асинхронных операциях, у нас есть 3 подхода в ECMAScript 6:
Continuation Passing Style, они же — callback'и.
Я думаю, вы все к этому уже привыкли. Это когда мы делаем асинхронный запрос, передаем туда callback, и callback будет вызван либо с ошибкой, либо с результатом. Это распространенный подход, он есть и в браузере в Node.
Проблемы с этим подходом, я думаю, вы тоже все понимаете.
Вот так бы мы получали ленту твитов пользователей асинхронно, если бы все функции были синхронными.
Тот же самый код, но синхронно, выглядит следующим образом:
Можно увеличить шрифт, чтобы было виднее. И еще немного увеличить… И мы прекрасно понимаем, что это Schlecht!Script.
Я говорил, что они украли мою идею.
Continuation Passing Style — это стандартный API в браузере в Node.js, мы работаем с ними постоянно, но это неудобно. Поэтому у нас есть Promises. Это объект, который представляет собой асинхронную операцию.
Promise — это как бы обещание что-то сделать в будущем, асинхронно.
Мы можем навешивать callback'и на асинхронную операцию с помощью метода then, и это, в принципе, основной метод в API. И мы можем, очень важно, чейнить Promises, мы можем вызывать then последовательно, и каждая функция, которая передается в then, также может возвращать Promises. Вот так выглядит запрос ленты пользователя из твиттера на Promises.
Если мы сравниваем этот подход с Continuation Passing Style, Promises, естественно, удобнее пользоваться — они дают нам возможность писать гораздо меньше бойлерплейта.
Continuation Passing Style, по-прежнему, используется во всех API по умолчанию, в Node.js, в io.js., и они даже не планируют переходы на Promises по нескольким причинам. Сначала многие говорили, что причины — это производительность. И это действительно так, исследования 2013 года показывают, что Promises сильно позади callback'ов. Но с появлением таких библиотек как bluebird, мы уже можем уверенно сказать, что это не так, потому что Promises в bluebird приближаются в производительности к callback'ам. Важный момент: почему Promises не рекомендуют использовать в API до сих пор? Потому что, когда вы выдаете Promises из вашей API, вы навязываете имплементацию.
Все Promises библиотеки должны подчиняться стандарту, но выдавая Promises, вы так же выдаете и имплементацию, т.е. если вы написали свой код, используя медленные Promises, и выдаете медленный Promises из API, это будет не очень приятно пользователям. Поэтому для внешних API, конечно же, по-прежнему рекомендуют использовать callback'и.
Реализаций Promises — масса, и если вы не написали свою реализацию, вы ненастоящий JavaScript-программист. Я не написал свою реализацию Promises, поэтому мне пришлось придумать свой язык.
Итак, Promises, в общем, чуть меньше бойлерплейта, но, тем не менее, все еще не так хорошо.
Что по поводу Coroutines? Здесь уже начинаются интересные штуки. Представьте…
Это интересно. Мы были на JSConf в Будапеште, и там был сумасшедший человек, который на JavaScript программировал квадракоптер и что-то еще, и половину из того, что он пытался нам показать, у него не получалось. Поэтому он постоянно говорил: «OK, теперь представьте… Этот квадракоптер взлетел и все получилось...».
Представьте, что мы можем поставить функцию на паузу в какой-то момент ее выполнения.
Здесь функция получает имя пользователя, она лезет в базу, получает объект пользователя, возвращает его имя. Естественно, «залезть в базу» — функция getUser асинхронная. Что, если мы могли бы поставить функцию getUserName на паузу в момент вызова getUser? Вот, мы выполняем нашу getUserName функцию, дошли до getUser и остановились. getUser сходил в базу, получил объект, вернул его в функцию, мы продолжаем выполнение. Насколько круто это было бы.
Дело в том, что Coroutines дают нам эту возможность. Coroutines — это функция, которую мы можем приостанавливать и возобновлять в любой момент времени. Важный момент: мы не останавливаем всю программу.
Это не блокирующая операция. Мы останавливаем выполнение конкретной функции в конкретном месте.
Как getUserName выглядит с помощью генераторов на JavaScript? Нам нужно добавить «*» в объявление функции, чтобы сказать о том, что функция возвращает генератор. Мы можем использовать ключевое слово «yield» в том месте, где мы хотим поставить функцию на паузу. И важно помнить, что getUser здесь возвращает Promises.
Т.к. генераторы изначально придумали для того чтобы делать ленивые последовательности в JavaScript, по большому счету, использовать их для синхронного кода — это хак. Поэтому нам нужны библиотеки, чтобы как-то это компенсировать.
Здесь мы используем «co» для того, чтобы обернуть генератор и вернуть нам асинхронную функцию.
Итого, вот, что у нас получается:
У нас функция, внутри которой мы можем использовать if, for и другие операторы.
Чтобы вернуть значение, мы просто пишем return, так же, как в синхронной функции. Мы можем использовать try/catch внутри, и мы поймаем исключение.
Если Promises с getUser разрешится с ошибкой, это выкинется как исключение.
getUserName функция возвращает Promises, поэтому можем работать с ним так же, как с любым Promises, можем навешивать callback'и с помощью then, чейнить и т.д.
Но, как я уже сказал, использовать генераторы для асинхронного кода — это хак. Поэтому экспоузить в качестве внешней API нежелательно. Но использовать внутри приложения нормально, так что пользуйтесь, если у вас есть возможность транспайлить ваш код.
Есть множество реализаций. Какие-то используют генераторы, которые уже часть стандарта, есть Fibers, которые в Node.js работают и которые не используют генератор, а свои заморочки у них.
В общем, это третий подход для работы с единичными асинхронными операциями, и это пока еще хак, но мы уже можем использовать код, который приближен к синхронному. Мы можем использовать условные операторы, циклы и блоки try/catch.
Т.е. ECMAScript 6 для работы с единичными асинхронными операциями как бы немного приближает нас к желаемому результату, но по-прежнему проблема единого интерфейса не решена, даже в Coroutines, потому что нам нужно писать «*» специальную и использовать ключевой оператор «yield».
Итак, в ECMAScript 6 для работы с множеством событий у нас есть EventEmitter и Stream, для работы с единичными асинхронными операциями — CPS, Promises, Coroutines. И все это, вроде бы, здорово, но чего-то не хватает. Хочется большего, чего-то отважного, смелого, нового, хочется революции.
И ребята, которые пишут ES7, решили дать нам революцию и принесли для нас Async/Await и Async Generators.
Async/Await — это стандарт, который позволяет нам работать с единичными асинхронными операциями, такими, как, например, запросы в БД.
Вот так мы писали getUserName на генераторах:
А вот так тот же код выглядит с помощью Async/Await:
Все очень похоже, по большому счету, это шаг в сторону от хака к стандарту. Здесь у нас появилось ключевое слово «async», которое говорит о том, что функция асинхронная, и она вернет Promise. Внутри асинхронной функции мы можем использовать ключевое слово «await», там, где мы возвращаем Promise. И мы можем дожидаться выполнения этого Promise, мы можем ставить функцию на паузу и дожидаться выполнения этого Promise.
И так же у нас работают условные операторы, циклы, и try/catch, то бишь, асинхронные функции легализованы в ES7. Теперь мы явно говорим, что если функция асинхронная, то добавьте ключевое слово «async». И это, в принципе, не так плохо, но опять же единого интерфейса у нас нет.
Что по поводу множества событий? Здесь у нас есть стандарт, который называется Async Generators.
Что такое, вообще, множество? Как мы работаем с множеством в JavaScript?
C множеством мы работаем при помощи циклов, так давайте работать с множеством событий при помощи циклов.
Внутри асинхронной функции мы можем использовать ключевую конструкцию «for… on», которая нам позволяет итерировать по асинхронным коллекциям. Как бы.
В данном примере observe возвращает нам что-то, по чему мы можем итерировать, т.е. каждый раз, когда пользователь будет двигать мышкой, у нас будет появляться событие «mousemove». Мы попадаем в этот цикл, и обрабатываем как-то это событие. В данном случае рисуем черточки на экране.
Т.к. функция асинхронная, важно понимать, что она возвращает Promise. Но что, если мы хотим вернуть множество значений, если мы хотим, например, обрабатывать как-то сообщения из веб-сокета, фильтровать их? Т.е. у нас поступает множество и на выходе у нас множество. Здесь нам помогают асинхронные генераторы. Мы пишем «async function *» и говорим о том, что функция асинхронна, и мы возвращаем множество какое-то.
В данном случае мы смотрим на событие Message на веб-сокете и каждый раз, когда оно происходит, мы делаем какую-то проверку и, если проверка проходит, мы в возвращаемую коллекцию. Как бы добавляем этот Message.
При чем, все это происходит асинхронно. Сообщения не копятся, они возвращаются по мере того, как приходят. И здесь так же работают все наши условные операторы, циклы и try/catch.
Вопрос: что возвращает filterWSMessages?
Это точно не Promise, потому что это какая-то коллекция, что-то в этом роде… Но это и не массив.
Даже больше. Что возвращают эти Observe, которые генерят события?
А возвращают они т.н. объекты Observables. Это новое слово, но по большому счету, Observables — это потоки, это Streams. Т.о., круг замыкается.
Итого, у нас есть для работы с асинхронными единичными операциями Async/Await, для работы с множеством — Async Generators.
Давайте пройдемся и сделаем небольшую ретроспективу того, от чего мы ушли и к чему пришли.
Чтобы получить ленту твитов, в CPS мы писали бы вот такой код:
Много бойлерплейта, обработка ошибок, ручная практически и, в общем, не очень приятная.
С помощью Promise код выглядит таким вот образом:
Бойлерплейт поменьше, мы можем обрабатывать исключения в одном месте, что уже хорошо, но, тем не менее, есть эти then..., не работают ни try/ catch, ни условные операторы.
С помощью Async/Await получаем такую конструкцию:
И примерно то же нам дают Coroutines.
Здесь все шикарно, за исключением того, что нам нужно объявить эту функцию как «async».
Что касается множества событий, если мы говорим о DOM-событиях, вот так мы обрабатывали бы mousemove и рисовали бы по экрану с помощью EventEmitter'а:
Тот же самый код, но с помощью Stream'ов и библиотеки Kefir выглядит таким образом:
Мы создаем поток из событий mousemove на window, мы их каким-то образом фильтруем, и на каждое значение мы вызываем callback функцию. И, когда мы вызовем end у этого stream'а, мы автоматически отпишемся от событий в DOM, что немаловажно
Async Generators выглядит таким образом:
Это просто цикл, мы итерируем по коллекции асинхронных событий и производим какие-то операции над ними.
По-моему, это огромный путь.
Хотелось бы в заключение сказать пару слов о том, как, собственно, перестать отлаживать асинхронный код и начать жить.
- Определите вашу задачу, т.е. если вы работаете с множеством событий, имеет смысл посмотреть на Streams или, возможно даже, на Async Generators, если у вас есть транспайлер.
- Если вы работаете с базой, например, отправляете туда запросы или отправляете AJAX-запросы, которые могут либо зафейлиться, либо выполниться, используйте Promises.
- Обдумайте ваши ограничения. Если вы можете использовать транспайлер, имеет смысл посмотреть на Async/Await и Async Generators. Если вы пишете API, возможно, не имеет смысла экспоузить Promise в качестве внешней API и делать все на callback’ах.
- Используйте лучшие практики, помните про события error на потоках и на EventEmitter'ах.
- Помните про специальные методы вроде Promise.all и т.д.
Я знаю, всех вас интересует судьба Schlecht!Script, когда он будет выложен на GitHub и т.д., но дело в том, что из-за постоянной критики, обвинений в плагиате — говорят, что язык такой уже есть, ничего нового я не изобрел, я решил закрыть проект и посвятить себя, может быть, чему-то полезному, важному, интересному, возможно, я даже напишу свою библиотеку Promises.
Контакты
Этот доклад — расшифровка одного из лучших выступлений на конференции фронтенд-разработчиков FrontendConf. Мы уже открыли приём докладов на 2017 год.
На главной странице Frontend Conf, кстати, есть форма подписки на лучшие материалы конференции. Подпишитесь, минимум 8 лучших расшифровок, гарантированы.
А на ближайшей HighLoad++ уже 11 заявок по теме «Производительность фронтенда», например:
- Применяем стандарты кодирования NASA к JavaScript / Денис Радин (Liberty Global);
- Отрисовать за 16 мс / Глеб Михеев (Beta Digital Production);
- Порядок для скорости. Система структурирования фронтендовой части веб-приложений / Сергей Гущин (SuperJob);
Автор: