0,0
рейтинг
8 августа 2014 в 11:42

Разработка → Асинхронный JavaScript: без колбеков и промисов из песочницы

Наверное, каждый, кто использовал JavaScript, когда-либо сталкивался (или столкнётся в будущем) с асинхронными вызовами. Может быть, это будет обращение к базе на стороне сервера. Может быть — работа с таймером для создания анимации на сайте.

Для того, чтобы «побороть» асинхронность, используются разные инструменты от промисов до смены языка программирования. Но иногда очень хочется бросить всё и написать на чистом JS линейный код:

timeout(1000);
console.log('Hello, world!');


Можно ли реализовать нечто подобное? Разумеется, можно.
В данной статье мы рассмотрим один опасный, но действенный способ.

Варианты действий


Если Вы недовольны колбеками, можно найти несколько путей развития:
  • Смириться и продолжать использовать колбеки,
  • Перейти на абсолютно другой язык,
  • Перейти на язык, компилируемый в JavaScript,
  • Использовать возможности языка.

Разумеется, первый и второй варианты мы рассматривать не будем, оставив претворение их в жизнь на совести читателя. Третий вариант более интересен: мы как бы и не пишем на JS, но вопреки всему, несмотря на все наши наивные ожидания, на выходе получается код на нём. Это наводит на мысль: «А давайте я расширю JS, добавлю туда оператор async и назову AJS?»
Реализация подобного решения приводит к добавлению излишней сущности — компилятора нового языка. Автору при этом придётся хорошо разрекламировать свой Новый Инновационный Продукт и заставить общественность установить ещё один компилятор, а также ввести в процесс разработки ещё одно явное преобразование кода. Если автор не представляет интересы крупных компаний и не является признанным авторитетом своего времени, сделать ничего не удастся.

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

Выбор формы записи


В первом приближении можно записывать асинхронный код с помощью строковых литералов, обрабатывать его и вызывать eval. Правда, это по громоздкости не сильно отличается от стопки колбеков. Немного подумав, можно использовать комментарии внутри функций. Метод toString, применённый к функции, возвращает нам её исходный код в виде строки. Реализацией многострочных строк внутри комментариев уже никого не удивишь. В зависимости от желания автора добавлением пары строк кода удаляются или не удаляются пробелы в начале или переносы строк. С помощью этой технологии можно, например, реализовать многострочные регулярные выражения с комментариями или интерпретатор какого-нибудь языка вроде Brainfuck или самого JavaScript, стоит только добавить ещё пару строк.
Многострочные регулярные выражения
function createRegExp(func){
  if(typeof func !== 'function') throw new TypeError(func + ' is not a function');
 
  var m = func.toString().
    replace(/\s+|--.*?((?=\*\/)|$)/gm, ''). // удаление всего внешнего по отношению к /* */, удаление комментариев после --
    match(/^.*?\/\*\/((?:\\\/|.)*?)\/(?:([img]+?)\/?)?\*\/\}$/); // разбор регулярных выражений
 
  if(!m) throw new TypeError('Invalid RegExp format');
  return new RegExp(m[1], m[2] || undefined);
}


И для примера — разбор регулярного выражения для разбора регулярных выражений в комментариях многострочного регулярного выражения:

var re = createRegExp(function(){/*
  /
    ^.*?\/\*            -- какие-то символы и открытие комментария в начале строки
    \/                  -- символ "/" - начало регулярного выражения
    ( (?: \\\/ | . )*? )-- сохраняем наименьшую последовательность строки "\/" или любых символов,
    \/                  -- идущих до символа "/"
    (?:([img]+?)\/?)?   -- если есть последовательность из букв i, m, g, вероятно, заканчивающаяся на "/",
                        -- сохраняем её
    \*\/\}$             -- конец регулярного выражения сопровождается концом комментария и
                        -- исходный код функции завершается
  /
*/});


Заметим, что последовательность символов "--" всё ещё можно использовать в регулярном выражении, разделив дефисы пробелом.


Подобные ухищрения помогают при работе со строками, но полностью убивают подсветку синтаксиса, а она для исходного кода на ЯВУ крайне важна. Следовательно, код надо использовать не закомментированный, а рабочий. То есть тот, который бы хотя бы может быть разобран и представлен в виде AST, иначе получим ошибку интерпретатора.

Конструкции, которые мы можем использовать

Для реализации своего диалекта, оставаясь при этом в рамках языка, мы можем использовать:

  • Директивы в комментариях: var a = getAsync(b); //! async
  • Специальные имена переменных: var _async_a = getAsync(b);
  • Специальные имена функций: var a = getAsync(async(b));
  • Редко используемые конструкции: var a = +++getAsync(b);


Всё это позволит нам, получив исходный код функции, найти места, где используется «заветная» конструкция и заменить их на использование промисов/вложенных функций/отправку жалобы на незаконное использование асинхронных вызовов.
Каждая конструкция имеет свои плюсы и минусы. Например, можно получить предупреждение или ошибку о неопределённой переменной, предупреждения от статических анализаторов и т.п. Но, в конце концов, что использовать, разработчик решает сам.

Реализация


Для примера выберем вариант, изображённый на картинке.



В исходную функцию добавив аргумент __cb — функцию, в которую перейдёт управление после завершения последней асинхронной операции. Асинхронные вызовы будем обозначать стрелкой (<-), указывающей на то, что в переменные слева от неё неплохо было бы положить результат выполнения функции справа. Все стрелки заменим на генерацию вызова вложенного колбека; весь последующий код будет «упакован» в него. Каждый возврат из функции заменим на возврат с вызовом колбека __cb.

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

Для простоты, рассмотрим урезанную реализацию преобразования исходного кода, в которой нет возможности передать контекст исполнения, но уже демонстрирующую возможности подхода, поскольку в ней есть стрелка:

function async(func){

  // разбиваем код на "function..." (prefix) и тело (code)
  var parsed = func.toString()
    .match(/(function.*?\{)([\s\S]*)\}.*/);
  var prefix = parsed[1], code = parsed[2];
  
  // в lines храним неизменённые строки кода и заменённые строки, использовавщие "<-"
  // в nends храним уровень вложенности колбеков - количество последовательностей
  // закрывающихся скобок плюс один
  // по умолчанию функция имеет одну закрывающуюся скобку
  var lines = ['(' + prefix], nends = 2;
  
  // для кажлой строки... (для простоты разделяем по \n)
  code.split('\n').forEach(function(line){
  
    // ... проверяем, есть ли в ней "<-",
    // если нет - сохраняем строку как есть
    if(!/<-/.test(line))
      return void lines.push(line, '\n');
      
    // если есть - берём список имён слева от стрелки в качестве аргументов колбека,
    // формируем код вызова анонимной функции и добавляем в lines
    var parsed = /([\w\d_$\s,]+)<-(.+)\)/.exec(line);
    lines.push(parsed[2], ', function(', parsed[1], '){\n');
    
    ++nends; // не забываем про увеличение уровня вложенности
    
  });
  
  // Соединяем список строк и восстанавливаем уровень вложенности
  return lines.join('') + Array(nends).join('\n});');
}


Выглядит и записывается довольно просто для создаваемой функциональности!

Желающие могут посмотреть более подробный вариант, описывающий заявленное преобразование.
function async(func){
  if(typeof func != 'function')
    throw new TypeError('First argument of "async" must be a function.');
  
  // удаляем комментарии, выделяем префикс "function...", аргументы и тело
  var parsed = func.toString()
    .replace(/\/\*[\s\S]*?\*\/|\/\/.*$/mg, '')
    .match(/(function.*?)\((.*?)\)\s*\{([\s\S]*)\}.*/);
  var prefix = parsed[1], args = parsed[2], code = parsed[3];
  
  // если аргументы есть, добавляется запятая перед аргументом __cb
  if(!/^\s*$/.test(args)) args += ',';
  
  // имеем список строк и список "})"
  // если расширять функционал до поддержки работы с исключениями, ends понадобится
  // именно как список
  var lines = ['(', prefix, '(', args, '__cb', '){'], ends = ['\n})'];
  
  code.split('\n').forEach(function(line){
  
    // каждый выход из функции сопровождаем вызовом колбека
    line = line.replace(/return\s*(.*?);/, 'return void __cb($1);');
    
    // проверяем, встречается ли "<-" ровно один раз
    if(!/<-/.test(line)) return void lines.push(line, '\n');
    if(/<-.*?<-/.test(line)) throw new Error('"<-" is found more than 1 times in "'+line+'".');
    
    // заменяем стрелку на код вызова колбека
    var parsed = /([\w\d_$\s,]+)<-(.+)\((.*)\)/.exec(line);
    if(!parsed) throw new Error('"<-" is used incorrectly in "' + line + '".');
    lines.push(parsed[2], '(');
    if(parsed[3]) lines.push(parsed[3], ', ');
    lines.push('function(', parsed[1], '){\n');
    ends.push('\n});');
  });
  
  // склеиваем собранные строки и "})"
  return lines.concat(ends.reverse()).join('');
}



Использование


Допустим, что для получения аватара в слишком распределённой системе по адресу электронной почты сервер должен обратиться к одной базе данных, достать оттуда идентификатор пользователя, затем по идентификатору из второй базы получить данные пользователя (в том числе и путь к файлу), обратиться к файловой системе и загрузить файл.



Наивное решение выглядит как:
function getAvatarData(eMail, callback){
  db1.getID(eMail, function(id){
    db2.getData(id, function(user){
      fs.exists(user.avatarPath, function(exists){
        if(!exists) return void callback(null);
        fs.readFile(user.avatarPath, callback);
      });
    });
  });
}


А с помощью стрелки можно сделать его более «плоским»:
function getAvatarData_src (eMail) {
  id <- db1.getID(eMail);
  user <- db2.getData(id);
  exists <- fs.exists(user.avatarPath);
  if (!exists) return null;
  data <- fs.readFile(user.avatarPath);
  return data;
}
var getAvatarData = eval(async(getAvatarData_src));


Результаты


Оказалось, что можно довольно легко реализовать свой синтаксический сахар в JavaScript. Мы сохранили подсветку синтаксиса и автозавершение, избавились от внешнего препроцессора и множества колбеков. Быть может, получили инструмент для прототипирования.
Конечно, в идеальном случае следует использовать нормальный парсер JS, а не регулярные выражения (хотя, сам факт добавления функционала парой десятков строк радует), чтобы избавить себя от синтаксических сюрпризов и корректно обрабатывать все ситуации.

Возможно, придётся отказаться от минимизации кода, поскольку конструкция «a < — f()» может быть оптимизирована или преобразована в иную форму. Также придётся следить за контекстом. Как заметил внимательный читатель, описанные выше функции возвращают строку, а не готовую функцию. Подобное поведение выбрано из-за возможного разделения async и использующего её кода на разные файлы — в этом случае eval не «захватит» нужный лексический контекст функции; eval нужно вызывать в том же файле, что и пользовательский код.

Также представленная реализация работает некорректно с исключениями, но их обработку можно легко реализовать. И конечно же, она конфликтует с серьёзными начальниками и über-энтерпрайз-проектами.

Посему, повторяйте подобное только у себя дома!
Пугачёв Константин @sekrasoft
карма
14,2
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

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

  • +21
    Поздравляю, вы изобрели do-нотацию функции bind (или >>=) языка Haskell!
    • +14
      Осталось только обнаружить внутренние разногласия и получается имплементация промисов как монад в жс с синтаксисом хаскела.
      • 0
        Уже все есть
        • 0
          Да тем более даже так
  • +3
    Идея отличная. Вообще, мощь асинхронщины хорошо бы оставлять под капотом, так, чтобы исходный код по-прежнему был линейным.
  • +2
    А не проще ли будет RxJs заюзать?
    Функционал стрелочки кроется через flatMap. Плюс еще куча полезных фич.
    • 0
      Я только лишь смотрел видео с увлекательным выступлением Александра Соловьёва «Functional Reactive Programming & ClojureScript». Очень порадовался реактивному программированию, но более ничего в этой области не изучал. Надо только придумать себе задачу и наконец попробовать.
  • +3
    А чего не streamline.js?

    А вообще, с такими конвертерами всё равно не решаются важные проблемы:

    1. Проблема с производительностью. Наматывать контексты замыканий на коллбэки, а потом разматывать — это гораздо дольше, чем нативные call/ret. Плюс оптимизатор v8 умеет такие оптимизации, как global value numbering, loop invariant motion. А они работают, если граф потока управления един, а не разбит по 100 колбэкам.
    2. Проблема с отладкой.
    3. Нарушение контрактов в ряде случаев. Например, event bubbling переходит к следующим элементам сразу по возвращении из listener'ов. А если listener написан с помощью streamline.js, то нужны специальные ухищрения.

    По мне, так не выпендривались бы вендоры, а давно внедрили бы кучу хороших синхронных API в webworkers, которые вроде как в черновиках есть, но не реализованы.
    • +1
      Так вышло, что острой необходимости в моей стрелочке я так и не почувствовал, потому и остальные решения специально не искал.
      Да, проблемы важные. Замечу только, что производительность в случаях больших задержек сети не так критична, и можно иногда позволить себе использовать библиотеки и велосипеды.
  • +15
    Идея интересная. А вы смотрели в сторону async и await из es7?
    Например, сейчас в node.js можно писать так:
    async function loadStory() {
      try {
        let story = await getJSON('story.json');
        addHtmlToPage(story.heading);
        addTextToPage("All done");
      } catch (err) {
        addTextToPage("Argh, broken: " + err.message);
      }
    }
    
    (async function() {
      await loadStory();
      console.log("Yey, story successfully loaded!");
    }());
    

    подключив соответствующий препроцессор этот код будет на лету преобразован в es6 код с использованием нативных генераторов и Promise'ов.
    • +1
      Не смотрел. Спасибо!
      Честно говоря, я рассматривал ES6 как наше светлое будущее, которое всё ещё не наступило (и ждал arrow functions), а про ES7 не знал. Обязательно обновлю Node.js.
      • +1
        es6 уже наступил :)
      • 0
        Собственно, вы можете свою реализацию переделать на использование ключевых слов async и await
        • 0
          Посмотрел ECMAScript 6 compatibility table — слишком много там красного для столь оптимистичных заявлений :) Чувствуешь себя первым обладателем телефона в маленьком городке. Вроде радостно, но звонить некому.
          Про препроцессор не сразу внимательно прочитал и осознал. По пути нашёл форк Node.js, где всё уже два года как работает (автору — почёт).

          вы можете свою реализацию переделать на использование ключевых слов async и await

          Если наличие async/await можно как-то проверить в коде, интересно (в качестве ещё одного домашнего эксперимента) было бы использовать либо их, либо колбеки в зависимости от версии. А если async/await появится везде, можно забыть о стрелочках.
          • 0
            Регуляркой проверить наличие await/async в коде будет гораздо проще, чем проверить многи другие конструкции языка. Тем более, что async всегда идёт перед function, а await перед CallExpression. Ну или воспользоваться esprima (найти только ветку или форк, который поддерживает await/async).
            • 0
              Регуляркой проверить наличие await/async в коде будет гораздо проще, чем проверить многи другие конструкции языка.

              Я имел в виду проверку поддержки async/await в используемой версии языка, какими-то конструкциями в коде. Извините, если запутал.
              • +1
                var asyncSupported = false;
                try {
                  asyncSupported = typeof (new Function('return async function(){ }')) === 'function'
                }
                catch(e){}
                
      • 0
        del
    • 0
      В nodejs так нельзя сейчас.
  • –1
    Имхо, лучше подключить нормальную библиотеку или препроцессор, чем делать это на коленке.
  • 0
    Iced CoffeeScript вам в помощь.
  • +4
    Можно использовать генераторы из ES6 (есть в Chrome, FF и node.js).

    Вот например с использованием co

    co(function *(){
      var a = yield get('http://google.com');
      var b = yield get('http://yahoo.com');
      var c = yield get('http://cloudup.com');
      console.log(a[0].statusCode);
      console.log(b[0].statusCode);
      console.log(c[0].statusCode);
    })()
    
    • 0
      Прошу заметить, что в Node.js не мажорной версии, а unstable. 0.12 стабильная версия в которой появятся генераторы, стоит ждать минимум в конце года.
    • 0
      не пугайте народ. Они спокойно traceur-ом компилируются в ie9+. А нативно только в фф. Ноды 0.12 не было же вроде пока?
      • 0
        Скажем так: ноды 0.12 ещё не было пока, но с 7 августа началась работа над Node.js v0.13; следовательно, как только в v0.11 окончится остаточная ловля багов и недоделок, так сразу и v0.12 выйдет. Неизбежность.
  • +1
    Получается, что использовать стрелку можно только на самом верхнем уровне функции, а, например, внутри if или for уже не выйдет.
    • 0
      Или внутри выражения
      • +2
        Да, Вы правы. Я должен был упомянуть об этом, но забыл.
        С использованием нормального парсера можно учесть не только работу с исключениями, о которой я упомянул, но и конструкции for/while/другое. И аккуратно реализовать два сценария «return» — выход из функции и выход из функции с вызовом колбека.
  • 0
    По поводу arrow function то в версии Chrome Canary уже давно можно писать в такой нотации, а кому интересно следить за развитием нововведений может посмотреть тут www.chromestatus.com/features/5047308127305728. Очень жду этого в NodeJS, после C# этих стрелок ой как не хватает.
  • 0
    Также если кому интересно подобный подход используется в C# github.com/yortus/asyncawait
  • 0
    Вы заново изобрели backcall из Livescript )
  • 0

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