Кастомна мова запитів: розширення можливостей пошуку інформації

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

Привіт, мене звати Віктор, я Tech Lead у компанії MEV. У цій статті я розповім про створення гнучкої, швидкої та масштабованої системи пошуку на основі кастомної мови запитів. Завдяки такій мові ми в компанії не просто дали можливість користувачам переміщатися широким спектром варіантів з використанням понад 300 параметрів, але створили високо персоналізовану та ефективну систему пошуку.

Конкретно наш кейс реалізований у сфері нерухомості, але, звичайно, підхід, про який я розповім, може бути застосований і в багатьох інших галузях. Гадаю, стаття особливо зацікавить інженерів, які віддають перевагу оптимальним у довгостроковій перспективі (а не просто швидким) рішенням: із застосуванням наукових та технічних знань, креативності та інженерної експертизи.

Суть задачі

Отже, сама задача, на перший погляд, може здатись досить типовою — потрібно за критеріями користувача знаходити в базі даних інформацію про об’єкти нерухомості. Але є нюанси:

  1. Цей запит може формуватись за понад ніж 300 параметрами:
    • числові значення або діапазони чисел, як-от ціна або площа квадратних метрів;
    • дати;
    • булевий (логічний) тип даних (наприклад, «басейн = так» або «басейн = ні»);
    • розташування на мапі тощо.
  2. Користувач може навіть не пам’ятати всіх цих параметрів, тому ми повинні формувати для нього ще й пропозиції. Наприклад, він хоче знайти нерухомість у певній локації. Чи має це бути гірська місцевість, наскільки близько до океану, наскільки далеко від вулкана тощо — все це пропонується клієнту, коли він шукає за параметром «Розташування».
  3. Кількість і види параметрів можуть змінюватись. Тому ми повинні мати можливість швидко масштабувати систему.
  4. Замовник повинен мати можливість мігрувати в нашу систему пошукові запити, створені в іншій системі та в іншому форматі. Наперед скажу, що таку можливість ми реалізували. Ба більше, змогли знайти та усунути логічні помилки й виправити некоректні запити. Грубо кажучи, в сторонній системі клієнт налаштував пошук, яким намагався знайти житло в пішій доступності до моря в околицях Львова. Ми мігрували його пошуковий запит до нас та «відловили» логічну помилку.

Звичайно, ми мали забезпечити простоту формування запитів з боку користувача (front-end), можливість аналізувати ці запити зі сторони сервера (back-end) та перекладати їх на мову, яку розуміє пошуковий сервер Elasticsearch або будь-яке інше сховище даних, яке можна використати в майбутньому.

Одразу скажу, що варіант використовувати SQL та добудовувати в ньому незакінчену кількість параметрів WHERE ми відкинули, оскільки займатися ручною обробкою строкових даних або ж синтаксичним аналізом і формувати рядок для конструкції WHERE — не є оптимальним розв’язанням задачі. Це рішення, хоча теоретично і виглядає простішим, але розв’язує поставлену задачу тільки в короткостроковій перспективі. У майбутньому підтримувати та масштабувати його було б, м’яко кажучи, складно. Ба більше, на практиці подібний підхід призвів би до того, що в разі необхідності внесення змін ледь не все рішення потрібно було б переписувати — а це неабиякі часові та фінансові ресурси.

Тож перейдемо до того рішення, на якому ми зупинились — на кастомній мові запитів.

Деталі реалізації

У наявності ми мали підсистему метаданих, де поля для фільтрів, їх типи, назва тощо описані як набір структур зі сторони сервера. Зважаючи на це та на нашу ціль ми прийшли до того, що мова матиме граматику, схожу на таке поняття як предикат. Точніше як сукупність предикатів, об’єднаних між собою операцією кон’юнкції.

Приклад граматики

(variable: complexExpression) and (variable: complexExpression)...

де complexExpression в нашому випадку може бути таким:

complexExpression

  • not expression
  • or/and expression

expression

  • from value to value
  • from value
  • to value
  • value

value має такі типи:

  • polygonType:
    • POLYGON((latitude longitude), (latitude longitude), …)
  • booleanType:
    • true/false
  • dateTimeWithOffsetType
    • ’2000-12-12T00:00:00+000′
  • longType
  • doubleType
  • stringType

Реальний приклад запиту виглядає приблизно так:

(var1: POLYGON((2 3), (3 4), (5 6))) 
and (var2: not 5 or true or "s" or "2000-12-12T00:00:00+000") 
and (var3: from 4 to 5 or to 6 or from 5.6)

Інструмент для обробки запитів

Для реалізації функціонала аналізу та трансляції запиту зі сторони серверної частини ми обрали ANTLR4.

ANTLR (ANother Tool for Language Recognition) — потужний генератор парсерів для читання, обробки, виконання або перекладу структурованого тексту або двійкових файлів. Він широко використовується для створення мов, інструментів та фреймворків. За допомогою граматики ANTLR генерує парсер, який може створювати та обходити дерева розбору.

Отже, окресливши граматику нашої мови, щоб розпізнавати запити, ми маємо описати її в ANTLR.

Структура опису граматики ANTLR

Під час опису граматики мови, яку хочемо розібрати, ми повинні визначити правила для різних видів виразів, які можуть з’являтися в запитах. Згідно з документацією для опису граматики в ANTLR ми маємо створити файл з розширенням g4. Цей файл починається з ключової фрази grammar та назви граматики.

Розгляньмо розділи в файлі граматики.

Токени

Токени — це прості лексеми, які відповідають конкретному рядку символів у вихідному тексті. Вони зазвичай використовуються для визначення ключових слів, ідентифікаторів та літералів, як-от числа та рядки.

Токени визначаються за допомогою регулярних виразів і позначаються з малих літер. Префікси T__ та TOKENNAME_ використовуються для визначення спеціальних токенів.

Приклад токенів виглядає так:

fragment TRUE: [tT] [rR] [uU] [eE] ;
fragment FALSE: [fF] [aA] [lL] [sS] [eE] ;
BOOLEAN : TRUE
        | FALSE
   ;

У цьому прикладі TRUE та FALSE — це фрагменти, що використовуються для визначення іншого токену BOOLEAN.

В ANTLR4 ключове слово fragment потрібне для опису підрядків, які можуть бути складовими частинами складніших токенів або правил лексем. Фрагменти не можуть бути безпосередньо використані у граматиці, але вони можуть бути використані для визначення складових частин складніших токенів або правил.

Наприклад, у випадку fragment TRUE, TRUE є фрагментом, який використовується для визначення токена BOOLEAN. Це означає, що TRUE не можна використати безпосередньо як окремий токен (але він може бути складовою частиною складніших токенів або правил лексем, які містять BOOLEAN).

Лексеми

Лексеми — це комбінації токенів та інших лексем, що потрібні для визначення складніших конструкцій.

Наприклад:

polygonType     : POLYGON LEFT_BRACKET ( pointList (COMMA pointList)* )? RIGHT_BRACKET ;
longType        : LONG ;
doubleType      : DOUBLE ;
pointList       : LEFT_BRACKET ( point ( COMMA point )* )? RIGHT_BRACKET ;
point           : latitude longitude ;
latitude        : longType
                | doubleType
                ;
POLYGON         : [pP] [oO] [lL] [yY] [gG] [oO] [nN] ;
LONG            : (MINUS)? DIGIT+ ;
DOUBLE          : (MINUS)? DIGIT+ '.' DIGIT*
                | (MINUS)? '.' DIGIT+
                ;
RIGHT_BRACKET   : ')' ;
LEFT_BRACKET    : '(' ;
MINUS           : '-' ;
fragment DIGIT  : [0-9] ;

Тут polygonType — це лексема, що описує частину правила для розпізнавання запиту для пошуку входження координат в окреслену фігуру на мапі.

Правила

Правила визначають структуру мови та використовують лексеми та інші правила для формування складніших конструкцій. Правила лексем — це складніші лексеми, які містять більше число символів. Вони використовуються для опису структур, як-от імена файлів, URL-адреси, дати та інші. Правила лексем складаються з токенів та інших правил лексем, які можуть бути використані для складніших лексем.

Отже, основна відмінність між токенами та правилами лексем полягає в складності та деталізації опису лексем, що можна використовувати для синтаксичного аналізу тексту. Токени — це простіші лексеми, які відповідають конкретним символам у тексті, тоді як правила лексем — це складніші структури, що можуть містити кілька токенів та / або інших правил лексем.

Наприклад, правило для запиту може бути таким:

queryDeclaration : predicateGroupItem predicateGroupItemWithBooleanOperator* EOF;
predicateGroupItemWithBooleanOperator   : groupOperator predicateGroupItem ;
predicateGroupItem                      : LEFT_BRACKET variable variableDelimiter complexExpression RIGHT_BRACKET ;
variable                                : VARIABLE_STRING ;
variableDelimiter                       : VAR_DELIMITER  ;
expression : fromToExpression
           | toExpression
           | fromExpression
           | value
           ;
value  : polygonType
        | booleanType
        | dateTimeWithOffsetType
        | longType
        | doubleType
        | stringType
        ;

У цьому випадку:

  • правило «queryDeclaration» визначає початок та кінець запиту та збирає всі елементи в один список. Це правило старту, яке вказує, з чого починається аналіз тексту;
  • правило «predicateGroupItemWithBooleanOperator» визначає оператори (AND, OR) для поєднання предикатів (тобто виразів, які містять значення або оператори порівняння);
  • правило «predicateGroupItem» визначає групу предикатів, які можуть бути об’єднані за допомогою логічних операторів;
  • правило «variable» визначає ім’я змінної, яка може бути використана для збереження значення, введеного користувачем;
  • правило «variableDelimiter» визначає роздільник, який використовується між іменем змінної та значенням.
  • правило «expression» визначає значення, які можна використати як елемент запиту. Воно може бути простим значенням або складнішим виразом;
  • правило «value» визначає конкретні типи значень, які можна використати в запиті, як-от BOOLEAN, LONG, DOUBLE тощо;
  • Правило «complexExpression» визначає складні вирази, які можуть містити більше одного значення та операторів.

Додаткові деталі

Крім вищезгаданих розділів, у файлі .g4 можуть бути також визначені:

  • options — визначення опцій парсера та лексера;
  • imports — імпорт додаткових файлів та бібліотек;
  • actions — додатковий код.

Короткий підсумок

Порядок розділів, правил, токенів та лексем у файлі .g4 (граматиці ANTLR) є важливим. ANTLR обробляє граматику в лінійному порядку зверху вниз, і це визначає уявлення про структуру мови та спосіб розпізнавання та аналізу тексту.

Зазвичай граматика ANTLR складається з таких розділів:

  • Лексичні токени: визначаються токени, як-от ключові слова, оператори, розділові знаки та інші елементи, які розпізнаються лексичним аналізатором.
  • Правила лексем: визначаються правила для розпізнавання складних лексем, що складаються з одного або декількох токенів.
  • Правила синтаксису: визначаються правила для синтаксичного аналізу тексту. Кожне правило описує структуру та синтаксис конструкцій мови.
  • Правило старту: вказує, з якого правила починається аналіз тексту.

Порядок визначення токенів та правил важливий, оскільки ANTLR будує парсер згідно із цим порядком. Наприклад, якщо правило використовує токен, його потрібно визначити до визначення самого правила. Також важливо дотримуватись логічного порядку визначення, щоб уникнути конфліктів та неоднозначностей в аналізі.

Отже, коректний порядок та логічна структура розділів, правил, токенів та лексем в граматиці ANTLR впливають на правильність та ефективність аналізу тексту.

Компоненти обробки тексту ANTLR

Lexer та Parser — це два основні компоненти, які використовуються для обробки тексту в ANTLR. Ці компоненти виконують різні завдання та послуговуються різними типами правил для аналізу вхідного тексту.

Lexer відповідає за лексичний аналіз тексту, що означає розбиття вхідного тексту на лексеми, де лексема — це мінімальні одиниці тексту, які можна розрізнити. Lexer перетворює вхідний текст на послідовність лексем, кожна з яких має властивості, як-от тип токена та значення. Lexer також відповідає за видалення пропусків, коментарів та інших знаків, які не повинні бути враховані під час подальшого аналізу.

Наприклад, розглянемо такий вхідний рядок:

2 + 3 * 4

Lexer розбиває його на токени, відповідно до правил, описаних у граматиці:

INTEGER_LITERAL(2)
PLUS(+)
INTEGER_LITERAL(3)
STAR(*)
INTEGER_LITERAL(4)

Parser, з іншого боку, відповідає за синтаксичний аналіз лексем. Він перевіряє, чи відповідає послідовність токенів синтаксичним правилам, описаним у граматиці. Парсер перетворює послідовність лексем на дерево синтаксичного аналізу, яке представляє структуру вхідного тексту.

Наприклад, розглянемо таку граматику:

expression: term ((PLUS | MINUS) term)* ;
term: factor ((STAR | SLASH) factor)* ;
factor: INTEGER_LITERAL | LPAREN expression RPAREN ;
PLUS: '+';
MINUS: '-';
STAR: '*';
SLASH: '/';
INTEGER_LITERAL: [0-9]+ ;
LPAREN: '(' ;
RPAREN: ')' ;

Parser використовує цю граматику для перевірки, чи відповідає послідовність токенів правилам граматики.

Перед тим як робити синтаксичний аналізатор, ANTLR4 автоматично генерує лексичний аналізатор. Лексичний аналізатор ANTLR4 перетворює послідовність символів у вхідному файлі в послідовність токенів, які надалі будуть використовуватися синтаксичним аналізатором.

Важливо розуміти, що лексичний аналізатор відрізняється від синтаксичного тим, що він працює з токенами, а не із синтаксичним деревом. Лексичний аналізатор має доступ лише до поточного токена, тоді як синтаксичний аналізатор може аналізувати всі токени та виконувати складнішу логіку.

Отже, якщо подумати про розбір вхідного файлу як про процес пошуку інформації в тексті, то лексичний аналізатор відповідає за знаходження токенів, а синтаксичний аналізатор — за виконання логіки.

Лексичний аналізатор генерує послідовність токенів, що передаються до синтаксичного аналізатора (парсера), який використовує правила граматики для перевірки правильності синтаксису. Якщо синтаксис відповідає граматиці, то парсер генерує синтаксичне дерево, що представляє структуру запиту в нашому випадку.

Як згенерувати функціонал описаної граматики

Для того, щоб розробка граматик у ANTLR4 була зручнішою та продуктивнішою, ми використали плагін «ANTLR v4» для середовища розробки JetBrains, як-от IntelliJ IDEA.

Цей плагін допомагає розробникам зручно створювати, редагувати та налагоджувати граматики мови у форматі ANTLR4.

Основні функціональні можливості плагіна «ANTLR v4» містять:

  • Підсвічування синтаксису: плагін надає кольорове виділення токенів та правил граматики, що полегшує читання та редагування коду граматики.
  • Автодоповнення: плагін надає автодоповнення для токенів та правил граматики, що допомагає зменшити кількість помилок під час введення коду та прискорює процес розробки.
  • Перевірка синтаксису: плагін автоматично перевіряє синтаксис граматики та відображає помилки або попередження при виявленні некоректного коду. Це допомагає забезпечити правильність та надійність граматики.
  • Візуальне налагодження: плагін надає можливість візуального налагодження граматики, що дозволяє крок за кроком переглядати процес розбору тексту та перевіряти, як взаємодіє граматика з вхідними даними.
  • Генерація коду: плагін дозволяє генерувати код на основі граматики, що містить лексичний аналізатор, парсер та відповідні класи обробки вузлів AST.

Приклад аналізу запиту з оглядом синтаксичного дерева:

Після встановлення плагіну ми можемо виконати налаштування генератора коду, викликавши вікно налаштувань у меню:

Основні параметри налаштувань ANTLR можуть містити наступні:

  • Вихідна директорія для генерації коду: вказати директорію, куди буде збережений згенерований код з граматики. Це може бути окрема директорія в межах проєкту або інше розташування за вибором.
  • Вибір граматик для генерації коду: вказати, які граматики мови ANTLR мають бути використані для генерації коду. Ви можете вибрати всі граматики в проєкті або окремі граматики, що необхідні.

У цьому вікні налаштувань ANTLR ви можете налаштувати параметри залежно від ваших потреб і вимог вашого проєкту. Це дозволяє вам використовувати плагін «ANTLR v4» в зручний для вас спосіб та налаштувати його для оптимального використання під час роботи з граматиками мови.

Останнім етапом є вибір розділу меню Generate ANTLR Recognizer, після чого на виході ви отримаєте такі файли:

  • SearchQueryVisitor
  • SearchQueryBaseVisitor
  • SearchQueryLexer
  • SearchQueryParser
  • SearchQuery.interp
  • SearchQuery.tokens
  • SearchQueryLexer.interp
  • SearchQueryLexer.tokens

Кожен з файлів, які ви отримали після генерації коду на Java з вашої граматики SearchQuery, виконує певну роль у процесі аналізу тексту за допомогою ANTLR.

  • SearchQueryVisitor: відвідувач (Visitor), який надає можливість обходити та обробляти вузли абстрактного синтаксичного дерева (AST), створеного під час розбору тексту. Ви можете розширити цей клас та визначити власні методи обробки різних вузлів AST, щоб виконати певні дії або отримати інформацію з розпізнаного тексту.
  • SearchQueryBaseVisitor: базовий клас-відвідувача (Base Visitor), який містить загальні методи для обробки вузлів AST. Якщо ви розширюєте клас SearchQueryVisitor, то зазвичай ви також розширюєте цей базовий клас та визначаєте власні методи обробки для конкретних вузлів.
  • SearchQueryLexer: лексичний аналізатор, який розбиває вхідний текст на лексеми (токени). Лексичний аналізатор використовується парсером для отримання послідовності токенів з вхідного тексту.
  • SearchQueryParser: парсер, який виконує синтаксичний аналіз (парсинг) послідовності токенів, що була згенерована лексичним аналізатором. Парсер будує абстрактне синтаксичне дерево (AST) на основі правил граматики та виконує перевірку синтаксичної вірності вхідного тексту. Клас SearchQueryParser надає методи для розпізнавання та аналізу тексту згідно з правилами граматики.
  • SearchQuery.interp: цей файл є результатом генерації ANTLR і містить інформацію про інтерпретацію вашої граматики SearchQuery. Він потрібний для внутрішнього використання ANTLR і може бути корисним під час налагодження та відлагодження вашої граматики.
  • SearchQuery.tokens: тут визначені токени для вашої граматики SearchQuery. Кожен токен представляє розпізнавану одиницю, наприклад, ідентифікатор, ключове слово або оператор. Цей файл використовується для зв’язування токенів з їхніми відповідними лексемами під час аналізу вхідного тексту.
  • SearchQueryLexer.interp: файл є результатом генерації ANTLR і містить інформацію про інтерпретацію вашого лексичного аналізатора (lexer) для граматики SearchQuery. Він використовується для внутрішнього використання ANTLR і може бути корисним під час налагодження та відлагодження вашого лексичного аналізатора.
  • SearchQueryLexer.tokens: файл містить визначені токени для вашого лексичного аналізатора (lexer) граматики SearchQuery. Він використовується для зв’язування токенів з їхніми відповідними лексемами під час лексичного аналізу вхідного тексту.

Ці файли є важливими компонентами, які ANTLR генерує на основі вашої граматики, щоб допомогти вам аналізувати та обробляти тексти відповідно до визначених правил граматики.

Використання згенерованого коду

Для того, щоб перейти до аналізу текстового запиту, нам залишається декілька кроків. Потрібно:

  • описати структуру, у яку буде парситись текстове представлення запиту, частіше за все вона буде повторювати структуру правил, які описані в граматиці. (У нашому випадку це список об’єктів предикатних груп, назви змінних та вирази, які застосовуються до цих змінних).
  • обрати варіант для реалізації парсингу структури та виконати реалізацію. Для реалізації парсингу в ANTLR4 є кілька варіантів:
    1. Parse Tree Visitors: Підхід з використанням Parse Tree Visitors базується на шаблоні Visitor. Ми можемо створити клас Visitor і наперед визначити методи, відповідні кожному правилу граматики. За допомогою методів Visitor, ми можемо виконувати необхідні дії для кожного вузла дерева розбору під час обходу.
    2. Parse Tree Listeners: За допомогою Parse Tree Listeners ми можемо зареєструвати спеціальний обробник (listener), який автоматично викликатиметься при обході кожного вузла у дереві розбору. Цей підхід дозволяє нам реалізувати необхідну логіку обробки безпосередньо у методах обробника, які спрацьовують під час проходу парсером по дереву розбору.

Вибір між цими підходами залежить від конкретних потреб вашого проєкту. Visitor, Listener, Parse Tree та AST надають різні рівні абстракції та можуть бути використані для різних видів обробки та аналізу вхідних даних.

У нашому випадку ми вибрали варіант з Visitor, тому для нашої граматики на базі предикатних груп ми описали Visitor для таких правил, як:

    • FromToExpressionVisitor;
    • PredicateExpressionsVisitor;
    • ValueExpressionVisitor;
    • PredicateGroupOperatorVisitor;
    • PredicateGroupVisitor;
    • PredicateVariableVisitor;
    • SearchQueryVisitor;

А також описали основний клас парсера, у якому:

  • ініціювали лексер;
  • ініціювали CommonTokenStream, передавши в нього наш лексер;
  • створили кореневий SearchQueryVisitor;
  • ініціювали Парсер;
  • почали обробку дерева розбору;
  • після чого ми отримали наповнену структуру даних, яка представляє наш запит у вигляді.

Обробляти структуру даних SearchQuery, яка представляє наш запит, тепер можна за вашими потребами. Ми зі свого боку, проходячи цією структурою, виконуємо трансляцію запиту до пошукового індексу з подальшим виведенням результатів пошуку.

Трансляція пошукового запиту до пошукового індексу

Як зазначалось вище, для зберігання даних та пошуку ми використовуємо розподілену пошукову та аналітичну систему Elasticsearch.

Не буду вдаватись глибоко в деталі бізнес-вимог, лише скажу, що Elasticsearch як система зберігання даних була обрана не просто так:

  • Пошуковий запит має бути сформований зі списку обраних бізнесом полів, які під час формування самого запиту мають пропонуватись автоматично з урахуванням виставленого пріоритету (індексу підсилення), що дуже добре реалізовувати як sugestions в Elasticsearch.
  • Можливість гнучко, за допомогою аналайзерів, налаштовувати пошук текстовими даними, числами, геопросторовими, структурованими даними і, знову ж таки, за допомогою індексу підсилення керувати результатами пошуку.
  • Легка інтеграція з іншими інструментами: Elasticsearch легко інтегрується з іншими компонентами стеку Elastic, як-от Logstash (для збору та обробки журнальних даних) і Kibana (для візуалізації даних). Це створює повний набір інструментів для обробки та аналізу даних.
  • Elastic Stack також надає інструменти для моніторингу та керування Elasticsearch-кластером. Ви можете легко стежити за станом вашого кластера і вчасно реагувати на проблеми.

Другою частиною в ланцюжку оброблення пошукових запитів в нашій системі є транслятор запитів з індивідуальної мови в набір команд до пошукового індексу.

У цю задачу входить обробка структури SearchQuery, яку сформував парсер ANTLR4, та побудова запиту до Elasticsearch індексу в правильній структурі.

Деталі реалізації

Маючи структуру SearchQuery, ми реалізували декілька QueryGenerator, набір стратегій та білдерів, які разом будують запит до Elasticsearch індексу. Для відправки запитів до Elasticsearch індексу та побудови таких запитів ми використовуємо Spring Data Elasticsearch.

Структура SearchQuery повторює структуру лексеми так, щоб її було зручно заповнювати на стадії парсингу запиту в текстовому вигляді.

@Data
@AllArgsConstructor
public class SearchQuery {
private List<PredicateGroup> predicateGroups;
}

Основними складовими структури є сукупність предикатних груп

public enum PredicateGroupOperator {

    AND, NONE

}

@Data

@AllArgsConstructor

public class PredicateGroup {

    private PredicateGroupOperator groupOperator;

    private String field;

    private List<AbstractExpression> expressions;

}

Предикатна група повторює структуру запиту і складається з:

  • оператора між предикатними групами, це логічне AND у нашому випадку;
  • назви змінної field;
  • набору expressions.
@Getter
@AllArgsConstructor
public enum ExpressionBooleanOperator {
    OR, OR_NOT, AND, AND_NOT, NOT, NONE
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public abstract class AbstractExpression {
    private ExpressionBooleanOperator booleanOperator;
}
@Data
@EqualsAndHashCode(callSuper = true)
public class FromToExpression extends AbstractExpression {
    private ExpressionValueType<?> fromValue;
    private ExpressionValueType<?> toValue;
    public FromToExpression(
final ExpressionBooleanOperator booleanOperator, 
final ExpressionValueType<?> fromValue,
final ExpressionValueType<?> toValue) {
        super(booleanOperator);
        this.fromValue = fromValue;
        this.toValue = toValue;
    }
}
@Data
@EqualsAndHashCode(callSuper = true)
public class ValueExpression extends AbstractExpression {
    private MathOperator mathOperator;
    private ExpressionValueType<?> value;
    public ValueExpression(
final ExpressionBooleanOperator booleanOperator,
final MathOperator mathOperator,
final ExpressionValueType<?> value) {
        super(booleanOperator);
        this.value = value;
        this.mathOperator = mathOperator;
    }
}

Expressions — це всі ті можливі вирази, що описані в нашій лексемі, та логічні оператори між цими виразами, описані в структурі ExpressionBooleanOperator. Сам собою Expression в нашому випадку складається з двох типів виразів:

  • ValueExpression, який описує значення виразу всіх можливих типів згідно з лексемою (BooleanType, DateTimeWithOffsetType, DoubleType, LongType, MultiSelectedType, PolygonType, StringType).
  • FromToExpression, за допомогою якого можна описати такі вирази, як-от діапазони значень для типів DateTimeWithOffsetType, DoubleType, LongType.

Отже, в основі функціонала лежить прохід структурою SearchQuery та вибір стратегій на базі виразів ValueExpression та FromToExpression. Для цього ми маємо дві основні стратегії генерації запиту до Elasticsearch індексу:

  • FromToExpressionQueryGenerator. В основі цієї стратегії лежить RangeQueryBuilder з методами
  • ValueWithBooleanOperatorExpressionQueryGenerator. В основі цієї стратегії лежить сукупність методів BoolQueryBuilder, таких як:
    • must();
    • should();
    • mustNot().

а також:

  • TermQueryBuilder, який використовується для фільтрації нетекстових значень;
  • WildcardQueryBuilder та QueryStringQueryBuilder, котрі ми використовуємо для фільтрації текстових значень;
  • NestedQueryBuilder використовується для обгортки запитів так, щоб можна було виконати пошук вкладених полів.

Останні етапи отримання результатів

Після того, як ми побудували запит до Elasticsearch, лишається використати ElasticsearchRestTemplate для виконання NativeSearchQuery за допомогою search-методу та перетворити SearchHits результуючу структуру у вигляді, який, до прикладу, можна буде відрендерити на front-end-стороні.

У підсумку я б виділив таку сукупність викликів, окрім розробки транслятора, які ми вирішували:

  • Перш за все це доволі розгалужена структура сутностей, формування запиту, до яких потрібна побудова в правильному порядку та вкладеністю. Для цього ми попередньо будували дерево змінних та Expression до них так, щоб на основі цього дерева можна було побудувати запит та правильно використати NestedQueryBuilder.
  • Ще одним викликом були запити для даних типу String. Тут нам довелося розробити допоміжний функціонал для додаткової обробки запитів, перш ніж відправляти їх до Elasticsearch, особливо це стосується спеціальних символів.
  • А також дуже цікавим була розробка функціоналу для динамічного встановлення boost-параметру для підняття пошукових результатів вище в загальній, результуючій множині. Ще одним цікавим моментом була розробка функціонала для побудови QueryStringQueryBuilder:
    • динамічного налагодження збігів у словах;
    • встановлення мінімальної кількості співпадінь;
    • налагодження синонімів під час пошуку, вибір та налагодження аналайзера.

Підсумки та додаткові можливості ANTLR4

ANTLR4 може бути корисним для багатьох інших завдань, окрім розробки індивідуальної мови запитів. Деякі з використань ANTLR4 містять:

  • Розробку мов програмування, створення граматик і парсерів для них. Ви можете визначити синтаксис мови та згенерувати парсер для перетворення вихідного коду цією мовою у структуроване дерево або AST.
  • Розбір та обробку структурованого тексту, як-от конфігураційні файли, формати даних, мови запитів тощо. Ви можете визначити граматику для цього тексту і згенерувати парсер для його розбору та подальшої обробки.
  • Створення мовних інструментів, як-от редактори з підсвічуванням синтаксису, автодоповненням, перевіркою помилок тощо. З використанням ANTLR4 ви можете аналізувати вхідний код і надавати користувачам корисні функції під час редагування.
  • Генерацію коду для різних мов програмування. Ви можете визначити граматику, яка описує вихідний код, і згенерувати парсер, який розбере цей код і згенерує еквівалентний код цільовою мовою.
  • Валідацію структурованих даних. Ви можете визначити граматику для цих даних і згенерувати парсер, який буде валідувати відповідність правилам вашого коду.

У нашому випадку розробка індивідуальної мови запитів на базі ANTLR4 дозволила створити потужний інструмент для синтаксичного аналізу та обробки вхідних запитів. На теперішній момент ми можемо легко модифікувати граматику мови з урахуванням потреб, оновлювати кодову базу функціонала на базі ANTLR4 та розширювати або змінювати функціонал трансляції запитів за необхідністю.

Джерела

👍ПодобаєтьсяСподобалось6
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Ну... чому б не використовувати для запитів хоча б мову програмування Prolog? Чому саме нова мова?

Prolog — це більше про факти та правила на базі яких Prolog зможе зробити висновки. Наприклад:
Петро автомобіліст, в нього є право на водіння авто, та є авто, Марія дружина Петра, та в неї є право на водіння авто. Якщо запитати Prolog про Марію, то він зробить висновок, що Марія автомобіліст, хоча такого факту не було в описі.
Перед нами була інакша задача, тому і був обраний такий підхід.

Це також мова запитів, наприклад знайти всіх автомобілістів. Усі запити, які я побачив у статті, гарно лягають на мову Prolog.

Нажаль в статті мова йшла не про запити, які б доцільно можна було б реалізувати на Prolog. Тут більше про уніфікацію пошукових запитів з UI частини, грубо кажучи супер багато фільтрів, які на серверній частині можна було б зручно транслювати в запити до різного типу сховищ, в нашому випадку це пошуковий індекс Elasticsearch. UI частина це Angular, серверна частина це Java, між ними запити у вигляді простих предикатів, які транслятор розбирає використовуючи ANTLR4 та формує запити у Elasticsearch. Можливо тут можна було б застосувати Prolog, але це виглядає як overhead

Чудова стаття, Вікторе! Доволі цікава і не напряжна!

Підписатись на коментарі