Практика: расчёт доставки (Shipping rates)
Способ доставки выбирается правилами из конфига, а не
ifв коде: страна, вес и сумма заказа решают, какая стратегия посчитает цену.
Базовый пример: Стратегия. Там стратегию в Checkout кладёт тест руками. В жизни главный вопрос другой: откуда стратегия берётся и кто решает, какая из них нужна этому заказу.
Задача
Интернет-магазин считает стоимость доставки на кассе. Сначала был один тариф. Потом добавились пункты выдачи для лёгких посылок по России, курьер по России и Беларуси с ценой за килограмм, международная доставка, а маркетинг попросил «бесплатно от 5000». Тарифы и пороги меняются раз в месяц, новые службы доставки появляются раз в квартал.
Если писать это как if country == 'RU' and weight <= 1000, касса превращается в клубок условий, где цена и правило выбора перемешаны. Каждая правка тарифа — релиз.
Что урезано
- Деньги — целые копейки, одна валюта.
- Конфиг — массив в тесте. В проекте это YAML или таблица в БД, но форма та же: упорядоченные правила и запасной способ.
- Нет вызовов API перевозчиков, нет сроков доставки, нет исключений вроде «негабарит».
- Нет DI-контейнера: стратегии регистрируются руками, в проекте это делает контейнер или модуль сборки.
Решение
Два разных вопроса разводятся по двум разным местам.
Сколько стоит — это стратегия. Контракт ShippingMethod получает Parcel (страна, вес, сумма) и возвращает цену. Реализаций три: FlatRate, PerKilogram и FreeOver, которая оборачивает другую стратегию и обнуляет цену от порога суммы.
Какая стратегия нужна — это правила. Rule описывает условие из конфига: код способа, список стран, максимальный вес. ShippingPolicy хранит упорядоченные правила, запасной код и реестр «код → стратегия». Первое подошедшее правило называет код, реестр выдаёт стратегию.
Checkout не знает ни правил, ни тарифов: спросил у политики стратегию, вызвал её.
Структура
Checkout стал ещё тоньше, чем в базовом примере: он не получает стратегию в конструкторе, а получает политику и спрашивает стратегию под каждую посылку. Выбор переехал из вызывающего кода в данные.
Правила из конфига
Конфиг — это список правил в порядке приоритета плюс запасной код:
fallback: international
rules:
- { method: pickup, countries: [RU], max_weight_g: 1000 }
- { method: courier, countries: [RU, BY] }ShippingPolicy::fromConfig превращает его в объекты Rule. Отсутствующее поле означает «без ограничения». Порядок важен: лёгкая посылка по России подходит под оба правила, выигрывает первое.
Реестр заполняется кодом при сборке приложения: register('pickup', new FreeOver(5000, new FlatRate(199))). Код способа — единственное, что связывает конфиг и код. Правило, которое ссылается на незарегистрированный код, падает при первом же запросе с понятной ошибкой, а не возвращает «ноль рублей».
Добавить новую службу доставки — одна строка в конфиге и одна регистрация. Ни Checkout, ни ShippingPolicy, ни существующие стратегии не меняются.
Как это собирается
Политика собирается один раз при старте приложения: конфиг читается из файла, ShippingPolicy::fromConfig превращает его в правила, а модуль сборки регистрирует стратегии по кодам. Дальше Checkout живёт в контейнере, и корзина просто зовёт shippingCost для каждой посылки: лёгкая по России уходит в пункт выдачи, крупный заказ получает бесплатную доставку, Беларусь - курьера, Германия - запасной international.
В конфиге уже есть правило express для Казахстана, а стратегию для него ещё не зарегистрировали. Политика честно падает, а вызывающий код решает, что с этим делать: корзина ловит ошибку, пишет её в лог и показывает способ как недоступный. Остальные посылки считаются как обычно.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Стратегии не знают о правилах, а правила о тарифах: FreeOver берёт порог и «обычную» стратегию, поэтому «бесплатно от 5000» можно прикрутить к любому способу. Вес округляется вверх до начатого килограмма одинаково во всех языках.
| Язык | Особенность |
|---|---|
| PHP | interface ShippingMethod, Rule с nullable-полями, fromConfig с array-shape для PHPStan |
| Go | Стратегия — type Rate func(Parcel) int; Rule и Config с json-тегами, тест декодирует конфиг из JSON; нулевое значение поля = «без ограничения» |
| Python | Стратегия — callable; Rule.from_config и ShippingPolicy.from_config как @classmethod |
| TypeScript | Тип Rule совпадает с формой конфига, отдельного парсинга нет; стратегии — объектные литералы из фабрик |
Запуск: make test T=patterns/behavioral/strategy/practice
Что проверяют тесты
Тесты одинаковы во всех языках. Таблица из пяти посылок проверяет сами правила: лёгкая по России — пункт выдачи, тяжёлая — курьер за три начатых килограмма, Беларусь — курьер, потому что пунктов выдачи там нет, Германия — запасной способ, крупный заказ — бесплатно. Второй тест добавляет доставку дроном через конфиг и регистрацию и убеждается, что остальной код не трогали. Третий — правило с незарегистрированным кодом падает сразу.
Где граница
- Условия в правилах усложняются («кроме островов», «только в будни», «если в корзине есть габаритный товар») — правила становятся объектами-спецификациями, см. Specification, или уезжают в движок правил.
- Способ доставки должен знать о других способах (сравнить, выбрать дешёвый) — это уже не одна стратегия, а их сравнение; политика может вернуть список.
- Стратегий две и новые не планируются — таблица правил избыточна, хватит
if. - Порядок правил в конфиге начал решать слишком многое — это сигнал, что критерии выбора надо сделать явными полями, а не полагаться на «первое подошедшее».
Отличия между языками
- PHP —
Ruleс?array/?intи явная проверка=== null;fromConfigдокументирован array-shape, иначе PHPStan не пропустит обращения к ключам. - Go — конфиг декодируется стандартным
encoding/jsonпрямо в структуры с тегами, поэтому отдельного «парсера» нет; «нет ограничения» выражено нулевым значением, и это надо помнить при чтенииMatches. - Python —
from_configкакclassmethod,Selfв сигнатурах; стратегии — замыкания, как в базовом примере. - TypeScript — тип правила и есть схема конфига, поэтому JSON-конфиг подставляется без преобразований; за счёт структурной типизации стратегии — объектные литералы, класс не нужен.