javascript

Реализуем промисы на Java

  • вторник, 6 марта 2018 г. в 03:18:00
https://habrahabr.ru/post/350444/
  • JavaScript
  • Java


Всем доброго времени суток. Сегодня я хочу рассказать о том, как писал реализацию механизма промисов для своего 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. И оборачиваем уже его в нашу обёртку. В любом случае, на этот счёт есть много замечательных паттернов, с которыми можно поиграться.

Надеюсь, данная статья была кому-то полезна. Исходники движка я обязательно выложу, как только доведу их до ума и добавлю недостающий функционал. Удачного дня!