Практика: форма оформления заказа (Checkout form)
Поля формы зависят друг от друга: доставка прячет и показывает адрес, страна меняет список способов, промокод пересчитывает итог. Посредник
CheckoutFormсобирает эти правила в одном месте и не даёт уведомлениям ходить по кругу.
Базовый пример: Посредник. Там диспетчерская только отвечает на события и сама новых не порождает. Здесь посредник меняет поля, поля сообщают ему об изменении, и без защиты одно действие пользователя запускает цепочку повторных пересчётов.
Задача
Форма оформления заказа начиналась с трёх полей. Потом добавили самовывоз: при выборе пункта выдачи адрес не нужен, нужен номер пункта. Потом Казахстан, где самовывоза нет. Потом промокоды. Каждое правило дописывали в обработчик того поля, которое «вызывает» изменение: обработчик доставки прятал адрес и пересчитывал итог, обработчик страны сбрасывал доставку и тоже пересчитывал итог, обработчик промокода пересчитывал итог и проверял кнопку. Через полгода правило «когда активна кнопка» жило в пяти местах, и в двух из них забыли про промокод.
Хуже было с циклами: сброс доставки при смене страны вызывал обработчик доставки, тот пересчитывал итог, итог дёргал проверку кнопки, и всё это до того, как обработчик страны закончил свою работу. Форма мигала и иногда показывала итог по старому способу доставки.
Что урезано
- Нет интерфейса: поле — простой объект со значением, видимостью, доступностью и списком вариантов. Тест играет роль пользователя и вызывает
set. - Справочники (страны, цены доставки, промокоды) — константы в коде, а не ответ API.
- Деньги — целые копейки, итог выводится строкой, как в поле интерфейса. Нет валют, налогов и округления скидки сверх
intdiv. - Валидация сводится к «поле заполнено» и «промокод известен», без текстов ошибок под полями.
Решение
Контракт FormMediator — одна операция changed(field). Это всё, что знает поле: кому сообщить, что его значение изменилось. Поле не знает, какие ещё поля есть в форме и что от него зависит.
Посредник CheckoutForm создаёт поля, отдаёт им себя и на каждое уведомление выполняет один проход правил в фиксированном порядке: страна -> варианты доставки, доставка -> видимость адреса и пункта выдачи, доставка и промокод -> итог, всё вместе -> доступность кнопки. Порядок проходов и есть граф зависимостей полей, записанный в одном месте.
Структура
Поле Field — коллега. Значение меняется только через set, а set молчит, если значение не изменилось: это первая защита от цикла. Видимость, доступность и варианты — обычные свойства без уведомлений: их выставляет только посредник, и пользователь их не меняет.
Правила и защита от цикла
Правила — четыре закрытых метода посредника. refreshDeliveryOptions берёт варианты по стране и сбрасывает доставку, если выбранного варианта больше нет. refreshVisibility показывает адрес для курьера и пункт выдачи для самовывоза. refreshTotal считает итог: подытог минус скидка по промокоду плюс доставка. isValid решает, доступна ли кнопка: способ выбран, нужное место доставки заполнено, промокод пустой или известный.
Посредник сам вызывает set у полей: сбрасывает доставку и записывает итог. Каждый такой set снова приходит в changed. Вторая защита — флаг applying: пока идёт проход, уведомления игнорируются, потому что проход и так дойдёт до всех зависимых правил. Флаг снимается в finally, чтобы исключение в правиле не заморозило форму. Счётчик passes нужен тестам: одно изменение пользователя — ровно один проход.
Как это собирается
CheckoutForm создаёт контроллер страницы оформления: сумма корзины приходит с сервера, форма сама создаёт поля и отдаёт им себя. Дальше обработчик событий браузера получает пары "имя поля, значение", находит поле через field(name) и зовёт set. Всё остальное делает посредник: пересчитывает итог, показывает адрес или пункт выдачи, включает и выключает кнопку. После каждого события страница перерисовывается по состоянию полей.
В примере пять событий. Курьер без адреса даёт выключенную кнопку, адрес её включает, промокод меняет итог, переход на самовывоз прячет адрес и снова выключает кнопку до выбора пункта. Событие coupon присылает старая закэшированная версия страницы: такого поля нет, field бросает ошибку, и обработчик пропускает событие, не ломая форму.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
| Язык | Особенность |
|---|---|
| PHP | public private(set) string $value (PHP 8.4): значение читается снаружи, меняется только через set; справочники — private const array |
| Go | Значение — неэкспортируемое поле с методом Value(); флаг снимается в defer; Field(name) возвращает ошибку с сентинелом |
| Python | value — property без сеттера; FormMediator — Protocol; флаг снимается в finally |
| TypeScript | Закрытые поля #value, #applying, правила — приватные методы #refresh...; поля формы создаются инициализаторами свойств |
Запуск: make test T=patterns/behavioral/mediator/practice
Что проверяют тесты
Тесты одинаковы во всех языках. Главная таблица — сценарии: пользователь по шагам заполняет поля по имени, корзина на 10000 копеек, курьер стоит 500, промокод SAVE10 даёт 10%. После шагов проверяется всё, что видит пользователь: способ доставки, видимость адреса и пункта выдачи, итог и кнопка.
| Сценарий | Шаги | Доставка | Адрес / пункт | Итог | Кнопка |
|---|---|---|---|---|---|
| courier needs an address | курьер | courier | виден / скрыт | 10500 | нет |
| courier with address | курьер, адрес | courier | виден / скрыт | 10500 | да |
| pickup point | самовывоз, пункт | pickup | скрыт / виден | 10000 | да |
| promo code | курьер, адрес, SAVE10 | courier | виден / скрыт | 9500 | да |
| unknown promo | курьер, адрес, FREE | courier | виден / скрыт | 10500 | нет |
| country without pickup | самовывоз, пункт, страна KZ | пусто | скрыт / скрыт | 10000 | нет |
Последний сценарий — каскад: смена страны сбрасывает доставку, сброс прячет пункт выдачи, итог теряет стоимость доставки, кнопка гаснет. Ни одно поле не знает о другом, всё сделал один проход посредника.
Отдельные тесты: варианты доставки следуют за страной (KZ — только курьер, неизвестная страна — пустой список и поле недоступно); одно изменение страны с каскадным сбросом даёт ровно один проход правил, а повторный set того же значения — ни одного; неизвестное имя поля падает с текстом unknown field: zip.
Где граница
- Полей становится полсотни, правила пишут не программисты — посредник с методами превращается в движок правил: зависимости описываются данными («поле A видимо, если B = x»), а порядок пересчёта строится по графу.
- Пересчёт всего на каждое изменение дорог (запросы к API за ценой доставки) — нужен разбор по отправителю и пересчёт только зависимого, а флаг
applyingдополняется очередью отложенных уведомлений. - Правила асинхронные (адрес проверяется сервисом) — простой флаг уже не спасает: нужны отмена устаревших запросов и версия состояния формы.
- Формы в интерфейсных фреймворках обычно решают это реактивностью: итог и видимость объявляются как вычисляемые значения от других полей, и фреймворк сам следит за порядком и циклами. Ручной посредник оправдан там, где такого механизма нет или правила должны жить вне интерфейса (на сервере, в тестах).
- Два поля и одно правило — посредник избыточен, хватит обработчика на поле.
Отличия между языками
- PHP — асимметричная видимость
public private(set)убирает геттер:$field->valueчитается, но присвоить можно только внутри класса. Поля формы —public readonly: ссылку не подменить, а состояние поля менять можно. - Go — исключений нет, поэтому флаг снимается в
defer, и это работает и при панике. Поиск поля по имени возвращает(*Field, error): опечатка в имени не должна статьnil-указателем дальше по коду. - Python —
propertyбез сеттера защищаетvalueот случайного присваивания, но не от намеренного_value: в Python это соглашение, а не запрет. Аннотация"Field"вProtocolв кавычках, потому что класс объявлен ниже. - TypeScript —
#-поля закрыты в рантайме. Поля формы созданы инициализаторами свойств, и порядок важен:#fieldsобъявлен первым, иначе#addположит поле в ещё не созданныйMap.
Связанные темы
- Посредник (базовый пример)
- Фасад: практика — тоже центральный объект в оформлении заказа, но подсистемы фасада о нём не знают, а поля формы сами сообщают посреднику.