Паттерны проектирования Правила как данные: Interpreter, Specification и композиция предикатов
0%

Правила как данные: Interpreter, Specification и композиция предикатов

Правила как данные: Interpreter, Specification и композиция предикатов

Есть класс задач, где обычный код проигрывает не по красоте, а по скорости изменения. Скидочные правила, антифрод, тарификация, роутинг заявок, право доступа, условия выдачи кредита, критерии попадания в рассылку. Общее у них одно: правило меняется чаще, чем выходит релиз, и меняет его не тот, кто пишет код.

Типичная эволюция такого места известна заранее. Сначала один if. Через полгода — метод на 300 строк с двенадцатью вложенными условиями, который никто не рискует трогать. Ещё через год — тот же метод, но с флагами is_legacy_promo_2024, и никто в компании не может ответить на вопрос «какие скидочные правила сейчас действуют» иначе как чтением кода.

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


Карта главы


Specification: предикат, ставший объектом

Specification — паттерн, в котором логическое условие представлено объектом с методом «удовлетворяет ли кандидат», а сложные условия строятся композицией простых. Формализован Эриком Эвансом и Мартином Фаулером в «Specifications» (2002).

Структурно это Composite над предикатами и одновременно вырожденный Interpreter: у него есть грамматика из трёх операций (И, ИЛИ, НЕ) и листья-условия.

Зачем это, если есть обычные функции? Функция lambda c: c.age >= 18 тоже предикат. Разница в интроспекции: объект-спецификацию можно не только вызвать, но и разобрать — перевести в SQL, показать пользователю на человеческом языке, сохранить в базу, сравнить с другой спецификацией, объяснить, почему кандидат не подошёл. Замыкание всего этого не умеет: внутрь функции заглянуть нельзя.

from dataclasses import dataclass
from typing import Any


class Spec:
    """Базовый класс: даёт композицию через операторы & | ~."""

    def is_satisfied_by(self, c: "Customer") -> bool:
        raise NotImplementedError

    def __and__(self, other: "Spec") -> "Spec":
        return And(self, other)

    def __or__(self, other: "Spec") -> "Spec":
        return Or(self, other)

    def __invert__(self) -> "Spec":
        return Not(self)


@dataclass(frozen=True)
class And(Spec):
    left: Spec
    right: Spec

    def is_satisfied_by(self, c) -> bool:
        # Короткое замыкание: правая часть может быть дорогой (запрос, вызов сервиса).
        return self.left.is_satisfied_by(c) and self.right.is_satisfied_by(c)


@dataclass(frozen=True)
class Or(Spec):
    left: Spec
    right: Spec

    def is_satisfied_by(self, c) -> bool:
        return self.left.is_satisfied_by(c) or self.right.is_satisfied_by(c)


@dataclass(frozen=True)
class Not(Spec):
    inner: Spec

    def is_satisfied_by(self, c) -> bool:
        return not self.inner.is_satisfied_by(c)


# --- листья: каждое условие живёт в одном месте и имеет имя ---

@dataclass(frozen=True)
class AgeAtLeast(Spec):
    years: int

    def is_satisfied_by(self, c) -> bool:
        return c.age >= self.years


@dataclass(frozen=True)
class CountryIn(Spec):
    codes: frozenset[str]

    def is_satisfied_by(self, c) -> bool:
        return c.country in self.codes


@dataclass(frozen=True)
class OrdersAtLeast(Spec):
    count: int

    def is_satisfied_by(self, c) -> bool:
        return c.orders_count >= self.count

Правило маркетинга «совершеннолетние из России или Казахстана, у кого от трёх заказов, кроме корпоративных клиентов» превращается в выражение, которое читается как исходная фраза:

loyal = (
    AgeAtLeast(18)
    & CountryIn(frozenset({"RU", "KZ"}))
    & OrdersAtLeast(3)
    & ~IsCorporate()
)

if loyal.is_satisfied_by(customer):
    apply_discount(customer, percent=10)

Сложность. Проверка — O(n) по числу листьев, память — O(d) по глубине дерева на стек рекурсии. Практически это ничто по сравнению с любым обращением к базе, но при 10 000 правил на событие структура начинает стоить денег — об этом ниже, в разделе про масштаб.


Главная выгода: одно правило — две интерпретации

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

  1. Проверить конкретного клиента — когда он оформляет заказ.
  2. Найти всех таких — когда маркетинг делает рассылку.

Наивное решение: написать предикат в Python и отдельно написать WHERE в SQL. Через месяц маркетинг поменяет «от трёх заказов» на «от пяти», кто-то поправит одно место из двух, и система начнёт давать скидку тем, кому не рассылает, и наоборот. Это не гипотеза, это самый частый баг такого рода.

Одна спецификация — два вычислителя: память и SQL

Решение: вторая интерпретация той же структуры. Дерево спецификации разбирается и переводится в SQL — это буквально Visitor из поведенческих паттернов, только записанный через сопоставление с образцом.

def to_sql(spec: Spec) -> tuple[str, list[Any]]:
    """Переводит спецификацию в фрагмент WHERE и список параметров.

    Параметры возвращаются отдельно — конкатенация значений в строку запроса
    была бы SQL-инъекцией. Это обязательное свойство, а не стилистика.
    """
    match spec:
        case And(left, right):
            ls, lp = to_sql(left)
            rs, rp = to_sql(right)
            return f"({ls} AND {rs})", lp + rp
        case Or(left, right):
            ls, lp = to_sql(left)
            rs, rp = to_sql(right)
            return f"({ls} OR {rs})", lp + rp
        case Not(inner):
            s, p = to_sql(inner)
            return f"(NOT {s})", p
        case AgeAtLeast(years):
            return "age >= %s", [years]
        case CountryIn(codes):
            return "country = ANY(%s)", [sorted(codes)]
        case OrdersAtLeast(count):
            return "orders_count >= %s", [count]
        case _:
            # Явный отказ лучше молчаливого расхождения интерпретаций.
            raise NotImplementedError(f"нет SQL-перевода для {type(spec).__name__}")


where, params = to_sql(loyal)
rows = conn.execute(f"SELECT id, email FROM customers WHERE {where}", params)

Три детали, которые отличают рабочий код от учебного:

  • raise NotImplementedError в case _. Спецификация, которую нельзя перевести в SQL (например, «клиент звонил в поддержку за последние сутки» — данные в другой системе), должна падать явно, а не тихо выпадать из условия. Молчаливое расхождение двух интерпретаций — худший исход из возможных.
  • Параметры отдельно от текста. См. инъекции.
  • Согласованность проверяется тестом. Свойство «для любого клиента is_satisfied_by и SQL дают одинаковый ответ» — идеальная мишень для property-based тестирования: генерируем случайных клиентов, сохраняем в тестовую базу, сверяем два ответа. Один такой тест закрывает целый класс расхождений.

Тот же приём переводит спецификацию в человеческий текст («вам нужно ещё 2 заказа») — это уже не техническая, а продуктовая ценность: пользователю показывают причину, а не просто «не подходит».


Interpreter: когда правила складываются в маленький язык

Спецификации хватает, пока условия строятся из фиксированного набора листьев. Дальше приходит запрос: «пусть аналитик сам пишет условие в админке». Это переход к Interpreter — единственному паттерну GoF, который в предыдущих главах трека упоминался только в классификации.

Interpreter — паттерн, в котором для простого языка определяется представление грамматики в виде дерева объектов и вычислитель, обходящий это дерево. Формула из GoF: одному правилу грамматики соответствует один класс.

Наивный вычислитель — рекурсивный обход:

def evaluate(node, env: dict[str, float]) -> Any:
    match node:
        case Const(v):
            return v
        case Var(name):
            return env[name]
        case Add(a, b):
            return evaluate(a, env) + evaluate(b, env)
        case Mul(a, b):
            return evaluate(a, env) * evaluate(b, env)
        case Gt(a, b):
            return evaluate(a, env) > evaluate(b, env)
        case AndNode(a, b):
            return evaluate(a, env) and evaluate(b, env)

Стоимость: O(n) по числу узлов на каждое вычисление, где вся работа — это диспетчеризация по типу узла и рекурсивные вызовы. Для правила, применяемого миллион раз в час, накладные расходы дерева начинают доминировать над полезной арифметикой.

Приём, который стоит знать: компиляция дерева в замыкания

Обход дерева повторяется при каждом вызове, хотя структура дерева не меняется. Значит, диспетчеризацию можно выполнить один раз — на этапе подготовки, а не на этапе вычисления. Это частичное вычисление (partial evaluation), и в динамических языках оно даёт кратный выигрыш:

from typing import Callable

Program = Callable[[dict[str, float]], Any]


def compile_expr(node) -> Program:
    """Обходит дерево ОДИН раз и возвращает замыкание, которое считает быстро."""
    match node:
        case Const(v):
            return lambda env, v=v: v
        case Var(name):
            return lambda env, name=name: env[name]
        case Add(a, b):
            fa, fb = compile_expr(a), compile_expr(b)      # диспетчеризация — здесь
            return lambda env: fa(env) + fb(env)           # горячий путь — только вызовы
        case Mul(a, b):
            fa, fb = compile_expr(a), compile_expr(b)
            return lambda env: fa(env) * fb(env)
        case Gt(a, b):
            fa, fb = compile_expr(a), compile_expr(b)
            return lambda env: fa(env) > fb(env)
        case AndNode(a, b):
            fa, fb = compile_expr(a), compile_expr(b)
            return lambda env: fa(env) and fb(env)


rule = compile_expr(parse("amount * 1.2 > limit and score > 700"))
# Дальше миллион вызовов rule(env) не трогают дерево вообще.

Тот же смысл на статически типизированных платформах носят деревья выражений: Expression<Func<T,bool>> в C# компилируется в делегат через expr.Compile(), LINQ-провайдеры переводят его же в SQL — это буквально «две интерпретации одного дерева» из предыдущего раздела, встроенные в платформу.

Где Interpreter перестаёт быть уместным

GoF честно пишут: паттерн применим для простых грамматик. Признаки, что вы вышли за границу:

Признак Что это значит Что делать
Нужны переменные, присваивание, циклы Вы пишете язык программирования Взять готовый: Lua, Starlark, CEL
Нужны понятные сообщения об ошибках с позицией Нужен настоящий парсер и лексер Разбор и парсеры, парсер-комбинаторы из функциональных паттернов
Правила пишет пользователь, а исполняет ваш сервер Появляется модель угроз Песочница, лимиты по времени и памяти, никогда eval
Грамматика растёт каждый спринт Это продукт, а не деталь Проектирование DSL

Отдельно про eval. Соблазн «пусть аналитик пишет питон-выражение, а мы вызовем eval» стоит компании инцидента: выражение исполняется с правами процесса, а __import__("os").system(...) внутри строки — рабочая нагрузка, а не теория. Если нужен пользовательский язык выражений, берите изолированный вычислитель: CEL (Google, используется в Kubernetes и Envoy), Starlark, JsonLogic, OPA/Rego для политик доступа.


Правила как данные: таблица решений

Когда условий много, а структура у них плоская, дерево объектов избыточно — лучше работает таблица решений: строки-правила, столбцы-условия, отдельный столбец с результатом.

# rules/shipping.yaml — правило редактирует логист, а не разработчик.
version: 7
rules:
  - id: free-express-msk
    when: {country: RU, city: Москва, total_gte: 5000, weight_lt_kg: 10}
    then: {method: express, price: 0}
    valid_from: 2026-07-01
  - id: standard-ru
    when: {country: RU, total_gte: 0}
    then: {method: standard, price: 350}
  - id: fallback
    when: {}
    then: {method: pickup, price: 0}
def match_rule(order, rules: list[Rule]) -> Rule:
    """Первое совпадение выигрывает. Порядок — часть смысла таблицы,
    поэтому он фиксируется в файле, а не в порядке обхода словаря."""
    for rule in rules:
        if rule.valid_from and order.created_at < rule.valid_from:
            continue
        if all(cond.matches(order) for cond in rule.conditions):
            # Решение и его причину логируем вместе: без этого разбор
            # обращения «почему мне посчитали 350 рублей» невозможен.
            audit.log(order_id=order.id, rule_id=rule.id, version=RULES_VERSION)
            return rule
    raise NoRuleMatched(order.id)

Два свойства, которые нужно закладывать сразу, иначе через полгода будет больно:

  • Полнота. Последнее правило-заглушка обязано срабатывать всегда. NoRuleMatched в проде — это отказ обслуживания на ровном месте. Проверка на пересечение и полноту правил — статический анализ таблицы, который стоит написать один раз и гонять в CI.
  • Объяснимость. Записывайте не только результат, но и идентификатор сработавшего правила и версию таблицы. Это превращает часовое расследование в один запрос в логи.

Жизненный цикл правила

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

Минимальный набор требований к системе правил, которую пишете сами: схема и валидация; версия у каждой таблицы; неизменяемая история (кто, когда, что поменял); прогон на исторических данных; теневой режим; откат за одну команду; и обязательный аудит применённого правила в каждом решении. Если на всё это нет ресурса — не делайте правила данными, оставьте их в коде: код хотя бы проходит ревью, тесты и умеет откатываться релизом.


Масштаб: когда правил становятся тысячи

Линейный перебор правил при 10 000 правил и 5 000 событий в секунду — это 50 миллионов проверок в секунду, и здесь появляются приёмы посерьёзнее:

  • Индексация по дискриминатору. Большинство правил применимы к узкой группе событий. Группируем правила в словарь по типу события/стране/категории — перебор сокращается на порядки. Это самый дешёвый и самый эффективный шаг.
  • Порядок условий по стоимости и селективности. Дешёвое и отсекающее — первым, вызов внешнего сервиса — последним. Короткое замыкание and делает остальное.
  • RETE — алгоритм из систем продукционных правил (лежит в основе Drools): строит сеть общих подусловий и переиспользует промежуточные результаты между правилами. Оправдан, когда правил тысячи и они сильно пересекаются; цена — память и совсем другой уровень сложности отладки.
  • Кэширование результата для неизменной части входа — см. кэширование.

Типичные ошибки

Ошибка Как выглядит Что делать
Спецификация ради двух условий Пять классов вместо одного if Порог: 3+ комбинируемых условия или вторая интерпретация
Две реализации одного правила Предикат в коде и WHERE в SQL живут отдельно Один источник: дерево + переводчики; property-тест на согласие
Молчаливый case _ Непереводимый лист тихо выпадает из фильтра Явный NotImplementedError
eval пользовательской строки «Мы же доверяем аналитикам» Изолированный вычислитель: CEL, Starlark, JsonLogic
Правила без версии и аудита «Почему клиенту дали скидку?» — нет ответа Версия таблицы + идентификатор правила в каждом решении
Нет правила по умолчанию NoRuleMatched в проде Обязательная заглушка + проверка полноты в CI
Выкатка правила сразу в прод Инцидент вместо эксперимента Теневой режим, прогон на истории
Свой язык вместо готового Полгода пишем парсер и отладчик Взять CEL/Starlark/Lua
Внутренняя платформа Конфиг превратился в недоязык с циклами Признать, что это код, и вернуть его в код

Последний пункт — известный антипаттерн inner-platform effect, частный случай спекулятивной обобщённости: система конфигурации дорастает до кривой копии языка программирования, только без отладчика, тестов и типов.


Как это выглядит в проде

  • Django Q-объекты и SQLAlchemy-выражения — спецификации в чистом виде: композируются через &, |, ~ и компилируются в SQL. Если писали на них — вы уже применяли паттерн.
  • Elasticsearch Query DSL, MongoDB-фильтры — дерево условий как данные, отправляемое по сети.
  • CEL — язык выражений Google: используется в Kubernetes (валидация CRD), Envoy, IAM-политиках; специально спроектирован так, чтобы вычисление всегда завершалось.
  • OPA/Rego — политики доступа как данные, вынесенные из сервисов.
  • Drools / RETE — классический промышленный движок правил в банках и страховании.
  • Фича-флаги — вырожденный случай правил как данных: условие «показывать ли фичу» вынесено из кода в панель.
  • LINQ и Expression<Func<T, bool>> в .NET — платформенная реализация «одно дерево, два вычислителя».

Мини-итог

  • Правило как объект оправдано, когда условие композируется, меняется часто или нужно в двух местах сразу. Для двух стабильных условий if остаётся правильным ответом.
  • Specification = Composite над предикатами. Главная выгода не в композиции, а в интроспекции: дерево можно перевести в SQL, в текст, в объяснение отказа.
  • Одно правило — один источник истины. Две независимые реализации одного условия расходятся всегда; спасает общий разбор дерева плюс тест на согласие интерпретаций.
  • Interpreter уместен для маленькой стабильной грамматики. Если грамматика растёт — берите готовый язык выражений, а не пишите свой.
  • Дерево обходится один раз: компиляция AST в замыкания убирает диспетчеризацию с горячего пути.
  • Правила-данные требуют собственного релизного цикла: схема, версия, прогон на истории, теневой режим, аудит, откат. Без него гибкость оборачивается неуправляемым продом.
  • Никогда не исполняйте пользовательские выражения через eval.

Источники

  • Eric Evans, Martin Fowler, «Specifications», 2002 — martinfowler.com/apsupp/spec.pdf. Первоисточник паттерна.
  • Erich Gamma et al., «Design Patterns», 1994 — глава Interpreter, включая честное ограничение «для простых грамматик».
  • Martin Fowler, «Domain-Specific Languages», 2010 — различие внутренних и внешних DSL, семантическая модель, таблицы решений.
  • Google CEL — github.com/google/cel-spec: язык выражений с гарантией завершаемости.
  • JsonLogic — jsonlogic.com: правила как JSON, исполняемые и на сервере, и в браузере.
  • Open Policy Agent — openpolicyagent.org: политики как данные.
  • Charles Forgy, «Rete: A Fast Algorithm for the Many Pattern/Many Object Pattern Match Problem», Artificial Intelligence, 1982 — dl.acm.org.
  • Microsoft, «Expression Trees» — learn.microsoft.com: как одно дерево компилируется в делегат и в SQL.
  • Python, PEP 636 «Structural Pattern Matching: Tutorial» — peps.python.org.

Что дальше

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

Точки расширения: реестр, SPI, хуки и плагины

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов