В нашем интернет-магазине возникла задача назначение скидок клиентам и их подсчета. Вернее, скидки мы уже считали давно и до этого, но сейчас бизнес пришёл с новой идеей, на которую наш скидочный движок рассчитан не был.
Так же, нужно пояснить, что у нас разделены отдел разработки и отдел эксплуатации. Скидку должны назначать админы ресурса. Если расчет скидки делать через CustomFee.php скрипт, в котором бы была зашита логика подсчета, то каждый раз, при каких-либо изменениях, пришлось бы его заново деплоить.
Сам процесс деплоя, в нашей компании - не очень быстрый, т. к. исправления должен отревьювить техлид, после чего он попадёт тестерам и уже после админ его пустит в прод. Согласитесь, не очень удобно для назначения скидок. Да и напрягать разрабов каждый раз, что бы поменяли циферки в скрипте подсчета — не совсем правильно.
В общем, было решено писать интерпретатор выражений. Использование функции eval отмёл сразу, т. к. это такая потенциальная мина в безопасности, которую сам себе закладываешь. Моё субъективное мнение, что минусы от её использования перекрывают плюсы.
Что получилось
Что бы не томить большинство читателей, привожу сразу результат того что получилось. Ссылка на github: https://github.com/iustato/bql
Интерпретатор понимает следующие операторы:
+ , - , * , / , == , != , < , > , <= , >=, &&, AND,
! - не, in - Проверка в массиве, like - Поиск по шаблону (аналог SQL LIKE), ?? - если переменная null, то присвоить значение.
Простенький пример использования:
use IustatoBqlExpressionInterpreter;
$bql = new ExpressionInterpreter();
$a = 10;
// Определяем переменные
// Если мы хотим, что бы значение переменной могло быть изменено интерпретатором, то передаём его по ссылке &$a
$variables = [
'a' => &$a,
'b' => 5
];
// Устанавливаем переменные в интерпретатор.
$bql->setVariables($variables);
// Выполняем выражение
$bql->evaluate("a = a + b");
$result = $bql->getModifiedVariables();
echo "Результат: " . json_encode($result) . PHP_EOL; // 15
Особой фишкой является то, что интерпретатор может принимать и обрабатывать вложенные свойства и значения а так же их устанавливать.
Представьте себе, что в момент заказа у нас есть объект класса Order, с заполненными данными о заказе. У этого объекта есть свойство $customer, которое является объектом Customer, а у объекта Customer есть массив $counters, в котором может содержаться любое количество счетчиков, которые нам может понадобиться отслеживать при назначении нашей скидки.
После обработки платежа, можно вызывать интерпретатор выражений, примерно таким образом:
// представим что это метод пост-обработки заказа
public function OrderPostProcess (Order $current_order)
{
$bql = new ExpressionInterpreter();
$variables = [
'order' => $current_order
];
$bql->setVariables($variables);
// добавляем в общие счетчики (сколько итого заказов сделал клиент) в нашем магазине
$bql→evaluate("order.customer.counters.total_qnt++; order.customer.counters.total_sum+=order.amount; ");
echo "Так поменялся объект counters: " . json_encode($order->customer->counters) . PHP_EOL;
}
Так же, если присвоение будет происходить объекту класса, то интерпретатор хорошо работает с магическим методом __set или же с методом setVariable, который вы напишите для установки значения $variable в классе.
Вот еще один пример:
class A {
private $my_value;
public $var;
public function __construct($v)
{
$this->my_value = $v;
$this->var = 123;
}
public function setValue($value)
{
$this->my_value = $value;
}
public function getValue()
{
return $this->my_value; }
}
}
// index.php
$a = new A(15);
$bql = new ExpressionInterpreter();
$variables = [ 'A' => $a ];
$bql->setVariables($variables);
$bql->evaluate("A.Value = 5 + 3 * 8; A.var = A.Value - 7;");
echo "Так поменялся объект a: " . json_encode($a) . PHP_EOL;
$ModifiedVars = $bql→getModifiedVariables();
echo "Список изменённых переменных, по мнению интерпретатора: " . json_encode($ModifiedVars) . PHP_EOL;
Таким образом, вы можете встроить интерпретатор в любую вашу структуру классов и дать возможность пользователю (скорее всего админу ресурса) делать какие-то дополнительные действия с вашими объектами внутри приложения, но только с теми, с которыми вы ему разрешите.
Интерпретаторы выражений от других авторов
Привожу пару ссылок на решения, аналогичные моему, которые нашёл на просторах интернета. Так или иначе, они чем-то мне не подошли, а некоторые нагуглил уже после реализации своего решения. Возможно, Вам подойдут:
https://github.com/madorin/matex
https://github.com/xylemical/php-expressions
https://symfony.com/doc/current/components/expression_language.html
Основные части интерпретатора
На моё удивление, написать интерпретатор было сильно проще, чем я себе представлял изначально. Как Вы могли заметить, основная логика интерпретатора, практически уместилась в один класс. Интерпретатор состоит из 3х основных частей:
1. Конечный автомат, который разбивает введённую человеком строчку на токены. Название переменной, оператор, скобка — это всё токены. Это наиболее удобный способ «спарсить» введённую человеком строку. Не писать же для этого регулярки, в самом деле?
2. Затем выражение преобразуется в обратную польскую нотацию (бесскобочной способ записи математического выражения, когда оператор стоит не между двумя операндами, а в конце. То есть 2+2, будет записано как 2 2 +).
3. Непосредственно, исполнение выражения
Доступные операторы регистрируются в конструкторе, например:
$this->registerOperator('!=', fn(&$a, $b) => $a != $b, 3);
таким образом, можно легко добавить необходимые вам функции, которые я не добавил. Их можно добавлять и вне класса, но то что добавите в сам класс, буду признателен за pull request =)
Что-то вроде вывода
Я никогда ранее не писал интерпретаторы и это для меня первый подобный опыт. Скажу, что это оказалось сильно проще, чем я себе представлял (Спасибо chatgpt он сильно упростил мне жизнь изначально).
Сложности немного возникли, когда я отлаживал присвоение значения по ссылке. В XDebug не понятно, если значение передано как копия или как ссылка и особенно подгорело, когда обнаружил что проблема, на которую я потратил пол дня крылась в том, что call_user_func игнорирует передачу параметра по ссылке и всегда передаёт копию (да-да, потом я нашёл что это написано в документации и вообще все кроме меня это знают, но всё это я уже узнал потом)
Мой интерпретатор не имеет условных операторов и циклов. Для моей задачи я обошелся тем, что в таблице правил завел два поля Condition и Action. Код в Action, срабатывает, только если код в Condition возвращает true. Я не уверен, если нужно добавлять условные операторы и циклы, что повлечет за собой необходимость добавлять операторские скобки. Всё таки, цель была сделать простенький интерпретатор выражений для админов, а не писать язык программирования. Ради спортивного интереса, конечно, могу заняться.
Это моя первая статья на Хабре, не судите строго. Буду благодарен за фидбек.
Автор: VArtem