Як перестати налагоджувати асинхронний код і почати жити

Андрій Саломатін ( 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 за назвою подієві а, передаючи туди с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.

Контакти
» twitter
» filipovskii_off

Ця доповідь — розшифровка одного з кращих виступів на конференції фронтенд-розробників FrontendConf. Ми вже відкрили прийом доповідей на 2017 рік.

На головній сторінці Frontend Conf, до речі, є форма підписки на кращі матеріали конференції. Підпишіться, мінімум 8 кращих розшифровок, гарантовані.

А на найближчій HighLoad++ вже 11 заявок на тему «Продуктивність фронтенда», наприклад:
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.