handbookНаписать

patterns/behavioral/mediator/practice/README.md · практика · 4 языка · 39 тестов

база: Посредник

Практика: форма оформления заказа (Checkout form) ​

Поля формы зависят друг от друга: доставка прячет и показывает адрес, страна меняет список способов, промокод пересчитывает итог. Посредник CheckoutForm собирает эти правила в одном месте и не даёт уведомлениям ходить по кругу.

Базовый пример: Посредник. Там диспетчерская только отвечает на события и сама новых не порождает. Здесь посредник меняет поля, поля сообщают ему об изменении, и без защиты одно действие пользователя запускает цепочку повторных пересчётов.

Задача ​

Форма оформления заказа начиналась с трёх полей. Потом добавили самовывоз: при выборе пункта выдачи адрес не нужен, нужен номер пункта. Потом Казахстан, где самовывоза нет. Потом промокоды. Каждое правило дописывали в обработчик того поля, которое «вызывает» изменение: обработчик доставки прятал адрес и пересчитывал итог, обработчик страны сбрасывал доставку и тоже пересчитывал итог, обработчик промокода пересчитывал итог и проверял кнопку. Через полгода правило «когда активна кнопка» жило в пяти местах, и в двух из них забыли про промокод.

Хуже было с циклами: сброс доставки при смене страны вызывал обработчик доставки, тот пересчитывал итог, итог дёргал проверку кнопки, и всё это до того, как обработчик страны закончил свою работу. Форма мигала и иногда показывала итог по старому способу доставки.

Что урезано ​

  • Нет интерфейса: поле — простой объект со значением, видимостью, доступностью и списком вариантов. Тест играет роль пользователя и вызывает set.
  • Справочники (страны, цены доставки, промокоды) — константы в коде, а не ответ API.
  • Деньги — целые копейки, итог выводится строкой, как в поле интерфейса. Нет валют, налогов и округления скидки сверх intdiv.
  • Валидация сводится к «поле заполнено» и «промокод известен», без текстов ошибок под полями.

Решение ​

Контракт FormMediator — одна операция changed(field). Это всё, что знает поле: кому сообщить, что его значение изменилось. Поле не знает, какие ещё поля есть в форме и что от него зависит.

Посредник CheckoutForm создаёт поля, отдаёт им себя и на каждое уведомление выполняет один проход правил в фиксированном порядке: страна -> варианты доставки, доставка -> видимость адреса и пункта выдачи, доставка и промокод -> итог, всё вместе -> доступность кнопки. Порядок проходов и есть граф зависимостей полей, записанный в одном месте.

Структура ​

changed

7

«interface»

FormMediator

+changed(Field)

Field

+name: string

+value: string

+visible: bool

+enabled: bool

+options: string[]

+set(value)

CheckoutForm

+country: Field

+delivery: Field

+address: Field

+pickupPoint: Field

+promo: Field

+total: Field

+submit: Field

+passes: int

+field(name) : Field

+changed(Field)

Поле Field — коллега. Значение меняется только через set, а set молчит, если значение не изменилось: это первая защита от цикла. Видимость, доступность и варианты — обычные свойства без уведомлений: их выставляет только посредник, и пользователь их не меняет.

Правила и защита от цикла ​

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

Посредник сам вызывает set у полей: сбрасывает доставку и записывает итог. Каждый такой set снова приходит в changed. Вторая защита — флаг applying: пока идёт проход, уведомления игнорируются, потому что проход и так дойдёт до всех зависимых правил. Флаг снимается в finally, чтобы исключение в правиле не заморозило форму. Счётчик passes нужен тестам: одно изменение пользователя — ровно один проход.

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

CheckoutForm создаёт контроллер страницы оформления: сумма корзины приходит с сервера, форма сама создаёт поля и отдаёт им себя. Дальше обработчик событий браузера получает пары "имя поля, значение", находит поле через field(name) и зовёт set. Всё остальное делает посредник: пересчитывает итог, показывает адрес или пункт выдачи, включает и выключает кнопку. После каждого события страница перерисовывается по состоянию полей.

В примере пять событий. Курьер без адреса даёт выключенную кнопку, адрес её включает, промокод меняет итог, переход на самовывоз прячет адрес и снова выключает кнопку до выбора пункта. Событие coupon присылает старая закэшированная версия страницы: такого поля нет, field бросает ошибку, и обработчик пропускает событие, не ломая форму.

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

Реализации ​

ЯзыкОсобенность
PHPpublic private(set) string $value (PHP 8.4): значение читается снаружи, меняется только через set; справочники — private const array
GoЗначение — неэкспортируемое поле с методом Value(); флаг снимается в defer; Field(name) возвращает ошибку с сентинелом
Pythonvalue — 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курьер, адрес, SAVE10courierвиден / скрыт9500да
unknown promoкурьер, адрес, FREEcourierвиден / скрыт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.

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

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