https://habrahabr.ru/post/350444/Всем доброго времени суток. Сегодня я хочу рассказать о том, как писал реализацию механизма промисов для своего JS движка. Как известно, не так давно вышел новый стандарт ECMA Script 6, и концепция промисов выглядит довольно интересно, а также уже очень много где применяется веб-разработчиками. Поэтому для любого современного JS движка это, безусловно, must-have вещь.
Внимание: в статье довольно много кода. Код не претендует на красоту и высокое качество, поскольку весь проект писался одним человеком и всё ещё находится в бете. Цель данного повествования — показать, как же всё работает под капотом. Кроме того, после небольшой адаптации данный код можно использовать для создания проектов чисто на 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. И оборачиваем уже его в нашу обёртку. В любом случае, на этот счёт есть много замечательных паттернов, с которыми можно поиграться.
Надеюсь, данная статья была кому-то полезна. Исходники движка я обязательно выложу, как только доведу их до ума и добавлю недостающий функционал. Удачного дня!