handbookНаписать

patterns/creational/singleton/practice/README.md · практика · 4 языка · 22 теста

база: Одиночка

Практика: тарифы, загруженные один раз (Loaded once) ​

Единственность без глобальной точки доступа: справочник тарифов загружается один раз на процесс, потокобезопасно, а клиенты получают его через конструктор и подменяют в тестах.

Базовый пример: Одиночка. Там класс сам делает себя единственным и глобально доступным. В жизни нужна только первая половина: дорогой ресурс создаётся один раз. Вторую половину, точку доступа, заменяет точка сборки приложения.

Задача ​

Таблица тарифов доставки парсится из файла, парсинг дорогой, таблица нужна расчёту цен и выписке счетов. Первая версия сделала Tariffs::instance(): статический кэш, ленивая загрузка, обращение откуда угодно.

Потом тесты расчёта цен стали зависеть от порядка: первый тест загружал тарифы v1, второй ожидал v2 и падал только при запуске всем набором. Потом понадобился второй набор тарифов для другого склада, и одиночка, по определению один, не смог. А под нагрузкой два потока одновременно прошли проверку «ещё не загружено» и распарсили файл дважды.

Что урезано ​

  • Файл — строки zone=price, парсинг тривиальный; в проекте это YAML, БД или внешний сервис.
  • Две зоны, цена за килограмм, округление вверх до начатого килограмма.
  • Точка сборки — один класс с двумя клиентами; в проекте это контейнер или main.

Решение ​

Ресурс Tariffs — неизменяемое значение. Загрузить его дорого, делить дёшево.

Провайдер TariffsProvider отвечает за «один раз»: получает функцию загрузки, вызывает её при первом get() и дальше отдаёт то же значение. Потокобезопасность встроена: sync.Once в Go, блокировка с двойной проверкой в Python, один общий промис в TypeScript. Счётчик загрузок нужен тестам.

Клиенты Pricing и Invoicing получают провайдера в конструкторе. По сигнатуре видно, от чего они зависят, и в тесте вместо файла можно передать таблицу из трёх строк.

Точка сборки App — единственное место, где решено «один провайдер на процесс»: она создаёт его и раздаёт обоим клиентам. Единственность обеспечена конструкцией приложения, а не классом ресурса.

Структура ​

loads once

creates one

Tariffs

+perKgCents: Map

+version: string

+rate(zone) : int

TariffsProvider

-load: fn

-loaded: Tariffs

+loads: int

+get() : Tariffs

Pricing

+shippingCost(zone, grams) : int

Invoicing

+line(zone, grams, cost) : string

App

+pricing: Pricing

+invoicing: Invoicing

+boot(file) : 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() падает явно, если файла нет, а не парсит пустую строку
Gosync.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 и загрузят каждый сам.

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

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