handbookНаписать

patterns/enterprise/specification/practice/README.md · практика · 4 языка · 35 тестов

Практика: аудитория рассылки (Mailing audience) ​

Одно правило отбора клиентов проверяет клиента в памяти и переводится в SQL WHERE с параметрами, так что приложение и база отбирают одних и тех же людей.

Базовый пример: Спецификация. Там правило живёт только в памяти и отбирает список, который уже загружен. В жизни клиентов миллион и они в базе: отбор должен уйти в SQL, а правило при этом не должно раздвоиться на «код» и «запрос».

Задача ​

Маркетинг раз в неделю шлёт письмо с подборкой. Аудитория: клиент дал согласие на рассылку, покупал за последние 90 дней, потратил у нас от 5000 рублей или живёт в Казани (там открылся новый магазин), и он не из Москвы — у московских своя рассылка. Правило меняется почти каждую кампанию: то порог суммы, то город, то срок активности.

Сначала было два кода. Выгрузка аудитории — SQL-запрос в репозитории. Проверка перед отправкой (клиент мог отозвать согласие, пока письмо стояло в очереди) — if в обработчике очереди. Когда порог подняли до 5000, запрос поправили, а if нет, и часть писем ушла людям, которых в аудитории уже не было. Вторая беда — дата: «90 дней» считали от NOW() в базе и от часов сервера в коде, и на границе суток результаты расходились.

Что урезано ​

  • Запрос не выполняется: перевод возвращает строку условия и список параметров. В проекте они ушли бы в PDO/database/sql вместе с SELECT id FROM customers WHERE ….
  • Одна таблица customers с плоскими колонками, без соединений и диалектов SQL: имена колонок зашиты в переводчик.
  • Даты — дни без времени и часовых поясов; дата кампании передаётся явно, а не берётся из часов.

Решение ​

Specification — тот же один вопрос isSatisfiedBy(customer), что и в базовом примере. Комбинаторы другие: вместо бинарных and/or здесь AllOf и AnyOf от нескольких правил, потому что так правило читается как список условий кампании и так же переводится в SQL — одной строкой через AND, без лишних скобок.

Главное отличие — у правила два исполнителя. Метод isSatisfiedBy проверяет клиента в памяти. Переводчик toSql обходит то же дерево и строит SqlWhere: условие с плейсхолдерами ? и параметры в их порядке. Правило не знает про SQL, переводчик не знает про кампанию: он знает только, как выглядит каждое правило в колонках таблицы.

Структура ​

builds

walks

returns

checks

Customer

+id: int

+lastPurchaseOn: date

+totalSpentCents: int

+city: string

+marketingConsent: bool

«interface»

Specification

+isSatisfiedBy(customer) : bool

ActiveSince

+cutoff: date

MinTotalSpent

+cents: int

InCity

+city: string

HasMarketingConsent

Custom

+label: string

-predicate

AllOf

+rules: Specification[]

AnyOf

+rules: Specification[]

Not

+inner: Specification

MailingAudience

+on(asOf) : Specification

SqlTranslator

+toSql(rule) : SqlWhere

SqlWhere

+sql: string

+params: list

Правила хранят параметры открыто, потому что их читают двое: свой isSatisfiedBy и переводчик. ActiveSince считает границу cutoff один раз в конструкторе от переданной даты кампании, поэтому память и SQL сравнивают с одной и той же датой, а не каждый со своими часами. Custom — правило-замыкание для разовых условий вроде A/B-группы: в памяти работает, а перевода в SQL у него нет и быть не может.

Аудитория ​

MailingAudience собирает правило кампании из кирпичей: согласие, активность за 90 дней на дату кампании, «от 5000 рублей или Казань», не Москва. Это единственное место, где записаны условия рассылки. Выгрузка передаёт результат в переводчик, очередь отправки — в isSatisfiedBy, и правка порога меняет обе сразу.

Перевод в SQL ​

toSql разбирает правило по типу: простое правило даёт условие на одну колонку и один параметр, AllOf склеивает части через AND, AnyOf — через OR в скобках, Not оборачивает часть в NOT (…). Скобки ставятся только там, где без них изменится смысл: AND связывает сильнее OR, поэтому AllOf внутри AnyOf скобок не требует, а AnyOf внутри AllOf — требует.

Значение никогда не попадает в строку: условие всегда с ?, значение уходит в параметры. Правило, которое переводчик не знает, — ошибка UnsupportedRule с именем правила и подсказкой, что делать, а не молча пропущенное условие: пропуск расширил бы аудиторию рассылки.

Как это собирается ​

Правило кампании собирает еженедельная задача рассылки, и дату кампании она передаёт явно: MailingAudience на 12 октября, а не на часы сервера. Кампаний две: обычная и weekly-b, где к аудитории добавлено правило Custom для A/B-группы. Функция выгрузки export пытается перевести правило в SQL через toSql. У обычной кампании это получается, и WHERE уходит в базу целиком. У weekly-b перевод бросает UnsupportedRule, и вызывающий код не пропускает условие, а делит работу: в базу уходит переводимая часть, Custom доигрывается в памяти на полученных строках. Пропустить правило молча значило бы разослать письмо всей аудитории.

Второй потребитель - обработчик очереди отправки. Пока письма ждали, клиент 1 отозвал согласие. Обработчик заново загружает клиента и спрашивает у того же правила isSatisfiedBy: клиент 1 пропущен, 2 и 4 получают письмо. Путь вызова: задача -> правило -> переводчик -> база, а в очереди: обработчик -> репозиторий -> правило.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

Комбинаторы AllOf и AnyOf принимают первое правило отдельно от остальных: пустая комбинация не собирается вовсе, а значит, переводчику не нужно решать, чем считать пустой AND. Хранят они список правил открыто — по нему идёт переводчик, — а Not держит одно правило.

ЯзыкОсобенность
PHPinterface Specification, правила final readonly с публичными параметрами; переводчик — match (true) с instanceof, AllOf проверяет детей через array_all из PHP 8.4
GoПравила — именованные типы (MinTotalSpent int, InCity string, AllOf []Specification); переводчик — switch rule.(type), ошибка — сентинел ErrUnsupportedRule
PythonProtocol вместо базового класса; переводчик — match с образцами классов (case InCity(city=city)), параметры — кортеж
TypeScriptСтруктурный interface Specification, правила-классы для instanceof; даты — строки YYYY-MM-DD, которые сравниваются как строки

Запуск: make test T=patterns/enterprise/specification/practice

Что проверяют тесты ​

Тесты одинаковы во всех языках и построены на шести клиентах и дате кампании 1 октября 2026. Главная таблица для каждого правила проверяет сразу оба способа: точную строку SQL с параметрами и список клиентов, отобранных в памяти. Так видно, что city = ? с Kazan и отбор [2, 5] — одно и то же правило. Последняя строка таблицы — вся аудитория: пять параметров в порядке плейсхолдеров и клиенты 1, 2 и 6. Клиент 6 стоит ровно на границах — последняя покупка 3 июля (ровно 90 дней) и ровно 5000 рублей — и проходит, потому что обе границы включительные и в памяти, и в SQL.

Второй тест подставляет в город ' OR 1=1 --: строка запроса не меняется, значение уезжает параметром. Третий берёт правило A/B-группы: в памяти оно отбирает чётных клиентов, а перевод в SQL падает с именем правила, даже когда оно спрятано внутри AllOf.

Где граница ​

  • Совпадение памяти и SQL держится на дисциплине: переводчик и isSatisfiedBy написаны отдельно, и тест с таблицей — то, что их связывает. Для каждого нового правила нужна строка в этой таблице.
  • Таблиц и соединений стало больше одной, понадобились сортировка, пагинация, проекции — это уже Query Object или критерии ORM (Doctrine Criteria, SQLAlchemy select, query builder). Спецификация может переводиться в них, но строить SQL руками больше не стоит; для сборки запроса с параметрами см. практику Строителя.
  • Правила пишут маркетологи в админке — нужен язык условий и разбор текста в дерево, как в практике Интерпретатора; дерево спецификаций останется внутри.
  • Отбор только в базе и проверка в памяти не нужна — спецификация избыточна, хватит запроса в репозитории.

Отличия между языками ​

  • PHP — match (true) с instanceof — обычный способ разобрать иерархию без Посетителя. Ветка default бросает исключение, так что новое правило без перевода не пройдёт незамеченным. AllOf(Specification $first, Specification ...$rest) не даёт создать пустую комбинацию: пустой AND дал бы сломанный SQL.
  • Go — правила без структуры: MinTotalSpent — это int, AllOf — срез. Переключатель типов switch rule.(type) и есть переводчик; ошибка из глубины дерева протаскивается через join и проверяется errors.Is.
  • Python — match с образцами классов разбирает правило и сразу достаёт поля; AllOf и AnyOf — обычные классы с __init__(first, *rest), у них нет __match_args__, поэтому поля берутся по имени.
  • TypeScript — интерфейс структурный, поэтому переводчику нужны классы для instanceof; без них пришлось бы вводить поле kind и размеченное объединение. Даты в ISO-формате сравниваются как строки, а граница считается через Date в UTC, чтобы часовой пояс машины не сдвинул день.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS