AI От регулярок к ANTLR4: три архитектурных решения на парсере бизнес-формул

AI

Команда форума
Редактор
Регистрация
23 Авг 2023
Сообщения
4,163
Реакции
0
Баллы
36
Ofline
Одна из самых запомнившихся задач за три года коммерческой разработки — парсер бизнес-формул в аналитической системе. Выражения приходили строками из пользовательского ввода: арифметика, сравнения, логические связки, сорок с лишним функций, ссылки на поля модели данных, переменные, литералы дат. На выходе нужен был фрагмент SQL для колоночной БД плюс валидация типов.

Через эту задачу я плотно познакомился с ANTLR4: прошёл путь от пустого .g4 до продакшен-парсера, а дальше несколько лет курировал этот блок кода в команде.

Сегодня, оглядываясь на тот код, я вижу три центральные темы, которые сделал бы иначе. Расскажу про каждую и покажу, как это выглядит в коде. Плюс три небольшие заметки в конце — про что стоит подумать, когда вы только начинаете свой парсер.

Почему регулярки не подошли​


На первых прикидках казалось: ну выражения, ну вложенные скобки, разберём регулярками. Первый же пример выбил эту идею:

Код:
@avg_score = (TOTAL([goals]) + 5) / [matches_played];
WHEN(
    SCOPE(TOTAL([goals]), [tournament] = 'Champions', [date] >= #01.07.2024#) > @avg_score,
    ROUND(DATESHIFT(MONTH, 1, #01.09.2024#), 0),
    @avg_score * 0.85
)

Здесь [goals] и [tournament] — ссылки на поля модели данных, @avg_score — пользовательская переменная, #01.07.2024#— литерал даты в формате dd.mm.yyyy.

Тут вложенные скобки нескольких уровней, вызовы функций с произвольным числом аргументов, приоритет операторов, контекстная зависимость (внутри WHEN первым аргументом идёт сравнение, а дальше — выражения), литералы дат с собственным синтаксисом, объявление переменной с последующей подстановкой. Регулярка не умеет рекурсию — скобки парой она не поймает в общем виде. Можно было бы попытаться свести задачу к конечному набору шаблонов, но каждый новый оператор или функция ломал бы уже написанное.

Нужен был настоящий парсер с деревом разбора. С этого начался ANTLR4.

Как ANTLR4 работает — базовый словарь для статьи​


Чтобы дальше понимать разговор, нужно договориться о четырёх терминах.

Грамматика — файл .g4, в котором описывается синтаксис языка. ANTLR4 по нему генерирует лексер и парсер на целевом языке (у меня — Java).

Лексер превращает поток символов в поток токенов. Токен — это атомарная единица: число, идентификатор, оператор, ключевое слово.

Парсер собирает токены в дерево разбора (parse tree) по правилам грамматики.

Обходчики дерева — Listener или Visitor. ANTLR4 генерирует для них интерфейсы автоматически, вы реализуете методы под конкретные узлы.

Синтаксис грамматики выглядит так:

Код:
grammar Filter;

// Корневое правило: программа состоит из объявлений переменных и выражения
program
    : varDeclaration* expression EOF
    ;

// Объявление переменной: @name = expr ;
varDeclaration
    : VARIABLE '=' expression ';'
    ;

// Правила парсера — с маленькой буквы
expression
    : expression op=('*' | '/') expression                              # MulDiv
    | expression op=('+' | '-') expression                              # AddSub
    | expression op=('=' | '!=' | '<' | '>' | '<=' | '>=') expression   # Compare
    | '(' expression ')'                                                # Parens
    | functionCall                                                      # Function
    | VARIABLE                                                          # VarRef
    | IDENTIFIER                                                        # FieldRef
    | NUMBER                                                            # Number
    | STRING                                                            # StringLit
    | DATE_LITERAL                                                      # DateLit
    ;

functionCall
    : NAME '(' (expression (',' expression)*)? ')'
    ;

// Лексерные правила — с большой буквы
NAME         : [A-Z][A-Z_0-9]* ;                           // имена функций большими буквами
VARIABLE     : '@' [a-zA-Z_][a-zA-Z_0-9]* ;                // @name
IDENTIFIER   : '[' ~[\]\r\n]+ ']' ;                        // [field_name]
NUMBER       : [0-9]+ ('.' [0-9]+)? ;
STRING       : '\'' ~['\r\n]* '\'' ;
DATE_LITERAL : '#' [0-9][0-9] '.' [0-9][0-9] '.' [0-9][0-9][0-9][0-9] '#' ;
WS           : [ \t\r\n]+ -> skip ;

Несколько важных деталей, которые вам понадобятся:


  • Правила парсера именуются со строчной буквы (expression, functionCall),
    лексерные токены — с заглавной (IDENTIFIER, NUMBER, VARIABLE).


  • | разделяет альтернативы в одном правиле.


  • # MulDiv, # AddSub — это метки альтернатив. ANTLR сгенерирует для каждой отдельный метод в Listener/Visitor, что удобнее, чем один огромный метод с разбором через if.


  • -> skip в правиле WS говорит: «этот токен не включать в поток, это пробелы».


  • op=('*' | '/') — это именованная ссылка на токен. В обработчике можно обратиться как ctx.op.getType() и узнать, какой именно оператор распознался.


  • ~[\]\r\n]+ в правиле IDENTIFIER означает «любой символ, кроме закрывающей скобки и переноса строки». ~[...]— это инверсия, сильное средство в лексерных правилах.

Это минимум, которого хватит для остального текста. Теперь переходим к центральным решениям.

Дерево разбора одного фрагмента​


Чтобы видеть, что ANTLR4 на самом деле строит из формулы, разберём один фрагмент примера из лида — контекстную функцию с фильтрами:

SCOPE(TOTAL([goals]), [tournament] = 'Champions', [date] >= #01.07.2024#)

Дерево разбора этого фрагмента по нашей грамматике выглядит так:

Код:
Function
└── functionCall
    ├── NAME: SCOPE
    └── arguments:
        ├── arg 0: Function TOTAL([goals])
        │   └── functionCall
        │       ├── NAME: TOTAL
        │       └── arguments:
        │           └── FieldRef: [goals]
        ├── arg 1: Compare ([tournament] = 'Champions')
        │   ├── left:  FieldRef: [tournament]
        │   ├── op:    '='
        │   └── right: StringLit: 'Champions'
        └── arg 2: Compare ([date] >= #01.07.2024#)
            ├── left:  FieldRef: [date]
            ├── op:    '>='
            └── right: DateLit: #01.07.2024#

Корень — узел Function (метка альтернативы из правила expression), внутри него functionCall с тремя аргументами. Каждый аргумент — снова expression, потому что в грамматике аргументы функции описаны как (expression (',' expression)*)?. Поэтому в дереве естественно появляется вложенность: функция внутри функции, сравнение с литералом даты внутри аргумента.

Что важно увидеть на этом примере:


  • Каждое сравнение = или >= создаёт узел Compare с тремя слотами — left, op, right. Это работа альтернативы expression op=(...) expression # Compare из грамматики.


  • Литералы ('Champions', #01.07.2024#) — листы дерева. Их разбирает лексер, парсер уже работает с готовыми токенами.


  • Полное дерево всей формулы из лида занимает ~30 строк — оно построено по тем же принципам, просто крупнее. Здесь мы посмотрели на одну ветку, чтобы видеть форму.

Дерево — это вход для всех обходчиков, которые мы дальше обсудим. Visitor пойдёт по нему сверху вниз, возвращая значения; Listener пройдёт автоматически, дёргая enter и exit для каждого узла.

Решение 1. Приоритет операторов — через левую рекурсию, а не через Listener​


Когда вы первый раз пишете грамматику для выражений, есть два очевидных искушения:

Искушение А. Написать плоское правило «операнд оператор операнд оператор...» без приоритетов, а приоритет воспроизвести в коде обработчика — собирать * и / первыми, потом + и -.

Искушение Б. Вспомнить классику и расписать слоистую пирамиду: addExpr → mulExpr → atom, где каждый слой — это выражение соответствующего приоритета.

Первое — антипаттерн: грамматика перестаёт отражать семантику. Парсер вернёт дерево без приоритетов, и правильную структуру придётся достраивать руками в обработчике. Любой другой обход этого дерева получит «неправильные» приоритеты — вы теперь обязаны помнить про свой костыль везде.

Второе — корректно, но громоздко. Для DSL с четырьмя уровнями приоритета это пять-шесть правил вместо одного.

Правильный способ в ANTLR4 — левая рекурсия с упорядоченными альтернативами. ANTLR4 умеет прямую левую рекурсию и автоматически разруливает приоритет: чем выше альтернатива в списке, тем выше её приоритет.

Возьмём фрагмент правила expression из нашей грамматики и посмотрим только на операторные альтернативы:

Код:
expression
    : expression op=('*' | '/') expression   # MulDiv     <- высший приоритет
    | expression op=('+' | '-') expression   # AddSub
    | expression op=('=' | '!=' | '<' | '>' | '<=' | '>=') expression   # Compare
    | ...                                                  // остальные альтернативы
    ;

Парсер автоматически свяжет * и / сильнее, чем + и -, а + и - — сильнее, чем сравнения. Достаточно правильно расставить альтернативы по убыванию приоритета.

Для входа 2 + 3 *[I] 4[/I] парсер построит дерево, где [I]3 *[/I] 4 — внутренний узел, а 2 + (3 * 4) — внешний. Без единой строчки кода.

Если где-то нужна правоассоциативность (например, у возведения в степень или тернарного оператора), это делается тэгом:

Код:
expression
    : expression '^' expression                                # Power
    | <assoc=right> expression '?' expression ':' expression   # Ternary
    ;

Внутри Visitor это выглядит максимально скучно — как и должно быть:

Код:
@Override
public BigDecimal visitMulDiv(FilterParser.MulDivContext ctx) {
    BigDecimal left = visit(ctx.expression(0));
    BigDecimal right = visit(ctx.expression(1));
    return switch (ctx.op.getText()) {
        case "*" -> left.multiply(right);
        case "/" -> left.divide(right, 10, RoundingMode.HALF_UP);
        default -> throw new IllegalStateException("Unknown op: " + ctx.op.getText());
    };
}

Никакого ручного склеивания. Структура дерева — это и есть семантика, один в один.

Решение 2. Listener vs Visitor — выбирайте по природе задачи​


ANTLR4 даёт два паттерна обхода дерева, и выбор между ними — это не вопрос вкуса, а вопрос того, что вы делаете.

Коротко про механику, чтобы дальше говорить на одном языке.

Listener
запускается по готовому дереву через ParseTreeWalker.DEFAULT.walk(listener, tree), либо регистрируется прямо в парсере через parser.addParseListener(listener) и работает по ходу парсинга. В обоих режимах listener-ов можно повесить сколько угодно — парсер хранит их списком и на каждый узел дёргает по очереди. Методы enter<Rule>() и exit<Rule>() не возвращают значений: всё, что нужно накопить, живёт во внешнем состоянии (поле класса, стек, ParseTreeProperty).

Visitor парсеру не передаётся. Вы строите дерево, а потом вручную вызываете visitor.visit(tree). Управление рекурсией — на вас: вызываете visit(ctx.child()) там, где хотите спуститься. Каждый метод возвращает значение, и результат узла собирается из результатов детей снизу вверх. Никто не мешает запустить на одном дереве два разных Visitor-а, но на практике обычно нужен один — если от дерева нужен один составной результат, Visitor возвращает record сразу со всеми полями, а не делится на несколько обходчиков.

Простое правило выбора:


  • Нужен возвращаемый результат? — Visitor. Вычисление выражения, построение объекта, трансляция в другой AST — это всё Visitor.


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

В моём коммерческом проекте было чёткое разделение ответственностей: один Visitor + три Listener-а, каждый из них отвечает за свою задачу:


  • Visitor — семантическая валидация и типизация. Для каждого подвыражения возвращает его тип, проверяет совместимость типов операндов, проверяет сигнатуры функций, собирает флаги (есть ли в поддереве агрегат, есть ли условная функция).


  • Listener №1 — транслятор в SQL. Использует ParseTreeProperty<String> для хранения «уже сконвертированного фрагмента» на каждом узле, в exit-методах склеивает дочерние куски.


  • Listener №2 — собирает колонки, попавшие под агрегат, для построения GROUP BY.


  • Listener №3 — строит шаблон выражения с плейсхолдерами для отдельного сценария.

Разделение задач между четырьмя обходчиками было осознанным применением Single Responsibility: каждый класс делает одну вещь, тесты на них независимые, поддерживать их можно параллельно. Это сработало — эти четыре класса спокойно жили и развивались параллельно в команде.

Но есть нюанс, про который я тогда не задумывался. Каждый из этих обходчиков делает свой проход по одному и тому же дереву. Четыре обхода. Для коротких выражений это незаметно. Для больших — это четырёхкратный объём работы, и часть логики между обходчиками неизбежно дублируется (например, и Visitor, и один из Listener-ов независимо определяют, есть ли в поддереве агрегатная функция).

Сегодня я бы собрал это одним Visitor-ом, возвращающим record FormulaResult(String sql, Type type, Set<String> columns, Flags flags, ...). Один обход, вся информация собрана снизу вверх, состояние «мы внутри агрегата» прокидывается параметром рекурсии, а не флагом во внешнем поле. Разделение ответственностей при этом сохраняется — просто переезжает из четырёх классов в четыре метода внутри одного.

Но если у вас нет проблем с производительностью и есть выигрыш в читаемости — разделять на Listener-ы тоже валидно. Главное — не смешивать Listener и Visitor «наугад». Два активных обходчика на одном дереве в одной задаче — это сигнал, что границы ответственности размыты.

Решение 3. Храните промежуточные результаты на узлах, а не в памяти пересчёта​


Когда Visitor или Listener обрабатывает один узел, ему часто нужны результаты обработки детей — их типы, их транслированный SQL, их флаги. Есть три способа это сделать.

Способ первый, плохой. Взять ctx.getText() от поддерева и запустить парсинг заново в другом контексте. Соблазн большой: вроде же можно. У меня в коде это было, для нескольких узлов — объявления переменных и тело одной специальной функции парсились заново с другим набором параметров.

Что с этим не так:


  1. getText() склеивает токены без пробелов. sum( [price] , [quantity] ) после getText() становится sum([price],[quantity]). Если в вашем языке есть конструкции, чувствительные к исходному форматированию — теряете их.


  2. Локации ошибок нового парсинга относятся к подстроке, а не к исходной формуле. Пользователь видит «ошибка на позиции 5», но в исходной формуле это позиция, например, 47. Отладка становится болью.


  3. Лексер запускается повторно для того же текста. ANTLR внутри кеширует DFA, но сам факт повторной токенизации — лишняя работа.


  4. Все остальные Listener-ы и Visitor-ы вы тоже вызываете заново для поддерева. Если их четыре, это четыре обхода уже обойдённого.

Способ второй, ANTLR-ский. Использовать ParseTreeProperty<T> — это мапа «узел → произвольное значение», которую ANTLR даёт специально для аннотации дерева:

Код:
public class SqlTranslator extends FilterBaseListener {
    private final ParseTreeProperty<String> sqlFragments = new ParseTreeProperty<>();

    @Override
    public void exitMulDiv(FilterParser.MulDivContext ctx) {
        String left = sqlFragments.get(ctx.expression(0));
        String right = sqlFragments.get(ctx.expression(1));
        String op = ctx.op.getText();
        sqlFragments.put(ctx, "(" + left + " " + op + " " + right + ")");
    }

    public String getResult(ParseTree root) {
        return sqlFragments.get(root);
    }
}

Выходим из узла — кладём результат на узел. Выходим из родителя — читаем результаты детей с их узлов. Это работает одним проходом и не требует ни повторного парсинга, ни внешнего стека с ручным управлением.

Способ третий, для Visitor-а. Просто возвращать значение из метода. Это даже проще, чем ParseTreeProperty:

Код:
@Override
public String visitMulDiv(FilterParser.MulDivContext ctx) {
    String left = visit(ctx.expression(0));
    String right = visit(ctx.expression(1));
    return "(" + left + " " + ctx.op.getText() + " " + right + ")";
}

Когда нужно пройти то же самое поддерево ещё раз с другим контекстом (у меня был этот сценарий для переменных и специальной функции) — не вызывайте парсер заново. Вызовите новый Visitor на том же узле:

Код:
String resultInContextA = new ContextAVisitor().visit(ctx.expression());
String resultInContextB = new ContextBVisitor().visit(ctx.expression());

Одно дерево — несколько обходчиков с разными задачами. Парсинг выполнен один раз, локации токенов сохранены, производительность в порядке.

Два файла грамматики: когда одна .g4 становится слишком большой​


ANTLR4 поддерживает два режима грамматики:


  1. Combined grammar — один файл, где лексер и парсер описаны вместе. Подходит для небольших DSL до пары сотен строк.


  2. Split grammar — отдельно FooLexer.g4 (только лексерные правила) и FooParser.g4 (только правила парсера, импортирует лексер через options { tokenVocab = FooLexer; }). Для больших грамматик это стандартный приём.

У меня грамматика разрослась до объёма, при котором combined становилась неудобной — больше пятисот строк, сорок лексем, два языка ключевых слов (английский + локализованный), несколько десятков правил парсера. Я разделил на два файла сразу, как только стало ясно, что это не hello world на сто строк.

Внутри лексерного файла порядок правил важен: лексер матчит первое подходящее правило в порядке их объявления. Если у вас есть ключевые слова (AND, OR, NULL) и идентификаторы ([a-zA-Z_][a-zA-Z_0-9]*) — ключевые слова должны идти раньше идентификаторов, иначе любое ключевое слово попадёт под идентификатор.

Код:
lexer grammar FilterLexer;

// Сначала ключевые слова
AND : 'and' ;
OR  : 'or' ;
NOT : 'not' ;
NULL : 'null' ;

// Потом идентификаторы
IDENTIFIER : [a-zA-Z_][a-zA-Z_0-9]* ;

// Потом литералы и пробелы
NUMBER : [0-9]+ ('.' [0-9]+)? ;
STRING : '\'' (~['\r\n])* '\'' ;
WS     : [ \t\r\n]+ -> skip ;

Для больших грамматик также полезны fragment-правила — вспомогательные куски, которые не генерируют токенов сами по себе, но используются в других лексерных правилах:

Код:
fragment DIGIT : [0-9] ;
fragment LETTER : [a-zA-Z_] ;

NUMBER : DIGIT+ ('.' DIGIT+)? ;
IDENTIFIER : LETTER (LETTER | DIGIT)* ;

Это чистит грамматику и упрощает правки.

Ещё три вещи, о которых стоит подумать сразу​


Трансляция — строка или целевое AST?

У меня Listener-транслятор собирал выходной SQL как строку через ParseTreeProperty<String>. Это работает, но каждый раз, когда нужно добавить новый трюк к выходному SQL (обернуть деление в decimal-функцию, добавить isNull(...) OR ... к каким-то условиям), приходится править склейку строки по месту. Для сложного выходного языка лучше строить целевой AST (свой или библиотечный — jOOQ, Calcite), и сериализовать его в SQL одним проходом. Тогда изменения выходного формата делаются в одном месте, а не в трёх exit-методах.

Типизация — не все узлы обязаны иметь тип.

В моей системе типов формулы было шесть значений: NUMBER, STRING, DATE, BOOLEAN, плюс два синтетических — KEYWORD (для аргументов-констант вроде единицы времени в функции «прибавить к дате») и MODIFIER_FUNCTION (для специальной конструкции внутри одной функции). Синтетические типы сделали систему единообразной, но это лишние сущности.

Правильнее признать: бывают узлы, у которых нет типа данных, потому что они не представляют значения. Такие узлы просто не участвуют в типовой проверке, а не получают «ненастоящий» тип для единообразия.

Возвращаемый объект парсера — разрезайте по сценариям.

У меня публичный API парсера был прост: один метод parse(String formula, ParserContext context) возвращающий FormulaData с пятнадцатью полями. Каждый вызывающий код брал из объекта свои 2-3 поля. Это удобно на старте, но превращается в бога-объект со временем.

Лучше сразу разделить возвращаемые типы по сценариям: ValidationResult для валидатора, TranslationResult для транслятора, MetadataResult для сервиса, которому нужна только структурная информация. Да, внутри парсер всё равно считает всё за один обход — но наружу отдаёт ровно то, что запрошено.

Итог​


ANTLR4 — зрелый инструмент с продуманной архитектурой. Большая часть решений в нём уже правильная — нужно просто их знать:


  • Левая рекурсия с порядком альтернатив для выражений с приоритетом — вместо слоистых пирамид или ручной приоритезации в обработчике.


  • Один Visitor или один Listener с [B]ParseTreeProperty[/B] — вместо смеси нескольких активных обходчиков на одном дереве.


  • Новый обходчик того же узла — вместо повторного парсинга через getText().

Три центральных приёма, плюс привычки быстро уйти на split grammar при росте объёма, строить целевое AST вместо склейки строк и разделять возвращаемые типы по сценариям — и ваш парсер будет жить долго и не будет болеть, когда добавятся новые операторы, функции и типы.

Версии, которые я использовал: ANTLR4 4.13.2, Java 21, Maven plugin antlr4-maven-plugin.

Что дальше​


Это вторая статья в серии материалов из моих проектов. Первая была про Spec-Driven Development на примере Telegram-бота — про то, как я теперь работаю с AI-ассистентами и что это сделало с моим инженерным процессом.

В следующей статье планирую разобрать:


  • FullStack web-приложение LifeSync (B2C-трекер привычек с гексагональной архитектурой, Kafka и jOOQ вместо JPA, React 19 + TypeScript, 251 тест).
 
Назад
Сверху Снизу
Яндекс.Метрика Рейтинг@Mail.ru