handbookНаписать

patterns/structural/decorator/practice-reports/README.md · практика · 4 языка · 39 тестов

Практика: слои над медленным отчётом (Report source layers) ​

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

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

Первая практика темы, клиент курсов валют, про ретраи и сбои внешнего API. Эта про данные: слой, который режет результат, и общий кэш между несколькими сборками.

Задача ​

В админке есть отчёт по продажам: запрос к базе собирает выручку по городам за месяц и идёт больше секунды. Дашборд показывает первые три строки, выгрузка в CSV отдаёт все. Сначала кэш, лимит и замер времени жили прямо в методе отчёта, потом разошлись по контроллерам: у дашборда свой лимит, у выгрузки свой кэш, метрика медленных запросов то учитывала попадания в кэш, то нет.

Слои вынесли в декораторы, а их набор для каждого экрана записали в конфиг: dashboard: [log, limit, cache, timing]. Через неделю кто-то поменял местами cache и limit, и выгрузка начала отдавать три строки вместо пяти. Кэш общий (Redis), ключ одинаковый, а в кэш попал уже обрезанный отчёт.

Что урезано ​

  • Базы нет: запрос - функция, которая отдаёт пять строк и двигает фейковые часы на 1200 мс. Так замер времени проверяется без ожидания.
  • Общий кэш - объект CacheStore в памяти. В проекте это Redis, но важно одно: им пользуются несколько сборок с одинаковыми ключами.
  • Конфиг - список кодов слоёв на экран, литералом. Настройки слоёв (TTL, число строк) зашиты в фабрики при сборке, в проекте они тоже приходят из конфига.
  • Нет инвалидации кэша при записи, нет параметров отчёта кроме названия и периода.

Решение ​

Компонент - ReportSource с одним методом rows(query). Запрос ReportQuery - название отчёта и период, из них же собирается ключ кэша. Конкретный компонент DatabaseReport оборачивает медленный запрос и превращает строки базы в Row.

Декораторы:

  • Cached хранит результат в общем CacheStore на 60 секунд.
  • Limited отдаёт не больше трёх строк. Это единственный слой, который меняет данные.
  • Timed замеряет время вызова по внедрённым часам и отдаёт его в record.
  • Logged пишет строку журнала: какой отчёт и сколько строк дошло до этого слоя.

Новая роль по сравнению с базовым примером - сборка по конфигу. ReportStack::build (Build в Go, build в Python и TypeScript) получает источник, список кодов и реестр фабрик "код -> слой". Первый код в списке - внешний слой. Незнакомый код останавливает сборку сразу, при старте, а не на первом запросе пользователя.

Структура ​

wraps

wraps

wraps

wraps

shared

layers by code

«interface»

ReportSource

+rows(query) : Row[]

DatabaseReport

-fetch: fn

Cached

-inner: ReportSource

-store: CacheStore

-ttlMs: int

-now: fn

Limited

-inner: ReportSource

-maxRows: int

Timed

-inner: ReportSource

-now: fn

-record: fn

Logged

-inner: ReportSource

-write: fn

CacheStore

+get(key, now) : Row[]

+put(key, rows, expiresAt)

ReportStack

+build(source, codes, factories) : ReportSource

Порядок из конфига ​

Каждый слой видит то, что дошло до него снизу, и отдаёт наверх своё:

  • Кэш снаружи лимита (cache, limit): в общий кэш попадают три строки. Выгрузка с тем же ключом получает их же. Ошибка не в коде слоёв, а в порядке.
  • Лимит снаружи кэша (limit, cache): в кэше полный отчёт, дашборд режет его для себя, выгрузка получает пять строк. Запрос к базе один на оба экрана.
  • Замер снаружи кэша (timing, cache) учитывает попадания как 0 мс и портит метрику медленных запросов. Внутри кэша замер видит только настоящие запросы к базе.
  • Журнал снаружи лимита пишет, сколько строк увидел пользователь, внутри - сколько отдала база.

Правило: слой, который меняет данные, ставится снаружи общего кэша, иначе его решение достаётся всем, кто читает тот же ключ. Слой, который меряет базу, ставится под кэшем.

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

Сборка идёт при старте приложения: для каждого экрана из reports.yaml берётся список кодов, и ReportStack::build оборачивает один и тот же DatabaseReport. Реестр фабрик создаётся один раз на экран, потому что журналу нужно имя экрана, а кэш у всех один. В примере у виджета в конфиге опечатка cahce: его сборка падает с UnknownLayer, приложение пишет ошибку и не поднимает этот экран, остальные работают.

Дальше дашборд открывает отчёт за октябрь: замер видит 1200 мс запроса к базе, лимит режет до трёх строк, журнал пишет 3 rows. Выгрузка тем же ключом берёт полный отчёт из кэша, запроса к базе нет. Повторный дашборд тоже из кэша. Итог - один запрос к базе на три вызова.

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

Реализации ​

Слои одинаковы по смыслу во всех языках: кэш на 60 секунд, лимит в три строки, запрос к базе стоит 1200 мс фейкового времени. Часы и все выходы слоёв (record, write) - функции в конструкторе, поэтому тесты не ждут и не перехватывают вывод.

ЯзыкОсобенность
PHPfinal readonly классы над interface ReportSource; реестр - array<string, Closure(ReportSource): ReportSource>
GoСтруктуры с полем Inner, Rows возвращает ([]Row, error); Build идёт по slices.Backward, ошибка - сентинел ErrUnknownLayer; лимит режет rows[:n:n]
PythonProtocol вместо интерфейса, слои - frozen dataclass; строки - кортеж, срез не трогает кэш
TypeScriptКлассы с private readonly параметрами; readonly Row[] защищает закэшированный массив, build - reduce по toReversed()

Запуск: make test T=patterns/structural/decorator/practice-reports

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

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

  • Общий кэш: дашборд со слоями из конфига, выгрузка только с cache, оба читают один ключ. cache, limit - выгрузка получает 3 строки при одном запросе к базе; limit, cache - 5 строк при одном запросе; дашборд без кэша - 5 строк, но запросов два. В Go слои дашборда декодируются из JSON, как пришли бы из файла.
  • Что видит слой: два одинаковых вызова на сборку. Замер снаружи кэша пишет 1200 ms и 0 ms, внутри - только 1200 ms; журнал снаружи лимита пишет 3 rows, внутри - 5 rows.
  • TTL: через 59 секунд отчёт ещё из кэша, через 61 - новый запрос.
  • Опечатка в конфиге: сборка падает сразу, ошибка называет код 'cahce'.

Где граница ​

  • Слои, которые меняют данные (лимит, фильтр по правам, маскирование), опасны над общим кэшем. Если таких слоёв много, порядок лучше не доверять конфигу: собрать стек в коде и проверить тестом, а в конфиг вынести только параметры.
  • Если лимит разный для разных пользователей или тарифов, он должен попасть в ключ кэша или стоять снаружи. Это уже не вопрос порядка, а вопрос ключа.
  • Кэш здесь не защищён от одновременных промахов и не сбрасывается при записи в базу. Для этого нужен свой слой с инвалидацией или готовая библиотека кэша.
  • Если экран один и слои не меняются, конфиг и реестр избыточны: стек в три строки в точке сборки читается проще.

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

  • PHP - замыкания в реестре захватывают общие объекты по значению, а массив журнала в примере пишется через одну функцию $write, захватившую его по ссылке: стрелочная функция fn захватывает только по значению.
  • Go - у Rows есть ошибка, и каждый слой решает, что с ней делать: кэш её не запоминает, журнал пишет failed. Срез rows[:n:n] ограничивает ёмкость, чтобы append у вызывающего не переписал закэшированные строки. В CacheStore мьютекс: общий кэш читают из многих горутин.
  • Python - строки возвращаются кортежем: срез в Limited создаёт новый, закэшированный кортеж не меняется. Protocol проверяет, что слои совместимы, без наследования.
  • TypeScript - readonly Row[] запрещает менять закэшированный массив на уровне типов, slice создаёт новый. Реестр - Record<string, Layer>, отсутствующий код даёт undefined, и проверка if (!layer) обязательна при noUncheckedIndexedAccess.

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

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