Interpreter to wzorzec GoF który pozwala zdefiniować gramatykę dla prostego języka i zbudować interpreter który przetwarza wyrażenia w tym języku. Brzmi akademicko, ale ma bardzo konkretne zastosowania: reguły rabatowe konfigurowane przez operatorów bez zmian kodu, wyrażenia filtrujące produkty, warunki widoczności komponentów w CMS. Buduję od zera mini-interpreter reguł promocyjnych w PHP.
Kiedy Interpreter ma sens?
Interpreter jest uzasadniony gdy:
- Masz reguły które zmieniają się często i muszą być konfigurowalne przez nie-developerów
- Reguły można wyrazić w prostej gramatyce (wyrażenia logiczne, matematyczne)
- Złożoność gramatyki jest mała – duże języki lepiej obsłuży ANTLR lub dedykowany parser
Gramatyka reguł rabatowych
# Przykłady reguł które chcemy parsować i wykonywać:
# "order.total > 500 AND customer.group = 'vip'"
# "product.category IN ['shoes', 'bags'] OR product.brand = 'Nike'"
# "NOT order.has_coupon AND order.total >= 200"
# Gramatyka (uproszczony EBNF):
# expression = term (("AND" | "OR") term)*
# term = "NOT" term | comparison | "(" expression ")"
# comparison = identifier operator value
# identifier = word ("." word)*
# operator = "=" | "!=" | ">" | ">=" | "<" | "<=" | "IN" | "NOT IN"
# value = number | string | "[" value ("," value)* "]"
Abstract Expression Interface
<?php
declare(strict_types=1);
// Context - dane na których interpretujemy wyrażenie
class RuleContext
{
private array $data;
public function __construct(array $data)
{
$this->data = $data;
}
public function resolve(string $path): mixed
{
$parts = explode('.', $path);
$current = $this->data;
foreach ($parts as $part) {
if (!isset($current[$part])) {
return null;
}
$current = $current[$part];
}
return $current;
}
}
// Abstract Expression - interfejs dla wszystkich wyrażeń
interface ExpressionInterface
{
public function interpret(RuleContext $context): bool;
public function __toString(): string;
}
Terminal Expressions – liście drzewa
<?php
declare(strict_types=1);
// Wyrażenie porównania: "order.total > 500"
final class ComparisonExpression implements ExpressionInterface
{
public function __construct(
private readonly string $identifier, // "order.total"
private readonly string $operator, // ">"
private readonly mixed $value // 500
) {}
public function interpret(RuleContext $context): bool
{
$left = $context->resolve($this->identifier);
return match($this->operator) {
'=' => $left == $this->value,
'!=' => $left != $this->value,
'>' => $left > $this->value,
'>=' => $left >= $this->value,
'<' => $left < $this->value,
'<=' => $left <= $this->value,
'IN' => is_array($this->value) && in_array($left, $this->value, true),
'NOT IN' => is_array($this->value) && !in_array($left, $this->value, true),
default => throw new \InvalidArgumentException("Unknown operator: {$this->operator}"),
};
}
public function __toString(): string
{
$val = is_array($this->value) ? '[' . implode(', ', $this->value) . ']' : $this->value;
return "{$this->identifier} {$this->operator} {$val}";
}
}
Non-Terminal Expressions – kompozyty
<?php
declare(strict_types=1);
// AND - oba warunki muszą być spełnione
final class AndExpression implements ExpressionInterface
{
public function __construct(
private readonly ExpressionInterface $left,
private readonly ExpressionInterface $right
) {}
public function interpret(RuleContext $context): bool
{
// Short-circuit: jeśli left = false, nie obliczaj right
return $this->left->interpret($context)
&& $this->right->interpret($context);
}
public function __toString(): string
{
return "({$this->left} AND {$this->right})";
}
}
// OR - przynajmniej jeden warunek
final class OrExpression implements ExpressionInterface
{
public function __construct(
private readonly ExpressionInterface $left,
private readonly ExpressionInterface $right
) {}
public function interpret(RuleContext $context): bool
{
return $this->left->interpret($context)
|| $this->right->interpret($context);
}
public function __toString(): string
{
return "({$this->left} OR {$this->right})";
}
}
// NOT - negacja
final class NotExpression implements ExpressionInterface
{
public function __construct(
private readonly ExpressionInterface $expression
) {}
public function interpret(RuleContext $context): bool
{
return !$this->expression->interpret($context);
}
public function __toString(): string
{
return "(NOT {$this->expression})";
}
}
Parser – buduje drzewo wyrażeń z tekstu
<?php
declare(strict_types=1);
// Prosty parser rekurencyjnego zstępowania
class RuleParser
{
private array $tokens = [];
private int $position = 0;
public function parse(string $rule): ExpressionInterface
{
$this->tokens = $this->tokenize($rule);
$this->position = 0;
return $this->parseExpression();
}
// expression = term (("AND" | "OR") term)*
private function parseExpression(): ExpressionInterface
{
$left = $this->parseTerm();
while ($this->peek() === 'AND' || $this->peek() === 'OR') {
$operator = $this->consume();
$right = $this->parseTerm();
$left = $operator === 'AND'
? new AndExpression($left, $right)
: new OrExpression($left, $right);
}
return $left;
}
// term = "NOT" term | "(" expression ")" | comparison
private function parseTerm(): ExpressionInterface
{
if ($this->peek() === 'NOT') {
$this->consume();
return new NotExpression($this->parseTerm());
}
if ($this->peek() === '(') {
$this->consume(); // (
$expr = $this->parseExpression();
$this->consume(); // )
return $expr;
}
return $this->parseComparison();
}
// comparison = identifier operator value
private function parseComparison(): ExpressionInterface
{
$identifier = $this->consume(); // np. "order.total"
$operator = $this->consume(); // np. ">"
// Obsługa "NOT IN"
if ($operator === 'NOT' && $this->peek() === 'IN') {
$this->consume();
$operator = 'NOT IN';
}
$value = $this->parseValue();
return new ComparisonExpression($identifier, $operator, $value);
}
private function parseValue(): mixed
{
$token = $this->consume();
// Lista wartości: ['shoes', 'bags']
if ($token === '[') {
$values = [];
while ($this->peek() !== ']') {
$values[] = $this->parseScalar($this->consume());
if ($this->peek() === ',') {
$this->consume();
}
}
$this->consume(); // ]
return $values;
}
return $this->parseScalar($token);
}
private function parseScalar(string $token): mixed
{
// String w cudzysłowiu
if (str_starts_with($token, "'") && str_ends_with($token, "'")) {
return trim($token, "'");
}
// Liczba
if (is_numeric($token)) {
return str_contains($token, '.') ? (float) $token : (int) $token;
}
// Boolean
if ($token === 'true') return true;
if ($token === 'false') return false;
if ($token === 'null') return null;
return $token;
}
private function tokenize(string $rule): array
{
// Prosta tokenizacja przez regex
preg_match_all(
"/\w+\.\w+|\bNOT\b|\bAND\b|\bOR\b|\bIN\b|[><=!]+|'[^']*'|\[|\]|[(),]|\d+\.?\d*/",
$rule,
$matches
);
return $matches[0];
}
private function peek(): ?string
{
return $this->tokens[$this->position] ?? null;
}
private function consume(): string
{
$token = $this->tokens[$this->position]
?? throw new \RuntimeException("Unexpected end of rule");
$this->position++;
return $token;
}
}
Użycie w praktyce – reguły rabatowe
<?php
$parser = new RuleParser();
// Reguła z bazy danych / panelu admina
$ruleText = "order.total >= 500 AND customer.group IN ['vip', 'wholesale'] AND NOT order.has_coupon";
$expression = $parser->parse($ruleText);
// Kontekst dla konkretnego zamówienia
$context = new RuleContext([
'order' => [
'total' => 650.0,
'has_coupon' => false,
'item_count' => 5,
],
'customer' => [
'group' => 'vip',
'order_count' => 12,
'registration_days' => 365,
],
]);
$qualifies = $expression->interpret($context);
echo "Kwalifikuje do rabatu: " . ($qualifies ? 'TAK' : 'NIE') . "\n"; // TAK
// Debug - wypisz drzewo wyrażeń
echo $expression . "\n";
// ((order.total >= 500 AND customer.group IN [vip, wholesale]) AND (NOT (order.has_coupon = true)))
// Inny kontekst – nie kwalifikuje
$context2 = new RuleContext([
'order' => ['total' => 300.0, 'has_coupon' => true],
'customer' => ['group' => 'retail'],
]);
echo "Kwalifikuje: " . ($expression->interpret($context2) ? 'TAK' : 'NIE') . "\n"; // NIE
Integracja z Magento 2 – reguły w bazie danych
<?php
declare(strict_types=1);
// Serwis który ładuje reguły z bazy i ewaluuje dla zamówienia
class OrderDiscountRuleEngine
{
public function __construct(
private RuleParser $parser,
private \Magento\Framework\App\ResourceConnection $resourceConnection
) {}
public function getApplicableDiscounts(\Magento\Sales\Api\Data\OrderInterface $order): array
{
$rules = $this->loadActiveRules();
$context = $this->buildContext($order);
$discounts = [];
foreach ($rules as $rule) {
try {
$expression = $this->parser->parse($rule['condition']);
if ($expression->interpret($context)) {
$discounts[] = [
'rule_id' => $rule['rule_id'],
'label' => $rule['label'],
'discount' => (float) $rule['discount_percent'],
];
}
} catch (\Exception $e) {
// Loguj błąd parsowania reguły, ale nie przerwaj checkout
$this->logger->error("Invalid rule condition: {$rule['rule_id']}", [
'condition' => $rule['condition'],
'error' => $e->getMessage(),
]);
}
}
return $discounts;
}
private function buildContext(\Magento\Sales\Api\Data\OrderInterface $order): RuleContext
{
return new RuleContext([
'order' => [
'total' => $order->getGrandTotal(),
'item_count' => count($order->getItems()),
'has_coupon' => !empty($order->getCouponCode()),
'currency' => $order->getOrderCurrencyCode(),
],
'customer' => [
'group' => $order->getCustomerGroupId(),
'is_logged_in' => $order->getCustomerId() !== null,
],
]);
}
private function loadActiveRules(): array
{
$select = $this->resourceConnection->getConnection()->select()
->from($this->resourceConnection->getTableName('vendor_discount_rules'))
->where('is_active = ?', 1)
->order('priority ASC');
return $this->resourceConnection->getConnection()->fetchAll($select);
}
}
Podsumowanie
Wzorzec Interpreter daje coś rzadkiego: możliwość dodawania nowych reguł biznesowych przez konfigurację, bez deploymentu kodu. Operator może wpisać order.total > 1000 AND customer.group = 'premium' w panelu admina i reguła działa natychmiast. Parser zbudowany metodą rekurencyjnego zstępowania obsługuje arbitralnie zagnieżdżone wyrażenia. Kluczowe ograniczenie: gramatyka musi być prosta. Gdy potrzebujesz pełnego języka programowania – lepiej sięgnąć po Twig Sandbox albo dedykowany DSL.
