Практика: гость, лояльность и аналитика (Shop profile)
Гость вместо
null-пользователя, отсутствующая программа лояльности и выключенная аналитика как нулевые объекты, которые выбираются в одном месте, и ненайденный заказ, который нулевым объектом быть не должен.
Базовый пример: Нулевой объект. Там нулевой журнал подставляется по умолчанию, и вопроса «а можно ли» не возникает. В жизни важнее две вещи: откуда берётся нулевой объект (кто решает, что перед нами гость) и где он вреден, потому что молча превращает ошибку в «ничего».
Задача
Магазин считает корзину. Покупатель бывает вошедшим и анонимным, вошедший может участвовать в программе лояльности или нет, аналитика на стенде разработки и в тестах выключена. В первой версии $user был null для анонимов, программа лояльности - null для тех, кто в ней не состоит, трекер - null без настройки. В итоге в расчёте корзины, на странице заказов и в оплате одни и те же проверки: if ($user !== null && $user->loyalty !== null), а где-то их забыли, и аноним с пустой сессией ронял страницу.
Потом кто-то решил «заодно» убрать проверку и в оплате: пусть ненайденный заказ возвращает пустой заказ на 0 рублей. Оплата по устаревшей ссылке начала «успешно» проходить, аналитика записала оплату, которой не было.
Что урезано
- Пользователи и заказы - словари в памяти. В проекте это репозитории над БД, но форма та же: «найти по ключу».
- Сессия - два аргумента: id пользователя (может отсутствовать) и id сессии. Нет авторизации, куки и истечения сессии.
- Деньги - целые копейки, одна валюта; скидка и бонус - целые проценты, округление вниз.
- Корзина не хранится: от неё остался только ключ, под которым она лежала бы (
user:<id>илиsession:<id>).
Решение
Каждый вид «отсутствия» получил свой нулевой объект с тем же контрактом, что и настоящий:
Guestвместоnull-покупателя: скидки нет, история пустая, корзина живёт под ключом сессии, программа лояльности - нулевая.NoLoyaltyвместоnull-программы: бонус всегда 0.NullTrackerвместоnull-трекера: события уходят в никуда. В Go его роль играетnil-указатель*MemoryTracker.
Решение «перед нами гость» принимается один раз, в Customers.current, на границе между сессией и предметной областью. Это единственная проверка на отсутствие пользователя во всём коде. Storefront дальше её не повторяет.
Заказ нулевого объекта не получил. Orders отдаёт его двумя способами: find возвращает Order или «ничего» (?Order, Order | None, (Order, bool)) для мест, где отсутствие - нормальная ветка, а get бросает OrderNotFound для мест, где отсутствие - ошибка.
Структура
Storefront получает покупателя, трекер и заказы и ни разу не спрашивает, настоящие ли они. В quote скидка, бонус и ключ корзины берутся у покупателя, событие уходит трекеру, и путь один для всех. В pay всё наоборот: orders.get бросает исключение, и событие «оплачено» до трекера не доходит.
Откуда берётся нулевой объект
Customers.current превращает сессию в покупателя: нет id пользователя - гость, есть, но аккаунт удалён - тоже гость, иначе - участник. Остальной код получает Customer и работает с ним одинаково. Если проверку на null нельзя убрать совсем, её нужно собрать в одно место на границе, и нулевой объект - то, что эта граница возвращает.
Orders показывает, где граница проходит иначе. find - это Optional: отсутствие видно в типе, и вызывающий обязан решить, что делать (страница заказа покажет «не найден»). get для оплаты: отсутствие - ошибка, с текстом, по которому видно, какой заказ искали. Пустой заказ на 0 рублей был бы нулевым объектом и сделал бы ровно то, что описано в «Задаче»: оплата прошла, хотя платить было нечего.
В Go есть третий вариант - метод на nil-указателе. Track и Events у *MemoryTracker сначала проверяют t == nil, поэтому var disabled *MemoryTracker - рабочий выключенный трекер без отдельного типа. Так же Member с пустым полем Program отвечает NoLoyalty{}: нулевое значение структуры становится нулевым объектом.
Как это собирается
Customers, Orders и общий трекер аналитики создаются при старте приложения, а Storefront собирается на каждый запрос. Из запроса приходят три вещи: id пользователя из сессии, id сессии и согласие на аналитику из cookie. Customers.current превращает первые две в покупателя, согласие выбирает трекер: настоящий или NullTracker (в Go - nil-указатель *MemoryTracker). Дальше quote считается одинаково для всех: ann получает скидку и бонус, гость и zoe с удалённым аккаунтом - ноль и корзину в сессии.
Оплата показывает обратную сторону. Заказа A-9 нет, pay бросает OrderNotFound, и обработчик отвечает 404, а событие "оплачено" в аналитику не уходит. В итоговом списке событий нет ни гостя без согласия, ни несуществующего заказа.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Настоящие объекты отличаются от нулевых только ответами: Member хранит скидку, историю и программу, PointsProgram начисляет процент, MemoryTracker копит события для теста. Нулевые отвечают нейтрально и состояния не держат, кроме id сессии у гостя: корзина гостя не пропадает, у неё другой ключ.
| Язык | Особенность |
|---|---|
| PHP | Свойства в интерфейсе (PHP 8.4): public string $cartKey { get; }; Guest отвечает хуками get => 0, Member - обычными readonly-полями |
| Go | Выключенный трекер - nil-указатель *MemoryTracker; Find возвращает (Order, bool), Get - ошибку с сентинелом ErrOrderNotFound |
| Python | Protocol со свойствами; у Guest нейтральные поля с init=False, их нельзя переопределить при создании |
| TypeScript | noLoyalty и nullTracker - объектные литералы; find возвращает Order | undefined, компилятор заставит проверить |
Запуск: make test T=patterns/enterprise/null-object/practice
Что проверяют тесты
Тесты одинаковы во всех языках, корзина везде на 2000 копеек.
- Таблица расчёта: участник программы со скидкой 10% получает 1800 и бонус 90 (5% от 1800), участник без программы - 2000 и 0, гость - 2000 и 0 с ключом корзины
session:s-1. Один и тот жеquoteбез единой ветки. - Таблица сессий: вошедший - участник, аноним и удалённый аккаунт - гость. Это проверка единственного места, где решается «гость или нет».
- История гостя пустая, и
recentOrdersвозвращает пустой список без проверок. - Выключенная аналитика не меняет ни расчёт, ни оплату, а включённая записывает
quote user:annиpaid A-2. В Go выключенный трекер -nil-указатель, и у него тоже можно спросить события. - Ненайденный заказ:
findчестно говорит «нет»,payпадает сOrderNotFoundи текстомorder 'B-7' not found, а трекер не получил ни одного события. Это тест на границу: нулевой объект здесь спрятал бы ошибку.
Где граница
- Отсутствие - ошибка (ненайденный заказ при оплате, неизвестный товар в корзине, неуказанный адрес доставки) - исключение или
error, а не нулевой объект. - Отсутствие - нормальная ветка, которую вызывающий должен обработать по-своему (страница «заказ не найден», кнопка «Войти»), -
Optional/T | None/(T, bool): тип заставляет вспомнить о ветке, а нулевой объект её прячет. - Клиент начинает спрашивать
isGuest()в десятке мест - нулевой объект перестал работать, поведение надо перенести в самCustomer(метод «что показать в шапке») или признать, что гость - отдельный сценарий. - Нейтрального ответа нет («курс валют для гостя», «адрес по умолчанию») - нулевой объект выдумает данные, лучше явная ошибка.
- В Go метод на
nil-указателе работает только тогда, когда каждый метод типа к этому готов. Забыли проверку в одном - паника. Иnil-указатель в интерфейсе не равенnil-интерфейсу:NewStorefront(orders, nil)упадёт на первом событии.
Отличия между языками
- PHP -
?->сокращает проверку, но не убирает её; свойства в интерфейсе (8.4) позволяютGuestотвечать хуками без полей и конструктора для каждого ответа.?Orderвfindи?? throwвget- Optional и ошибка рядом, в одну строку. - Go - нулевые значения уже наполовину нулевые объекты: пустой срез, пустая строка,
nil-map для чтения. Приём доводит это до методов:nil-приёмник и поле интерфейса, которое приnilотвечаетNoLoyalty{}. Optional в Go - идиома «comma ok»(Order, bool). - Python -
Order | Noneи есть Optional, аProtocolпозволяет гостю быть обычным dataclass без наследования. Неизменяемость (frozen=True,tupleдля истории) нужна, чтобы нейтральный ответ никто не испортил: в изменяемую пустую историю кто-нибудь однажды добавил бы заказ. - TypeScript - при
strictNullChecksOrder | undefined- Optional, проверенный компилятором;?.в клиентах - лёгкий путь, но та же проверка в каждом месте. Нулевые объекты - литералы, структурная типизация не требует классов.
Связанные темы
- Нулевой объект (базовый пример)
- Стратегия (Strategy) -
NoDiscountтам тоже нулевой объект, а практика о выборе стратегии по правилам - Состояние (State) - когда поведение гостя и участника расходится сильнее нейтральных ответов, это уже разные состояния