Практика: слои над медленным отчётом (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) получает источник, список кодов и реестр фабрик "код -> слой". Первый код в списке - внешний слой. Незнакомый код останавливает сборку сразу, при старте, а не на первом запросе пользователя.
Структура
Порядок из конфига
Каждый слой видит то, что дошло до него снизу, и отдаёт наверх своё:
- Кэш снаружи лимита (
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) - функции в конструкторе, поэтому тесты не ждут и не перехватывают вывод.
| Язык | Особенность |
|---|---|
| PHP | final readonly классы над interface ReportSource; реестр - array<string, Closure(ReportSource): ReportSource> |
| Go | Структуры с полем Inner, Rows возвращает ([]Row, error); Build идёт по slices.Backward, ошибка - сентинел ErrUnknownLayer; лимит режет rows[:n:n] |
| Python | Protocol вместо интерфейса, слои - 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.
Связанные темы
- Декоратор (базовый пример)
- Практика: клиент курсов валют - ретраи, кэш и журнал над внешним API
- Практика Стратегии: расчёт доставки - тоже реестр "код из конфига -> объект", но выбирается одна стратегия, а не набор слоёв