handbookНаписать

patterns/structural/proxy/practice/README.md · практика · 4 языка · 23 теста

база: Заместитель

Практика: кэш репозитория товаров (Caching proxy) ​

Кэширующий заместитель перед медленным репозиторием: ответы из памяти с TTL, сброс при записи и честная граница — запись мимо заместителя кэш не видит.

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

Задача ​

Карточка товара в магазине читает цену и название из репозитория, а репозиторий ходит в базу, которая под нагрузкой отвечает медленно. Одну и ту же карточку открывают сотни раз в минуту, цены меняются несколько раз в день. Первая версия кэшировала прямо в контроллере: словарь sku -> товар без срока жизни. После смены цены покупатели до перезапуска видели старую, а тест «кэш истекает через минуту» делал sleep(60) и его отключили.

Нужно, чтобы кэш был отдельно от кода, который читает товары, и чтобы его поведение проверялось за миллисекунды.

Что урезано ​

  • Медленный репозиторий — словарь в памяти со счётчиком чтений; в проекте это SQL-запрос или HTTP-вызов каталога.
  • Кэш — словарь в памяти процесса без ограничения размера и вытеснения; в проекте это LRU, Redis или APCu.
  • Один процесс: инвалидация не рассылается другим экземплярам приложения.
  • Нет защиты от одновременных промахов (stampede): два параллельных первых чтения оба пойдут в базу.

Решение ​

Интерфейс ProductRepository с методами find и save знают все, кто работает с товарами. Медленный репозиторий SlowProductRepository его реализует и считает обращения в reads, чтобы тесты видели, дошёл ли вызов до базы.

Заместитель CachedProductRepository реализует тот же интерфейс. find смотрит в свой словарь: запись есть и не истекла — попадание, иначе промах, чтение из настоящего репозитория и новая запись со сроком now + ttl. save передаёт запись дальше и удаляет запись кэша по этому sku: следующее чтение пойдёт в базу и увидит новую цену. Счётчики hits и misses — то, что в проекте ушло бы в метрики.

Время заместитель берёт не из системы, а из часов Clock, переданных в конструктор. В бою это системные часы, в тестах — подделка, которую двигают вручную.

Структура ​

inner

«interface»

ProductRepository

+find(sku) : Product

+save(product)

Product

+sku: string

+name: string

+priceCents: int

SlowProductRepository

+reads: int

+find(sku) : Product

+save(product)

CachedProductRepository

+hits: int

+misses: int

-inner: ProductRepository

-clock: Clock

-ttl

-entries: map

+find(sku) : Product

+save(product)

«interface»

Clock

+now() : time

Контракт не изменился по сравнению с репозиторием без кэша: Product неизменяем, а «товара нет» — отдельная ошибка ProductNotFound. Неизменяемость здесь важна: кэш отдаёт один и тот же объект многим читателям, и никто не может поменять цену в чужой карточке.

Часы вместо sleep ​

TTL — правило про время, и проверять его надо, управляя временем, а не ожидая его. Заместитель не вызывает time() или Date.now() сам: он спрашивает часы, полученные в конструкторе. Тест создаёт часы на фиксированном моменте, читает товар, сдвигает часы на 59 секунд — попадание, на 60 — промах. Граница проверяется точно, и тест идёт миллисекунды.

Истечение — строгое сравнение now < expiresAt: запись, прожившая ровно TTL, уже устарела. Это решение, и тест его фиксирует, чтобы оно не поменялось случайно при правке < на <=.

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

Заместителя подключает DI-контейнер: интерфейс ProductRepository связан с CachedProductRepository, внутри которого медленный репозиторий и системные часы, срок жизни записи - минута. Ни карточка товара, ни админка не знают, что за интерфейсом кэш: обе получают репозиторий в конструкторе и зовут find и save. В тестах часы подменяются, в примере стоят настоящие: весь сценарий укладывается в миллисекунды, до истечения записи дело не доходит.

Три просмотра карточки дают одно чтение из базы. Админка меняет цену через тот же репозиторий, save сбрасывает запись, и следующий просмотр уже видит новую цену. Несуществующий товар превращается в 404 в контроллере: ProductNotFound проходит сквозь заместителя, а промах для него не кэшируется. Счётчики в последней строке - то, что в проекте ушло бы в метрики.

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

Реализации ​

Заместитель кэширует только успешные ответы: если товара нет, ошибка проходит насквозь и ничего не записывается, поэтому товар, добавленный через секунду, сразу станет виден. Кэширование отказов (negative caching) — отдельное решение со своим TTL, его тут нет.

ЯзыкОсобенность
PHPЧасы — свой интерфейс Clock с DateTimeImmutable (в проекте — PSR-20 ClockInterface); запись кэша — массив-форма с типом в PHPDoc
GoЧасы — функциональный тип Clock func() time.Time, в тест передаётся метод-значение clock.Now; TTL — time.Duration, сравнение now.Before(expiresAt)
PythonЧасы — Callable[[], float] с умолчанием time.monotonic: монотонные часы не прыгают при переводе системного времени
TypeScriptЧасы — () => number с умолчанием Date.now; в тесте замыкание над переменной now, без фейковых таймеров

Запуск: make test T=patterns/structural/proxy/practice

Что проверяют тесты ​

Тесты одинаковы во всех языках: товар KTL-1 за 2500, TTL 60 секунд. Повторное чтение через 59 секунд — попадание: одно чтение из базы, одно попадание, один промах. Через ровно 60 секунд запись истекла: два чтения, ноль попаданий. Запись новой цены 2990 через заместителя сбрасывает кэш, и следующее чтение возвращает 2990. Главный тест — граница: цену 2990 записали прямо в медленный репозиторий, и заместитель до истечения TTL продолжает отдавать 2500, а после — 2990. Последний тест: отсутствующий товар не кэшируется, два чтения — два обращения к базе.

Где граница ​

  • Запись мимо заместителя. Кэш знает только о записях, которые прошли через него. Импорт цен из 1С, миграция, админка в другом сервисе пишут в базу напрямую — и до истечения TTL покупатели видят старую цену. Тест это фиксирует. TTL — страховка, а не решение: когда пишущих несколько, инвалидацию переносят в событие («цена изменилась»), на которое подписан кэш, или в саму базу (CDC).
  • Несколько процессов. Кэш в памяти у каждого экземпляра свой, и save сбрасывает только локальный. Нужен общий кэш (Redis) или рассылка инвалидаций.
  • Нагрузка. Без ограничения размера словарь растёт с каталогом; без защиты от одновременных промахов истечение популярной записи шлёт в базу пачку одинаковых запросов. Это задачи готовой библиотеки кэша, и заместитель тогда остаётся тонкой обёрткой над ней.
  • Избыточность. Если база отвечает быстро или данные читают редко, кэш добавляет только устаревание и сложность.

Отличия между языками ​

  • PHP — процесс живёт один запрос, и кэш в памяти заместителя живёт столько же. Тот же заместитель с кэшем во внешнем хранилище (APCu, Redis) работает между запросами. Время — DateTimeImmutable, его сравнение < работает напрямую.
  • Go — часы как функция короче интерфейса с одним методом, и в тест передаётся clock.Now — метод, привязанный к экземпляру. Для конкурентного доступа заместителю понадобится sync.Mutex; здесь он опущен, как и в остальных языках.
  • Python — time.monotonic подходит для TTL лучше time.time: перевод часов на сервере не продлит и не оборвёт срок жизни записей. Подделка часов — объект с __call__, протоколу Callable этого достаточно.
  • TypeScript — vi.useFakeTimers() тоже подменил бы Date.now, но глобально и для всего теста. Явные часы в конструкторе видны в сигнатуре и не требуют магии фреймворка.

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

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