Интерпретатор формул (DSL)

опубликовано: ⏰ 19

Довольно часто в проектах требуется возможность настройки каких-либо формул или скриптов, доступных для пользователя. Основная идея — это изменение поведения частей приложения без необходимости прохождения всего цикла разработки с поставкой нового релиза. Возможны разные варианты, как это сделать, об этом будет рассказано далее.

Вводная

Сделаю небольшое отступление для понимания, о чём вообще речь, и почему именно ANTLR4. Давным-давно, в далеком 2005–2006 году увлекался разработкой различных скриптовых языков и DSL. Книги “red/green dragon book” (Principles of Compiler Design) тогда не получилось найти, но была небольшая подборка из других источников и примеры. К сожалению, инструменты для генерации наподобие ANTLR прошли мимо меня, и все парсеры, лексические и семантические анализаторы писал вручную, повторно перенося из проекта в проект. С одной стороны это была довольно муторная работа, но казалось, что финальная скорость работы того стоила ( универсальные решения могли быть медленнее).

Часто основная потребность была в легковесном скриптовом движке или DSL под требования проекта (форматы описания данных, простая логика и прочее). С первым как-то не сложилось найти чего-то подходящего, но зато был довольно интересный опыт разработки компилятора с интерпретатором. Делал простые стековые VM (Delphi / C++) с байткодом и вызовом нативных функций (наподобие JNA) для встраивания в нативные приложения.

А в случае с DSL я всегда писал собственные решения, так как готового и не могло быть. Вот тут всегда хотелось попробовать готовые инструменты, прошло пара десятков лет, и их время настало :).

Задача

Сразу скажу, что пример очень простой и это побочная активность в рамках разработки tekBlueprint.plans. Для расчёта затрат на выполнение задач будут использоваться формулы, которые могут быть заданы пользователем. Основные требования к формулам:

  1. Необходима высокая скорость работы. Учитывая, что расчёт производится фактически для ячеек матрицы, важно минимизировать время расчёта. Количество ячеек может достигать десятков тысяч и пересчитываться они должны без задержек.
  2. Необходимо вычисление формул и возможность их расширения. Формулы могут содержать различные операторы, функции и пользовательские переменные.
  3. Обеспечить изоляцию кода. В будущем код должен выполняться на сервере приложений (SaaS), что означает определённые ограничения для формул по доступу к окружению исполнения.

Здесь возможны несколько вариантов реализации:

  1. Использование скриптовых языков – JavaScript (Nashorn, Rhino Engine), Groovy и прочих. Здесь не выполняются требования 1 и 3.
  2. Использование шаблонизаторов – Freemarker, MVEL, JStachio и т. д. Здесь не выполняется требование 1 и 2 (например, не все шаблонизаторы позволяют выполнять арифметические вычисления).
  3. Сделать самому, так как уже понятно, что присутствуют “фатальные недостатки” в предыдущих вариантах :) Это позволит полностью контролировать синтаксис формулы и обеспечить выполнение всех требований.

Далее будет реализация именно третьего варианта, то есть создание собственного парсера формул на языке Java. Для этого будем использовать грамматический анализатор ANTLR4.

Подключение ANTLR4 к проекту

ANTLR4 генерирует необходимые классы на основе заранее заданных грамматик. В проекте используется Maven, поэтому это можно сделать с использованием плагина (для Gradle существует аналогичный).

Подключение плагина
XML

<build>
    <plugins>
        <plugin>
            <groupId>org.antlr</groupId>
            <artifactId>antlr4-maven-plugin</artifactId>
            <version>4.13.2</version>
            <executions>
                <execution>
                    <id>antlr</id>
                    <goals>
                        <goal>antlr4</goal>
                    </goals>
                </execution>
            </executions>
            <configuration>
                <visitor>true</visitor>
            </configuration>
        </plugin>
    </plugins>
</build>

Сам файл с грамматиками должен находиться в директории src/main/antlr4. В нашем случае файл будет в каталоге src/main/antlr4/com/company/antlr4/EffortFormulaExpr.g4, финальный пакет для классов будет com.company.antlr4.

Грамматика и генерация парсера

Как и говорил ранее, формула довольна простая – арифметические операции, пользовательские переменные и функции. Поэтому грамматика (EffortFormulaExpr.g4) будет следующей:

Antlrv4
grammar EffortFormulaExpr;

expression  : expression (MUL | DIV) expression
            | expression (PLUS | MINUS) expression
            | LPAREN expression RPAREN
            | func
            | (MINUS | PLUS)* atom
            ;

func        : VARIABLE LPAREN (expression (',' expression)*)? RPAREN
            ;

atom        : NUMBER
            | VARIABLE
            ;

NUMBER      : '0'..'9'+ ('.' '0'..'9'+)?
            ;
MUL         : '*'
            ;
DIV         : '/'
            ;
PLUS        : '+'
            ;
MINUS       : '-'
            ;
VARIABLE    : [a-zA-Z_][a-zA-Z0-9_]*
            ;
LPAREN      : '('
            ;
RPAREN      : ')'
            ;
WS          : [ \r\n\t]+ -> skip
            ;

Грамматика сделана на основе arithmetic.g4.

Здесь наивысший приоритет имеют операции умножения и деления, затем сложение и вычитание. Функции (func) могут принимать любое количество аргументов, разделенных запятыми. Переменные (VARIABLE) начинаются с буквы или подчеркивания и могут содержать буквы, цифры и подчеркивания.

После описания грамматики можно запустить Maven-команду для генерации классов: mvn antlr4:antlr4. В результате получим следующие классы, которые потребуются далее:

  • EffortFormulaExprLexer.java – лексический анализатор для разбора формулы на токены.
  • EffortFormulaExprParser.java – синтаксический анализатор для построения дерева разбора формулы (AST).
  • EffortFormulaExprBaseVisitor.java – базовый класс визитора, который можно использовать для обхода дерева разбора. Для создания этого класса необходимо включить в плагине <visitor>true</visitor>.

Abstract Syntax Tree

Теперь можно создать класс-визитор, который будет строить AST на основе формулы. Для упрощения совместил его с классом компилятора выражений и добавил метод compile, который принимает строку с формулой и возвращает корневой узел AST.

Java
/**
 * Compiler class responsible for parsing and compiling effort formula expressions.
 *
 * @author Sergey Grachev
 */
@AllArgsConstructor
public class Compiler extends EffortFormulaExprBaseVisitor<Node> {

    /**
     * Compiles the given expression string into an abstract syntax tree (AST) node.
     *
     * @param expression The effort formula expression to compile.
     * @return The root node of the compiled AST.
     */
    public static Node compile(final String expression) {
        final var tokens = new CommonTokenStream(new EffortFormulaExprLexer(CharStreams.fromString(expression)));
        final var parser = new EffortFormulaExprParser(tokens);
        return new Compiler().visit(parser.expression());
    }

    /**
     * Visits an expression context and constructs the corresponding AST node.
     *
     * @param ctx The expression context to visit.
     * @return The constructed AST node for the given expression.
     */
    @Override
    public Node visitExpression(final EffortFormulaExprParser.ExpressionContext ctx) {
        if (ctx.atom() != null) {
            if (!ctx.PLUS().isEmpty()) {
                return new UnaryPlusNode(visit(ctx.atom()));
            }
            if (!ctx.MINUS().isEmpty()) {
                return new UnaryMinusNode(visit(ctx.atom()));
            }
        }
        if (!ctx.PLUS().isEmpty()) {
            return new BinaryNode(
                    visit(ctx.expression(0)), BinaryNode.PLUS, visit(ctx.expression(1)));
        }
        if (!ctx.MINUS().isEmpty()) {
            return new BinaryNode(
                    visit(ctx.expression(0)), BinaryNode.MINUS, visit(ctx.expression(1)));
        }
        if (ctx.MUL() != null) {
            return new BinaryNode(
                    visit(ctx.expression(0)), BinaryNode.MULTIPLY, visit(ctx.expression(1)));
        }
        if (ctx.DIV() != null) {
            return new BinaryNode(
                    visit(ctx.expression(0)), BinaryNode.DIVIDE, visit(ctx.expression(1)));
        }
        if (ctx.LPAREN() != null) {
            return visit(ctx.expression(0));
        }
        return super.visitExpression(ctx);
    }

    /**
     * Visits an atom context and constructs the corresponding AST node.
     *
     * @param ctx The atom context to visit.
     * @return The constructed AST node for the given atom.
     */
    @Override
    public Node visitAtom(final EffortFormulaExprParser.AtomContext ctx) {
        if (ctx.NUMBER() != null) {
            return new NumberNode(Double.parseDouble(ctx.NUMBER().getText()));
        }
        if (ctx.VARIABLE() != null) {
            return new VariableNode(ctx.VARIABLE().getText());
        }
        return super.visitAtom(ctx);
    }

    /**
     * Visits a function context and constructs the corresponding AST node.
     *
     * @param ctx The function context to visit.
     * @return The constructed AST node for the given function.
     */
    @Override
    public Node visitFunc(final EffortFormulaExprParser.FuncContext ctx) {
        final var fnName = ctx.VARIABLE().getText();
        final var contexts = ctx.expression();
        final var args = new Node[contexts.size()];
        int index = 0;
        for (final var arg : contexts) {
            args[index++] = visit(arg);
        }
        return new FunctionNode(fnName, args);
    }
}

Каждый из методов создаёт новую вершину в бинарном дереве, где вершина это операция, а листья это аргументы операции. То есть для того, чтобы получить результат вычислений, необходимо просто выполнить рекурсивно все операции начиная от корня. Для упрощения все вычисления используют тип double.

Ниже узлы AST

Java
/**
 * Represents a node in an abstract syntax tree (AST) for formulas.
 *
 * <p>This interface extends the {@link Formula} interface, indicating that each node
 * in the AST can be evaluated as part of a formula.</p>
 *
 * @author Sergey Grachev
 */
public interface Node extends Formula {

}
Java
/**
 * Represents a node in the abstract syntax tree (AST) that holds a numeric value.
 *
 * @author Sergey Grachev
 */
@Value
public class NumberNode implements Node {

    /**
     * The numeric value stored in this node.
     */
    private final double value;

    /**
     * Evaluates the node, which simply returns the stored numeric value.
     *
     * @param context the formula evaluation context (not used for NumberNode)
     * @return the numeric value of this node
     */
    @Override
    public double evaluate(final FormulaContext context) {
        return value;
    }
}
Java
/**
 * Represents a variable node in an abstract syntax tree (AST) for formula evaluation.
 *
 * <p>This class is used to represent variables within a formula, which can be resolved
 * to their corresponding values using the provided {@link FormulaContext}.
 *
 * @author Sergey Grachev
 */
@Value
public final class VariableNode implements Node {

    /**
     * The name of the variable represented by this node.
     */
    private final String name;

    /**
     * Evaluates the variable node using the given formula context.
     *
     * <p>This method retrieves the value of the variable from the provided {@link FormulaContext}.
     *
     * @param context the formula context containing variable values
     * @return the evaluated value of the variable
     */
    @Override
    public double evaluate(final FormulaContext context) {
        return context.var(name);
    }
}
Java
/**
 * Represents a unary minus operation in an abstract syntax tree (AST).
 *
 * <p>This node contains a single child node, which represents the operand to be negated.
 * When evaluated, this node returns the negative of its child's value.</p>
 *
 * @author Sergey Grachev
 */
@Value
public class UnaryMinusNode implements Node {
    /**
     * The child node representing the operand to be negated.
     */
    private final Node child;

    /**
     * Evaluates the unary minus operation using the provided formula context.
     *
     * <p>This method evaluates the child node and returns its value negated.</p>
     *
     * @param context the formula context used for evaluation
     * @return the negative of the evaluated child node's value
     */
    @Override
    public double evaluate(final FormulaContext context) {
        return -child.evaluate(context);
    }
}
Java
/**
 * Represents a unary plus node in an abstract syntax tree (AST) for formula evaluation.
 * The unary plus operation does not change the value of its operand, but it can be used
 * to ensure that the operand is treated as a positive number.
 *
 * @author Sergey Grachev
 */
@Value
public class UnaryPlusNode implements Node {
    /**
     * The child node representing the operand to which the unary plus operation is applied.
     */
    private final Node child;

    /**
     * Evaluates the unary plus node by evaluating its child node and returning the result.
     * Since the unary plus does not change the value, this method simply returns the evaluated
     * value of the child node.
     *
     * @param context The formula context containing variables and their values.
     * @return The evaluated value of the child node.
     */
    @Override
    public double evaluate(final FormulaContext context) {
        return +child.evaluate(context);
    }
}
Java
/**
 * Represents a function node in the abstract syntax tree (AST) of a formula.
 *
 * <p>This class encapsulates a function call, including its name and arguments,
 * and provides functionality to evaluate the function within a given context.</p>
 *
 * @author Sergey Grachev
 */
@RequiredArgsConstructor
public class FunctionNode implements Node {
    /**
     * An empty array of doubles used when no values are needed.
     */
    private static final double[] EMPTY = new double[0];

    /**
     * The name of the function to be called.
     */
    private final String name;

    /**
     * The arguments passed to the function, represented as an array of {@link Node} objects.
     */
    private final Node[] arguments;

    /**
     * An array to store evaluated values of the arguments. This is initialized if there are any arguments.
     */
    private final @Nullable double[] values;

    /**
     * The {@link FormulaFunction} object representing the function to be called.
     * It is lazily initialized when first needed.
     */
    private @Nullable FormulaFunction fn = null;

    /**
     * Constructs a new FunctionNode with the specified name and arguments.
     *
     * <p>The values array is initialized if there are any arguments.</p>
     *
     * @param name      The name of the function to be called.
     * @param args      The arguments passed to the function, represented as an array of {@link Node} objects.
     */
    public FunctionNode(final String name, final Node... args) {
        this.name = name;
        this.arguments = args;
        this.values = arguments != null && arguments.length > 0 ? new double[args.length] : null;
    }

    /**
     * Evaluates the function within the given context.
     *
     * <p>If the function has not been resolved yet, it is looked up in the provided {@link FormulaContext}.
     * If the function is not found, an {@link IllegalStateException} is thrown.</p>
     *
     * <p>The arguments are evaluated and their values stored in the values array.
     * The function is then called with these values.</p>
     *
     * @param context   The {@link FormulaContext} within which to evaluate the function.
     * @return The result of the function call as a double.
     */
    @Override
    public double evaluate(final FormulaContext context) {
        if (fn == null) {
            fn = context.findFunction(name);
            if (fn == null) {
                throw new IllegalStateException("Unknown function: " + name);
            }
        }

        if (values == null) {
            return fn.call(EMPTY);
        }

        int index = 0;
        for (final var argument : arguments) {
            values[index++] = argument.evaluate(context);
        }

        return fn.call(values);
    }
}
Java
/**
 * Represents a binary operation node in an abstract syntax tree (AST) for formula evaluation.
 *
 * <p>This class encapsulates two child nodes and an operator that defines the binary operation
 * to be performed between the values of these nodes.</p>
 *
 * @author Sergey Grachev
 */
@Value
public class BinaryNode implements Node {

    /**
     * Constant representing the addition (+) operator.
     */
    public static final byte PLUS = 0;

    /**
     * Constant representing the subtraction (-) operator.
     */
    public static final byte MINUS = 1;

    /**
     * Constant representing the multiplication (*) operator.
     */
    public static final byte MULTIPLY = 2;

    /**
     * Constant representing the division (/) operator.
     */
    public static final byte DIVIDE = 3;

    /**
     * The left operand node in the binary operation.
     */
    private final Node left;

    /**
     * The right operand node in the binary operation.
     */
    private final Node right;

    /**
     * The operator defining the binary operation to be performed between the left and right nodes.
     */
    private final byte operator;

    /**
     * Constructs a new BinaryNode with the specified left and right child nodes and operator.
     *
     * @param left     the left operand node
     * @param operator the operator defining the binary operation
     * @param right    the right operand node
     */
    public BinaryNode(final Node left, final byte operator, final Node right) {
        this.left = left;
        this.right = right;
        this.operator = operator;
    }

    /**
     * Evaluates the binary operation represented by this node using the provided formula context.
     *
     * @param context the formula context containing variable values and other evaluation data
     * @return the result of the binary operation as a double value
     * @throws RuntimeException if an unknown operator is encountered
     */
    @Override
    public double evaluate(final FormulaContext context) {
        final var lVal = left.evaluate(context);
        final var rVal = right.evaluate(context);

        return switch (operator) {
            case PLUS -> lVal + rVal;
            case MINUS -> lVal - rVal;
            case MULTIPLY -> lVal * rVal;
            case DIVIDE -> lVal / rVal;
            default -> throw new RuntimeException("Unknown operator: " + operator);
        };
    }
}

Здесь:

  • Node – это абстрактный класс, представляющий узел в дереве выражения.
  • NumberNode – числовая константа.
  • VariableNode – переменная, значение которой будет получено из контекста при вычислении формулы.
  • UnaryMinusNode, UnaryPlusNode – изменение знака выражения. UnaryPlusNode бесполезен на самом деле, можно избавиться для оптимизации.
  • BinaryNode – бинарная операция (например, сложение или умножение) выполняемая над другими узлами дерева.
  • FunctionNode – вызов функции с аргументами. Функция описывается интерфейсом FormulaFunction, который принимает массив значений и возвращает результат.

Дополнительно необходимо создать классы расширения.

Java
/**
 * Represents a context for evaluating formulas, containing variables and functions.
 *
 * @author Sergey Grachev
 */
@Getter
public final class FormulaContext {

    /**
     * The default value to return when a variable is not found in the context.
     */
    private static final Double DEFAULT_VALUE = (double) 0;

    /**
     * The current value of the formula context.
     */
    private double value;

    /**
     * A map containing variables and their corresponding values.
     */
    private final Map<String, Double> variables = new HashMap<>();

    /**
     * A map containing functions and their corresponding implementations.
     */
    private final Map<String, FormulaFunction> functions = new HashMap<>();

    /**
     * Constructs a new FormulaContext with the default value.
     */
    public FormulaContext() {
        this.value = DEFAULT_VALUE;
    }

    /**
     * Constructs a new FormulaContext with the specified initial value.
     *
     * @param value The initial value for the formula context.
     */
    public FormulaContext(final double value) {
        this.value = value;
    }

    /**
     * Adds a variable to the context with the given name and value.
     *
     * @param name  The name of the variable.
     * @param value The value of the variable.
     * @return This instance of FormulaContext for method chaining.
     */
    public FormulaContext addVariable(final String name, final double value) {
        variables.put(name, value);
        return this;
    }

    /**
     * Adds a function to the context with the given name and implementation.
     *
     * @param name The name of the function.
     * @param func The implementation of the function.
     * @return This instance of FormulaContext for method chaining.
     */
    public FormulaContext addFunction(final String name, final FormulaFunction func) {
        functions.put(name, func);
        return this;
    }

    /**
     * Calls a function in the context with the given name and arguments.
     *
     * @param name The name of the function to call.
     * @param args The arguments to pass to the function.
     * @return The result of the function call.
     * @throws IllegalStateException If the function is not found in the context.
     */
    public double call(final String name, final double... args) {
        final var fn = functions.get(name);
        if (fn == null) {
            throw new IllegalStateException("Unknown function: " + name);
        }
        return fn.call(args);
    }

    /**
     * Retrieves the value of a variable from the context.
     *
     * @param name The name of the variable to retrieve.
     * @return The value of the variable, or the default value if the variable is not found.
     */
    public double var(final String name) {
        final var v = variables.get(name);
        return v == null ? DEFAULT_VALUE : v;
    }

    /**
     * Finds a function in the context by its name.
     *
     * @param name The name of the function to find.
     * @return The function if found, or null if not found.
     */
    @Nullable
    public FormulaFunction findFunction(final String name) {
        return functions.get(name);
    }
}
Java
/**
 * Represents a functional interface for mathematical formulas that can be applied to an array of double values.
 *
 * <p>This interface is designed to be used with lambda expressions or method references, allowing
 * for the definition of custom formula functions that take an array of double arguments and return a double result.</p>
 *
 * @author Sergey Grachev
 */
@FunctionalInterface
public interface FormulaFunction {

    /**
     * Applies this function to the given array of double values.
     *
     * @param args an array of double values representing the input parameters for the formula
     * @return the result of applying this function to the input arguments as a double value
     */
    double call(double[] args);
}

Здесь:

  • FormulaFunction – интерфейс для функций в формулах.
  • FormulaContext – контекст, который хранит переменные и функции.

Проверка вычисления формулы

Всё готово и можно тестировать, в результате выполнения теста должно получится 52.0

Groovy
class CompilerTest extends Specification {

    def 'compile expression'() {
        given:
        var context = new FormulaContext(5)
        context.addFunction('FN1', args -> 5 * args[0]);
        context.addFunction('FN2', args -> 3 * args[0]);
        context.addVariable('VA1', 4);
        context.addVariable('VA2', 3);

        when:
        var expr = Compiler.compile('-1 + 2 * +3 + 5 + (VA1 * VA2) + FN1(FN2(2))')

        then:
        expr != null;
        expr.evaluate(context) == -1 + 2 * +3 + 5 + (4 * 3) + (5 * (3 * 2))
    }
}

Сравнение скорости выполнения

И в конце небольшое сравнение скорости вычисления используя различные подходы. Ниже видно, что Groovy оказался самым медленным, что в целом ожидаемо. Шаблоны MVEL/Freemarker немного получше, так как более легковесные по сравнению с полноценным скриптовым движком. Наш интерпретатор AST в 10 раз быстрее, но значительно отстаёт от нативного кода на Java.

Text
Benchmark                            Mode  Cnt       Score   Error  Units
FormulaBenchmark.benchGroovy        thrpt         2983.378          ops/s
FormulaBenchmark.benchMvelTemplate  thrpt        11770.380          ops/s
FormulaBenchmark.benchFreemarker    thrpt        12311.651          ops/s
FormulaBenchmark.benchPlan          thrpt       147752.201          ops/s
FormulaBenchmark.benchJava          thrpt       725310.418          ops/s

В дальнейшем скорость выполнения можно приблизить к нативной Java, используя определённые оптимизации:

  1. Убрать лишние ноды – UnaryPlusNode.
  2. Вычислять заранее константные выражения (аналог constexpr в C++).
  3. Преобразовать AST в байткод используя ASM/Javassist, но это не всегда возможно для некоторых JVM (например, GraalVM).

Заключение

В данной статье показал простой пример создания интерпретатора выражений на Java с использованием ANTLR4: определение грамматики, генерация парсера, сборка AST и вычисление значения на его основе. В результате получился простой, но функциональный интерпретатор, который будет расширяться в будущем под потребности tekBlueprint.plans.

ℹ️ Примечание

Пример проекта доступен в GitHub.

Java Antlr Dsl Parser Formula Devmix