Практика: тарифы, загруженные один раз (Loaded once)
Единственность без глобальной точки доступа: справочник тарифов загружается один раз на процесс, потокобезопасно, а клиенты получают его через конструктор и подменяют в тестах.
Базовый пример: Одиночка. Там класс сам делает себя единственным и глобально доступным. В жизни нужна только первая половина: дорогой ресурс создаётся один раз. Вторую половину, точку доступа, заменяет точка сборки приложения.
Задача
Таблица тарифов доставки парсится из файла, парсинг дорогой, таблица нужна расчёту цен и выписке счетов. Первая версия сделала Tariffs::instance(): статический кэш, ленивая загрузка, обращение откуда угодно.
Потом тесты расчёта цен стали зависеть от порядка: первый тест загружал тарифы v1, второй ожидал v2 и падал только при запуске всем набором. Потом понадобился второй набор тарифов для другого склада, и одиночка, по определению один, не смог. А под нагрузкой два потока одновременно прошли проверку «ещё не загружено» и распарсили файл дважды.
Что урезано
- Файл — строки
zone=price, парсинг тривиальный; в проекте это YAML, БД или внешний сервис. - Две зоны, цена за килограмм, округление вверх до начатого килограмма.
- Точка сборки — один класс с двумя клиентами; в проекте это контейнер или
main.
Решение
Ресурс Tariffs — неизменяемое значение. Загрузить его дорого, делить дёшево.
Провайдер TariffsProvider отвечает за «один раз»: получает функцию загрузки, вызывает её при первом get() и дальше отдаёт то же значение. Потокобезопасность встроена: sync.Once в Go, блокировка с двойной проверкой в Python, один общий промис в TypeScript. Счётчик загрузок нужен тестам.
Клиенты Pricing и Invoicing получают провайдера в конструкторе. По сигнатуре видно, от чего они зависят, и в тесте вместо файла можно передать таблицу из трёх строк.
Точка сборки App — единственное место, где решено «один провайдер на процесс»: она создаёт его и раздаёт обоим клиентам. Единственность обеспечена конструкцией приложения, а не классом ресурса.
Структура
Сравните с базовой схемой: там одна стрелка Greeter ..> AppConfig.instance(), которой нет ни в одном конструкторе. Здесь все стрелки входят через конструкторы, и их видно.
Загрузка один раз
Ленивая загрузка с защитой от гонки выглядит в каждом языке по-своему, но правило одно: проверка и загрузка должны быть атомарны. Наивное if (loaded === null) loaded = load() под нагрузкой загружает дважды.
- Go:
sync.Onceделает это за вас, результат и ошибка сохраняются рядом. - Python: быстрая проверка без блокировки, затем блокировка и повторная проверка внутри.
- TypeScript: однопоточный, но асинхронный: гонка между
awaitвсё равно есть, поэтому кэшируется сам промис, а не его результат. - PHP: один запрос — один поток, гонки нет; ленивость всё равно нужна, чтобы не читать файл там, где тарифы не понадобятся.
Как это собирается
Точка сборки здесь не одна, а по одной на склад: у Москвы и Петербурга свои файлы тарифов, поэтому создаются два TariffsProvider и два App. Это и есть задача, которую одиночка решить не смог: "один на процесс" превратилось в "один на точку сборки", и ни один класс для этого не менялся. Внутри каждого App расчёт цены и строка счёта делят одного провайдера, а тарифы читаются при первом заказе, а не при старте.
Заказ в зону us, которой нет в тарифах Петербурга, обработчик заказов встречает исключением из rate() и отвечает "зона не обслуживается", остальные заказы считаются дальше. Последняя строка вывода - счётчик загрузок: по одной на склад, сколько бы заказов ни пришло. В TypeScript провайдер асинхронный, поэтому example() возвращает промис.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Точка сборки показывает, куда делась точка доступа: App::boot(file) знает про файл, App раздаёт провайдера, а в тестах App собирается из любого провайдера. Два App в одном процессе — два независимых набора тарифов, что одиночке было недоступно.
| Язык | Особенность |
|---|---|
| PHP | Провайдер с Closure загрузки; boot() падает явно, если файла нет, а не парсит пустую строку |
| Go | sync.Once хранит и значение, и ошибку; счётчик загрузок atomic.Int32; тест с 32 горутинами |
| Python | Блокировка с двойной проверкой; App.wire() и App.boot(Path) |
| TypeScript | Кэшируется промис: 32 параллельных get() ждут одну загрузку |
Запуск: make test T=patterns/creational/singleton/practice
Что проверяют тесты
Тесты одинаковы во всех языках. До первого обращения загрузок ноль, после трёх вызовов из двух клиентов — одна. Два приложения с разными тарифами живут в одном процессе и не мешают друг другу: это то, что сломало бы одиночку. Клиент тестируется с подменённым провайдером без файла. boot() читает файл лениво: если файл удалить до первого расчёта, падает расчёт, а не сборка. В Go, Python и TypeScript отдельный тест запускает 32 одновременных первых обращения и ждёт одну загрузку.
Где граница
- Ресурс нужен в одном месте — провайдер избыточен, загрузите там, где нужно.
- Ресурс должен обновляться без перезапуска — это уже не «один раз», а кэш с инвалидацией; провайдеру нужны
reload()и политика устаревания. - «Один на процесс» на самом деле «один на запрос» или «один на арендатора» — провайдеров становится много, и их создаёт контейнер с нужной областью видимости.
- Если в проекте есть DI-контейнер, провайдер — это его singleton-регистрация, и писать класс не нужно; важно лишь, чтобы клиенты получали зависимость в конструктор.
Отличия между языками
- PHP — гонки нет, но ловушка другая: функции чтения файлов возвращают
falseс предупреждением, и без явной проверки «нет файла» превращается в «пустые тарифы». Проверка стоит две строки и один тест. - Go —
sync.Onceидиоматичен и вместе с ошибкой: если загрузка упала, все последующиеGet()вернут ту же ошибку, повторной попытки не будет. Это надо знать и иногда менять наsync.OnceValuesили свой ретрай. - Python — GIL не делает
if self._loaded is None: self._loaded = load()атомарным, между проверкой и присваиванием поток могут переключить. Блокировка нужна. - TypeScript — кэшировать надо промис: если кэшировать результат после
await, параллельные первые вызовы увидятundefinedи загрузят каждый сам.
Связанные темы
- Одиночка (базовый пример)
- Практика Абстрактной фабрики: платёжные провайдеры — реестр, созданный один раз в точке сборки, вместо статических фабрик