Всем доброго времени суток. Сегодня я хочу рассказать о том, как писал реализацию механизма промисов для своего JS движка. Как известно, не так давно вышел новый стандарт ECMA Script 6, и концепция промисов выглядит довольно интересно, а также уже очень много где применяется веб-разработчиками. Поэтому для любого современного JS движка это, безусловно, must-have вещь.
Внимание: в статье довольно много кода. Код не претендует на красоту и высокое качество, поскольку весь проект писался одним человеком и всё ещё находится в бете. Цель данного повествования — показать, как же всё работает под капотом. Кроме того, после небольшой адаптации данный код можно использовать для создания проектов чисто на Java, без оглядки на JavaScript.
Первое, с чего стоило начать написание кода — это с изучения того, как всё должно работать в итоге. Архитектура получившегося модуля во многом определялась по ходу процесса.
Promise — это специальный объект, который при создании находится в состоянии pending (пусть это будет константа равная 0).
Далее объект начинает исполнять функцию, которая была передана в его конструктор при создании. Если функция не была передана — следуя стандарту ES6, мы должны бросить исключение argument is not a function. Однако в нашей Java реализации можно ничего не кидать, и создать объект «как есть» (просто потом добавить дополнительную логику, я скажу об этом позже).
Итак, конструктор принимает функцию. В нашем движке это объект класса Function, реализующий метод call. Данный метод позволяет вызвать функцию, принимая на вход контекст исполнения, вектор с аргументами, и boolean параметр, определяющий режим вызова (вызов как конструктора или обычный режим).
Далее эта функция записывается в поле нашего объекта и потом может быть вызвана.
Заодно здесь же создадим константы для наших двух оставшихся состояний, и int поле, хранящее текущее состояние объекта.
Итак, согласно стандарту наша функция в процессе своего выполнения может вызвать одну из двух функций (которые передаются ей в качестве первых двух аргументов, поэтому по-хорошему мы должны задать их имена в сигнатуре функции). Обычно используют что-то вроде resolve и reject для простоты.
Это — обычные функции с точки зрения JavaScript, а значит, объекты Function с точки зрения нашего движка. Добавим поля и для них:
Эти функции могут быть вызваны в любой момент нашей основной рабочей функцией, а значит, должны находиться в её области видимости (scope). Кроме того, отработав, они должны менять состояние нашего объекта на fulfilled и rejected, соответственно. Наши функции ничего не знают про промисы (и знать не должны). Поэтому, нам нужно создать некую обёртку, которая будет про них знать, и сможет инициировать смену состояния.
Также нам нужен метод setState() для нашего объекта (с дополнительными проверками: например, мы не имеем права менять состояние, если оно уже fulfilled или rejected).
Займёмся конструктором нашего объекта:
Здесь, кажется, всё понятно. Если функция передана — мы обязаны вызвать её немедленно. Если нет — то ничего пока что не делаем (а наш объект сохраняет состояние pending).
Теперь про установку самих этих обработчиков (ведь в основной функции мы только объявляем их имена как формальные параметры). Для этого стандартом предусмотрены три варианта: Promise.then(resolve, reject), Promise.then(resolve) (эквивалентно Promise.then(resolve, null)), и Promise.catch(reject) (эквивалентно Promise.then(null, reject)).
Насчёт функции then: очевидно, что лучше всего реализовать подробно метод с двумя аргументами, а оставшиеся два сделать как «шорткаты» на него. Так и поступим:
В конце мы возвращаем ссылку на себя: это нужно для последующей реализации чейнинга промисов.
Что за блок у нас в начале метода, спросите вы? А дело в том, что наш обработчик мог исполниться ещё до того, как мы в первый раз вызвали then (такое бывает, и это совершенно нормально). В этом случае мы должны вызвать нужный обработчик из переданных в метод немедленно.
В месте многоточия потом будет ещё код, про него чуть позже.
Далее идёт установка наших обработчиков в нужные поля.
А вот далее самое интересное. Предположим, наша рабо��ая функция исполняется достаточно долго (запрос по сети, или просто setTimeout для учебного примера). В этом случае она по сути как бы исполнится, но создаст ряд объектов (таймер, сетевой XmlHttpRequest интерфейс и т.д.) которые исполнят некоторый код позднее. И эти объекты имеют доступ к scope нашей функции!
Поэтому сейчас ещё может быть не поздно добавить нужные переменные в её область видимости (а если поздно — то исполнится код в начале метода). Для этого мы создаём новый метод в классе Function:
Второй метод нам фактически не понадобится: он создан чисто ради полноты картины.
Теперь время реализовать шорткаты:
catch — зарезервированное слово в языке java, поэтому нам пришлось добавить знак подчёркивания.
Теперь опишем метод setState. В первом приближении он будет выглядеть так:
Отлично, теперь мы сможем менять состояние из наших обработчиков — точнее, из обёрток над ними. Займёмся обёртками:
Типов обёрток у нас два, но класс один. А за тип отвечает целочисленное поле to_state. Вроде, неплохо :)
Обёртка имеет ссылки как на свою функцию, так и на свой промис. Это очень важно.
С конструктором всё понятно, давайте посмотрим на метод call, переопределяющий метод класса Function. Для нашего JS интерпретатора — обёртки такие же функции, то есть объекты с тем же интерфейсом, которые можно вызывать, получать их значения, и так далее.
Сначала нам нужно пробросить в функцию объект Caller, полученный при вызове обёртки — это нужно как минимум для корректного всплытия исключений.
Далее мы вызываем нашу функцию и сохраняем в поле результат её исполнения. Заодно устанавливаем его в объект промиса, для чего создадим там ещё один метод setResult:
Про последнюю строчку пока говорить не будем: это нужно для чейнинга. В самом тривиальном случае там вернётся то же самое значение, которое мы только что получили и передали.
Важный момент: рабочая функция может вызвать resolve или reject до того, как мы вызовем метод then или catch (или мы можем не вызвать их вовсе). Чтобы при этом у нас не возникло исключения, прямо при создании промиса у нас создаются две «дефолтных» обёртки, у которых нет функций-обработчиков. При вызове они всего лишь поменяют состояние нашего промиса (и потом при вызове then это будет учтено).
Если коротко, чейнинг — это возможность писать вещи вида p.then(f1, f2).then(f3, f4).catch(f5).
Именно для этого наши методы then и _catch возвращают объект Promise.
Первое, что говорит нам стандарт — это то, что метод then при наличии существующего обработчика должен создать новый промис и добавить его в цепочку. Поскольку наши промисы должны быть равны между собой — пускай у нас не будет никакого головного промиса, хранящего линейный список, а каждый промис будет хранить только ссылку на следующий (изначально она равна null):
Вот и наш недостающий блок: если у нас уже есть следующий промис — передаём вызов ему и выходим (а он, если надо, передаст следующему, и так до конца). А если его нет — создаём и назначаем ему обработчики, которые получили в метод, после чего возвращаем уже его. Всё просто.
Теперь доработаем метод setState:
Во-первых, стандарт говорит о том, что мы обязаны передать обработчику следующего промиса результат работы предыдущего (в этом основной смысл чейнинга — назначить операцию, потом назначить вторую, и сделать так, чтобы вторая при старте приняла результат первой).
Во-вторых — ошибки обрабатываются особым образом. Если успешный результат передаётся по цепочке (видоизменяясь) до конца, то вот возникшая в коде обработчика ошибка — передаётся только на один шаг, до следующего onrejected, либо всплывает наверх, если достигнут конец цепочки.
В-третьих — функции могут вернуть новый промис. В этом случае мы обязаны подменить наш next, если он уже задан, на него (перебросив имеющиеся обработчики). Это, опять же, позволяет сочетать а цепочке обработчики моментального исполнения, и асинхронные — которые сами возвращают Promise.
Вышеприведённый код адресует все эти сценарии.
Пока что мы управляем всем со стороны Java кода. Тем не менее, всё уже работает: через полторы секунды мы увидим в консоли надпись «Promise fulfilled: OK». Кстати, наши функции resolve и reject, будучи вызванными из рабочей функции промиса, без чейнинга, могут принимать произвольное число аргументов. Весьма удобно. В этом примере мы передали строку «OK».
Ещё небольшое замечание: у промисов, созданных во время чейнинга, отсутствуют рабочие функции в принципе. У них сразу вызываются обработчики при смене состояния предыдущего промиса.
Пример посложнее:
Вызвав данный пример, мы получим следующий вывод:
Первые фигурные скобки — это объект промиса, который нам вернула наша цепочка вызовов then в результате чейнинга. В функции cbk1 мы вернули «OK» — и это значение было передано в cbk2, что мы и видим в последней строке. Внутри cbk2 мы бросаем ошибку со значением «ERROR» — поэтому cbk3 у нас не исполняется, зато исполняется err (как и должно быть при возникновении ошибки в обработчике предыдущего промиса в цепи). Но этот код исполняется моментально, а вот вывод cbk2 осуществляется через вспомогательную функцию, повешенную на таймер. Она имеет доступ к переменной str, как и должна, но её вывод идёт из-за этого ниже. Если исполнить данный пример в Chrome 49, мы получим ровно тот же вывод с одним исключением: переменная str не видна в анонимной функции, переданной в setTimeout. Это особенность поведения стрелочных функций в Хроме (а возможно, так нужно по стандарту, здесь я затрудняюсь сказать, в чём дело). Если поменять стрелочную функцию на обычную — вывод станет идентичным.
Но это ещё не всё. Наша конечная цель — чтобы новые возможности мог использовать JS код, исполняемый нашим интерпретатором. Впрочем, это уже дело техники.
Создаём конструктор:
И объект-прототип с набором нужных методов:
Не забудем добавить в конструктор Promise одну строчку в самом начале, чтобы всё работало:
И поменяем немного наш тест:
Вывод не должен измениться.
На этом всё! Всё отлично работает, можно писать дополнительные юнит-тесты и искать возможные ошибки.
Как приспособить этот механизм для Java? Очень просто. Создаём класс, аналогичный нашему Function, который что-то делает в методе operate. И оборачиваем уже его в нашу обёртку. В любом случае, на этот счёт есть много замечательных паттернов, с которыми можно поиграться.
Надеюсь, данная статья была кому-то полезна. Исходники движка я обязательно выложу, как только доведу их до ума и добавлю недостающий функционал. Удачного дня!
Внимание: в статье довольно много кода. Код не претендует на красоту и высокое качество, поскольку весь проект писался одним человеком и всё ещё находится в бете. Цель данного повествования — показать, как же всё работает под капотом. Кроме того, после небольшой адаптации данный код можно использовать для создания проектов чисто на Java, без оглядки на JavaScript.
Первое, с чего стоило начать написание кода — это с изучения того, как всё должно работать в итоге. Архитектура получившегося модуля во многом определялась по ходу процесса.
Что такое Promise?
Promise — это специальный объект, который при создании находится в состоянии pending (пусть это будет константа равная 0).
Далее объект начинает исполнять функцию, которая была передана в его конструктор при создании. Если функция не была передана — следуя стандарту ES6, мы должны бросить исключение argument is not a function. Однако в нашей Java реализации можно ничего не кидать, и создать объект «как есть» (просто потом добавить дополнительную логику, я скажу об этом позже).
Итак, конструктор принимает функцию. В нашем движке это объект класса Function, реализующий метод call. Данный метод позволяет вызвать функцию, принимая на вход контекст исполнения, вектор с аргументами, и boolean параметр, определяющий режим вызова (вызов как конструктора или обычный режим).
Далее эта функция записывается в поле нашего объекта и потом может быть вызвана.
public static int PENDING = 0; public static int FULFILLED = 1; public static int REJECTED = 2; ... private int state = 0; private Function func;
Заодно здесь же создадим константы для наших двух оставшихся состояний, и int поле, хранящее текущее состояние объекта.
Итак, согласно стандарту наша функция в процессе своего выполнения может вызвать одну из двух функций (которые передаются ей в качестве первых двух аргументов, поэтому по-хорошему мы должны задать их имена в сигнатуре функции). Обычно используют что-то вроде resolve и reject для простоты.
Это — обычные функции с точки зрения JavaScript, а значит, объекты Function с точки зрения нашего движка. Добавим поля и для них:
public Function onFulfilled = null; public Function onRejected = null;
Эти функции могут быть вызваны в любой момент нашей основной рабочей функцией, а значит, должны находиться в её области видимости (scope). Кроме того, отработав, они должны менять состояние нашего объекта на fulfilled и rejected, соответственно. Наши функции ничего не знают про промисы (и знать не должны). Поэтому, нам нужно создать некую обёртку, которая будет про них знать, и сможет инициировать смену состояния.
Также нам нужен метод setState() для нашего объекта (с дополнительными проверками: например, мы не имеем права менять состояние, если оно уже fulfilled или rejected).
Займёмся конструктором нашего объекта:
public Promise(Function f) { func = f; onFulfilled = new PromiseHandleWrapper(this, null, Promise.FULFILLED); onRejected = new PromiseHandleWrapper(this, null, Promise.REJECTED); if (f != null) { Vector<JSValue> args = new Vector<JSValue>(); args.add(onFulfilled); args.add(onRejected); func.call(null, args, false); } }
Здесь, кажется, всё понятно. Если функция передана — мы обязаны вызвать её немедленно. Если нет — то ничего пока что не делаем (а наш объект сохраняет состояние pending).
Теперь про установку самих этих обработчиков (ведь в основной функции мы только объявляем их имена как формальные параметры). Для этого стандартом предусмотрены три варианта: Promise.then(resolve, reject), Promise.then(resolve) (эквивалентно Promise.then(resolve, null)), и Promise.catch(reject) (эквивалентно Promise.then(null, reject)).
Насчёт функции then: очевидно, что лучше всего реализовать подробно метод с двумя аргументами, а оставшиеся два сделать как «шорткаты» на него. Так и поступим:
public Promise then(Function f1, Function f2) { if (state == Promise.FULFILLED || state == Promise.REJECTED) { onFulfilled = new PromiseHandleWrapper(this, f1, Promise.FULFILLED); onRejected = new PromiseHandleWrapper(this, f2, Promise.REJECTED); onFulfilled.call(null, new Vector<JSValue>(), false); return this; } ... onFulfilled = new PromiseHandleWrapper(this, f1, Promise.FULFILLED); onRejected = new PromiseHandleWrapper(this, f2, Promise.REJECTED); if (func != null) { String name1 = func.getParamsCount() > 0 ? func.getParamName(0) : "resolve"; String name2 = func.getParamsCount() > 1 ? func.getParamName(1) : "reject"; func.injectVar(name1, onFulfilled); func.injectVar(name2, onRejected); } if (f1 != null) has_handler = true; if (f2 != null) has_error_handler = true; return this; }
В конце мы возвращаем ссылку на себя: это нужно для последующей реализации чейнинга промисов.
Что за блок у нас в начале метода, спросите вы? А дело в том, что наш обработчик мог исполниться ещё до того, как мы в первый раз вызвали then (такое бывает, и это совершенно нормально). В этом случае мы должны вызвать нужный обработчик из переданных в метод немедленно.
В месте многоточия потом будет ещё код, про него чуть позже.
Далее идёт установка наших обработчиков в нужные поля.
А вот далее самое интересное. Предположим, наша рабо��ая функция исполняется достаточно долго (запрос по сети, или просто setTimeout для учебного примера). В этом случае она по сути как бы исполнится, но создаст ряд объектов (таймер, сетевой XmlHttpRequest интерфейс и т.д.) которые исполнят некоторый код позднее. И эти объекты имеют доступ к scope нашей функции!
Поэтому сейчас ещё может быть не поздно добавить нужные переменные в её область видимости (а если поздно — то исполнится код в начале метода). Для этого мы создаём новый метод в классе Function:
public void injectVar(String name, JSValue value) { body.scope.put(name, value); } public void removeVar(String name) { body.scope.remove(name); }
Второй метод нам фактически не понадобится: он создан чисто ради полноты картины.
Теперь время реализовать шорткаты:
public Promise then(Function f) { return then(f, null); } public Promise _catch(Function f) { return then(null, f); }
catch — зарезервированное слово в языке java, поэтому нам пришлось добавить знак подчёркивания.
Теперь опишем метод setState. В первом приближении он будет выглядеть так:
public void setState(int value) { if (this.state > 0) return; this.state = value; }
Отлично, теперь мы сможем менять состояние из наших обработчиков — точнее, из обёрток над ними. Займёмся обёртками:
public class PromiseHandleWrapper extends Function { public PromiseHandleWrapper(Promise p, Function func, int type) { this.promise = p; this.func = func; this.to_state = type; } @Override public JSValue call(JSObject context, Vector<JSValue> args, boolean as_constr) { return call(context, args); } @Override public JSValue call(JSObject context, Vector<JSValue> args) { JSValue result; if (func != null) { Block b = getCaller(); if (b == null) { b = func.getParentBlock(); while (b.parent_block != null) { b = b.parent_block; } } func.setCaller(b); result = func.call(context, args, false); } else { result = Undefined.getInstance(); } promise.setResult(result); promise.setState(to_state); return promise.getResult(); } @Override public JSError getError() { return func.getError(); } private Promise promise; private Function func; private int to_state = 0; }
Типов обёрток у нас два, но класс один. А за тип отвечает целочисленное поле to_state. Вроде, неплохо :)
Обёртка имеет ссылки как на свою функцию, так и на свой промис. Это очень важно.
С конструктором всё понятно, давайте посмотрим на метод call, переопределяющий метод класса Function. Для нашего JS интерпретатора — обёртки такие же функции, то есть объекты с тем же интерфейсом, которые можно вызывать, получать их значения, и так далее.
Сначала нам нужно пробросить в функцию объект Caller, полученный при вызове обёртки — это нужно как минимум для корректного всплытия исключений.
Далее мы вызываем нашу функцию и сохраняем в поле результат её исполнения. Заодно устанавливаем его в объект промиса, для чего создадим там ещё один метод setResult:
public JSValue getResult() { return result; } public void setResult(JSValue value) { result = value; }
Про последнюю строчку пока говорить не будем: это нужно для чейнинга. В самом тривиальном случае там вернётся то же самое значение, которое мы только что получили и передали.
Важный момент: рабочая функция может вызвать resolve или reject до того, как мы вызовем метод then или catch (или мы можем не вызвать их вовсе). Чтобы при этом у нас не возникло исключения, прямо при создании промиса у нас создаются две «дефолтных» обёртки, у которых нет функций-обработчиков. При вызове они всего лишь поменяют состояние нашего промиса (и потом при вызове then это будет учтено).
Чейнинг промисов
Если коротко, чейнинг — это возможность писать вещи вида p.then(f1, f2).then(f3, f4).catch(f5).
Именно для этого наши методы then и _catch возвращают объект Promise.
Первое, что говорит нам стандарт — это то, что метод then при наличии существующего обработчика должен создать новый промис и добавить его в цепочку. Поскольку наши промисы должны быть равны между собой — пускай у нас не будет никакого головного промиса, хранящего линейный список, а каждый промис будет хранить только ссылку на следующий (изначально она равна null):
public Promise then(Function f1, Function f2) { if (state == Promise.FULFILLED || state == Promise.REJECTED) { onFulfilled = new PromiseHandleWrapper(this, f1, Promise.FULFILLED); onRejected = new PromiseHandleWrapper(this, f2, Promise.REJECTED); onFulfilled.call(null, new Vector<JSValue>(), false); return this; } if (has_handler || has_error_handler) { if (next != null) { return next.then(f1, f2); } Promise p = new Promise(null); p.then(f1, f2); next = p; return p; } onFulfilled = new PromiseHandleWrapper(this, f1, Promise.FULFILLED); onRejected = new PromiseHandleWrapper(this, f2, Promise.REJECTED); if (func != null) { String name1 = func.getParamsCount() > 0 ? func.getParamName(0) : "resolve"; String name2 = func.getParamsCount() > 1 ? func.getParamName(1) : "reject"; func.injectVar(name1, onFulfilled); func.injectVar(name2, onRejected); } if (f1 != null) has_handler = true; if (f1 != null) has_error_handler = true; return this; } ... private Promise next = null;
Вот и наш недостающий блок: если у нас уже есть следующий промис — передаём вызов ему и выходим (а он, если надо, передаст следующему, и так до конца). А если его нет — создаём и назначаем ему обработчики, которые получили в метод, после чего возвращаем уже его. Всё просто.
Теперь доработаем метод setState:
public void setState(int value) { if (this.state > 0) return; this.state = value; Vector<JSValue> args = new Vector<JSValue>(); if (result != null) args.add(result); if (value == Promise.FULFILLED && next != null) { if (onFulfilled.getError() == null) { if (result != null && result instanceof Promise) { ((Promise)result).then(next.onFulfilled, next.onRejected); next = (Promise)result; } else { result = next.onFulfilled.call(null, args, false); } } else { args = new Vector<JSValue>(); args.add(onFulfilled.getError().getValue()); result = next.onRejected.call(null, args, false); } } if (value == Promise.REJECTED && !has_error_handler && next != null) { result = next.onRejected.call(null, args, false); } }
Во-первых, стандарт говорит о том, что мы обязаны передать обработчику следующего промиса результат работы предыдущего (в этом основной смысл чейнинга — назначить операцию, потом назначить вторую, и сделать так, чтобы вторая при старте приняла результат первой).
Во-вторых — ошибки обрабатываются особым образом. Если успешный результат передаётся по цепочке (видоизменяясь) до конца, то вот возникшая в коде обработчика ошибка — передаётся только на один шаг, до следующего onrejected, либо всплывает наверх, если достигнут конец цепочки.
В-третьих — функции могут вернуть новый промис. В этом случае мы обязаны подменить наш next, если он уже задан, на него (перебросив имеющиеся обработчики). Это, опять же, позволяет сочетать а цепочке обработчики моментального исполнения, и асинхронные — которые сами возвращают Promise.
Вышеприведённый код адресует все эти сценарии.
Первые тесты
JSParser jp = new JSParser("function cbk(str) { \"Promise fulfilled: \" + str } function f(resolve, reject) { setTimeout(function() { resolve(\"OK\") }, 1500) }"); System.out.println(); System.out.println("function cbk(str) { \"Promise fulfilled: \" + str }"); System.out.println("function f(resolve, reject) { setTimeout(function() { resolve(\"OK\") }, 1500) }"); System.out.println(); Expression exp = Expression.create(jp.getHead()); exp.eval(); jsparser.Function f = (jsparser.Function)Expression.getVar("f", exp); f.setSilent(true); jsparser.Promise p = new jsparser.Promise(f); p.then((jsparser.Function)Expression.getVar("cbk", exp));
Пока что мы управляем всем со стороны Java кода. Тем не менее, всё уже работает: через полторы секунды мы увидим в консоли надпись «Promise fulfilled: OK». Кстати, наши функции resolve и reject, будучи вызванными из рабочей функции промиса, без чейнинга, могут принимать произвольное число аргументов. Весьма удобно. В этом примере мы передали строку «OK».
Ещё небольшое замечание: у промисов, созданных во время чейнинга, отсутствуют рабочие функции в принципе. У них сразу вызываются обработчики при смене состояния предыдущего промиса.
Пример посложнее:
JSParser jp = new JSParser("function cbk1(str) { \"Promise 1 fulfilled: \" + str; return str } " + "function cbk2(str) { setTimeout(str => { \"Promise 2 fulfilled: \" + str }, 1000); throw \"ERROR\" } " + "function cbk3(str) { \"Promise 3 fulfilled: \" + str; return str } " + "function err(str) { \"An error has occured: \" + str } " + "function f(resolve, reject) { setTimeout(function() { resolve(\"OK\") }, 300) }"); System.out.println(); System.out.println("function cbk1(str) { \"Promise 1 fulfilled: \" + str; return str }"); System.out.println("function cbk2(str) { setTimeout(str => { \"Promise 2 fulfilled: \" + str }, 1000); throw \"ERROR\" }"); System.out.println("function cbk3(str) { \"Promise 3 fulfilled: \" + str; return str }"); System.out.println("function err(str) { \"An error has occured: \" + str }"); System.out.println("function f(resolve, reject) { setTimeout(function() { resolve(\"OK\") }, 300) }"); System.out.println("(new Promise(f)).then(cbk1).then(cbk2).then(cbk3, err)"); System.out.println(); Expression exp = Expression.create(jp.getHead()); ((jsparser.Function)Expression.getVar("f", exp)).setSilent(true); ((jsparser.Function)Expression.getVar("cbk2", exp)).setSilent(true); exp.eval(); jsparser.Function f = (jsparser.Function)Expression.getVar("f", exp); f.setSilent(true); jsparser.Promise p = new jsparser.Promise(f); p.then((jsparser.Function)Expression.getVar("cbk1", exp)) .then((jsparser.Function)Expression.getVar("cbk2", exp)) .then((jsparser.Function)Expression.getVar("cbk3", exp), (jsparser.Function)Expression.getVar("err", exp));
Вызвав данный пример, мы получим следующий вывод:
{}
"Promise 1 fulfilled: OK"
"OK"
"An error has occured: ERROR"
undefined
"Promise 2 fulfilled: OK"
Первые фигурные скобки — это объект промиса, который нам вернула наша цепочка вызовов then в результате чейнинга. В функции cbk1 мы вернули «OK» — и это значение было передано в cbk2, что мы и видим в последней строке. Внутри cbk2 мы бросаем ошибку со значением «ERROR» — поэтому cbk3 у нас не исполняется, зато исполняется err (как и должно быть при возникновении ошибки в обработчике предыдущего промиса в цепи). Но этот код исполняется моментально, а вот вывод cbk2 осуществляется через вспомогательную функцию, повешенную на таймер. Она имеет доступ к переменной str, как и должна, но её вывод идёт из-за этого ниже. Если исполнить данный пример в Chrome 49, мы получим ровно тот же вывод с одним исключением: переменная str не видна в анонимной функции, переданной в setTimeout. Это особенность поведения стрелочных функций в Хроме (а возможно, так нужно по стандарту, здесь я затрудняюсь сказать, в чём дело). Если поменять стрелочную функцию на обычную — вывод станет идентичным.
Проброс в JavaScript
Но это ещё не всё. Наша конечная цель — чтобы новые возможности мог использовать JS код, исполняемый нашим интерпретатором. Впрочем, это уже дело техники.
Создаём конструктор:
public class PromiseC extends Function { public PromiseC() { items.put("prototype", PromiseProto.getInstance()); PromiseProto.getInstance().set("constructor", this); } @Override public JSValue call(JSObject context, Vector<JSValue> args, boolean as_constr) { return call(context, args); } @Override public JSValue call(JSObject context, Vector<JSValue> args) { if (args.size() == 0) return new Promise(null); if (!args.get(0).getType().equals("Function")) { JSError e = new JSError(null, "Type error: argument is not a function", getCaller().getStack()); getCaller().error = e; return new Promise(null); } return new Promise((Function)args.get(0)); } }
И объект-прототип с набором нужных методов:
public class PromiseProto extends JSObject { class thenFunction extends Function { @Override public JSValue call(JSObject context, Vector<JSValue> args, boolean as_constr) { if (args.size() == 1 && args.get(0).getType().equals("Function")) { return ((Promise)context).then((Function)args.get(0)); } else if (args.size() > 1 && args.get(0).getType().equals("Function") && args.get(1).getType().equals("Function")) { return ((Promise)context).then((Function)args.get(0), (Function)args.get(1)); } else if (args.size() > 1 && args.get(0).getType().equals("null") && args.get(1).getType().equals("Function")) { return ((Promise)context)._catch((Function)args.get(1)); } return context; } } class catchFunction extends Function { @Override public JSValue call(JSObject context, Vector<JSValue> args, boolean as_constr) { if (args.size() > 0 && args.get(0).getType().equals("Function")) { return ((Promise)context)._catch((Function)args.get(0)); } return context; } } private PromiseProto() { items.put("then", new thenFunction()); items.put("catch", new catchFunction()); } public static PromiseProto getInstance() { if (instance == null) { instance = new PromiseProto(); } return instance; } @Override public void set(JSString str, JSValue value) { set(str.getValue(), value); } @Override public void set(String str, JSValue value) { if (str.equals("constructor")) { super.set(str, value); } } @Override public String toString() { String result = ""; Set keys = items.keySet(); Iterator it = keys.iterator(); while (it.hasNext()) { if (result.length() > 0) result += ", "; String str = (String)it.next(); result += str + ": " + items.get(str).toString(); } return "{" + result + "}"; } @Override public String getType() { return type; } private String type = "Object"; private static PromiseProto instance = null; }
Не забудем добавить в конструктор Promise одну строчку в самом начале, чтобы всё работало:
public Promise(Function f) { items.put("__proto__", PromiseProto.getInstance()); ... }
И поменяем немного наш тест:
JSParser jp = new JSParser("function cbk1(str) { \"Promise 1 fulfilled: \" + str; return str } " + "function cbk2(str) { setTimeout(str => { \"Promise 2 fulfilled: \" + str }, 1000); throw \"ERROR\" } " + "function cbk3(str) { \"Promise 3 fulfilled: \" + str; return str } " + "function err(str) { \"An error has occured: \" + str } " + "function f(resolve, reject) { setTimeout(function() { resolve(\"OK\") }, 300) }; " + "(new Promise(f)).then(cbk1).then(cbk2).then(cbk3, err)"); System.out.println(); System.out.println("function cbk1(str) { \"Promise 1 fulfilled: \" + str; return str }"); System.out.println("function cbk2(str) { setTimeout(str => { \"Promise 2 fulfilled: \" + str }, 1000); throw \"ERROR\" }"); System.out.println("function cbk3(str) { \"Promise 3 fulfilled: \" + str; return str }"); System.out.println("function err(str) { \"An error has occured: \" + str }"); System.out.println("function f(resolve, reject) { setTimeout(function() { resolve(\"OK\") }, 300) }"); System.out.println("(new Promise(f)).then(cbk1).then(cbk2).then(cbk3, err)"); System.out.println(); Expression exp = Expression.create(jp.getHead()); ((jsparser.Function)Expression.getVar("f", exp)).setSilent(true); ((jsparser.Function)Expression.getVar("cbk2", exp)).setSilent(true); exp.eval(); jsparser.Function f = (jsparser.Function)Expression.getVar("f", exp); f.setSilent(true);
Вывод не должен измениться.
На этом всё! Всё отлично работает, можно писать дополнительные юнит-тесты и искать возможные ошибки.
Как приспособить этот механизм для Java? Очень просто. Создаём класс, аналогичный нашему Function, который что-то делает в методе operate. И оборачиваем уже его в нашу обёртку. В любом случае, на этот счёт есть много замечательных паттернов, с которыми можно поиграться.
Надеюсь, данная статья была кому-то полезна. Исходники движка я обязательно выложу, как только доведу их до ума и добавлю недостающий функционал. Удачного дня!