Практика: клиент курсов валют (API client layers)
Ретраи, кэш и журнал как слои поверх одного клиента внешнего API: каждый слой простой, а поведение целого определяет порядок, в котором их сложили.
Базовый пример: Декоратор. Там добавки к кофе складываются в любом порядке с одной и той же ценой. Здесь порядок слоёв меняет число запросов к API, содержание журнала и то, переживёт ли клиент сбой.
Задача
Магазин показывает цены в долларах и евро, курс берёт из API банка. API иногда отвечает 503, у него лимит запросов, и курс меняется не чаще раза в минуту. Первая версия обернула вызов прямо в коде клиента: цикл повторов, словарь-кэш, log() на каждой ветке. Через полгода в одном методе жили три заботы, тест требовал настоящих пауз между повторами, а журнал то писал каждую попытку, то не писал ничего: зависело от того, на какой ветке вставили строку.
Декоратор раскладывает это на слои с одним интерфейсом: клиент, который ходит в сеть, и обёртки, каждая со своей заботой. Слои собираются в точке сборки, и тут всплывает главный вопрос, на который учебный пример не отвечает: в каком порядке их складывать?
Что урезано
- Сети нет: транспорт — функция «путь -> статус и тело», в тестах фейк со сценарием ответов (503, 404, потом 200).
- Паузы и часы внедряются функциями, тесты записывают паузы и двигают часы вручную, без настоящего ожидания.
- Курс — целое число копеек за единицу (
9150= 91,50 ₽), одна валюта на запрос, без пакетных запросов и заголовков. - Нет таймаутов, сетевых исключений,
Retry-Afterи джиттера: в проекте они понадобятся, форма слоёв от этого не меняется.
Решение
Компонент — интерфейс RatesApi с одним методом rate(currency) и две ошибки: RatesUnavailable (временный сбой, есть смысл повторить) и UnknownCurrency (постоянный, повтор не поможет). Узкий интерфейс — то, что делает слои дешёвыми.
Конкретный компонент HttpRates — единственный, кто знает про HTTP: превращает статус в ошибку нужного вида и разбирает JSON стандартной библиотекой.
Декораторы:
Retryingповторяет толькоRatesUnavailable, не больше заданного числа попыток, с паузами 100 и 200 мс через внедрённыйsleep.UnknownCurrencyпролетает сразу.Cachingпомнит курс 60 секунд, а отказ — 5 секунд: упавший API не забрасывают запросами, но и надолго в отказе не застревают.Loggingпишет строку журнала на каждый вызов, который до него дошёл.
Сборка (Stack::wrap в PHP, Chain в Go, chain в Python, stack в TypeScript) применяет слои так, что первый в списке — внешний. Порядок читается слева направо, как путь вызова.
Структура
Все внешние зависимости слоёв — функции в конструкторе: транспорт, sleep, часы, write. Поэтому тесты проверяют поведение без сети, без ожидания и без перехвата вывода.
Порядок слоёв
Сборка — единственное место, где решается порядок, и здесь он важен:
- Кэш снаружи ретрая (
cache, retry): промах кэша уходит в ретрай, ретрай переживает 503, в кэш попадает курс. Второй вызов — из кэша. Два запроса к API, оба вызова успешны. - Ретрай снаружи кэша (
retry, cache): первая попытка получает 503, кэш запоминает отказ на 5 секунд, следующие попытки ретрая получают тот же отказ из кэша. Один запрос к API, оба вызова неудачны. Ретрай есть, а толку от него нет. - Журнал снаружи ретрая пишет строку на вызов клиента, внутри — на каждую попытку. Первое нужно бизнес-журналу, второе — разбору сбоев.
- Журнал снаружи кэша видит все вызовы, внутри — только реальные запросы к API.
Разумный порядок для продакшена: log, cache, retry, http (журнал вызовов) или cache, retry, log, http (журнал запросов к API). Правило: слой, который должен видеть каждую попытку, ставится внутрь ретрая; слой, который экономит запросы, — снаружи.
Как это собирается
Слои складываются один раз, в точке сборки приложения: DI-контейнере или main. Пример берёт порядок cache, retry, log, http из раздела выше: журнал стоит внутри ретрая и пишет каждый запрос к API банка. Транспорт в примере - функция, которая один раз отвечает 503, а потом отдаёт курсы; в приложении на её месте HTTP-клиент. Паузы ретрая подменены пустой функцией, чтобы пример не ждал.
Страница цен знает только RatesApi и просит курс для каждой валюты. Первый запрос USD падает с 503, ретрай повторяет, курс попадает в кэш, и второй USD обходится без запроса: в журнале для него нет строки. Для GBP банк отвечает 404, ретрай его не повторяет, и уже страница ловит UnknownCurrency и пишет "not available" вместо цены.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Конкретный компонент одинаков по смыслу везде: статус 404 — UnknownCurrency, 5xx — RatesUnavailable, прочее не-200 — общая ошибка, тело разбирается стандартной библиотекой (json_decode с JSON_THROW_ON_ERROR, encoding/json, json.loads, JSON.parse). Ему всё равно, сколько слоёв сверху. Слои написаны идиомой языка, но поведение и числа одинаковы: 3 попытки, паузы 100 и 200 мс, кэш 60 и 5 секунд.
| Язык | Особенность |
|---|---|
| PHP | Классы Retrying, Caching, Logging с RatesApi внутри; паузы и часы — Closure с умолчанием на usleep и hrtime |
| Go | Middleware func(next RatesAPI) RatesAPI и адаптер RatesFunc, как http.HandlerFunc; Chain применяет слои; errors.Is выбирает, что повторять |
| Python | Слои — функции высшего порядка над Callable[[str], int]; работают и через @retry(...) над обычной функцией |
| TypeScript | Асинхронные классы: Promise<number>, sleep возвращает промис; кэш хранит исход как размеченное объединение |
Запуск: make test T=patterns/structural/decorator/practice
Что проверяют тесты
Тесты одинаковы во всех языках и работают на фейковом окружении: сценарий статусов (после него API отвечает 200 и курсом 9150), счётчик запросов, записанные паузы, ручные часы, журнал.
- Ретрай: два 503 подряд — курс получен за 3 запроса с паузами 100 и 200 мс; три 503 — отказ после 3 запросов, четвёртого нет; 404 — отказ после одного запроса без пауз.
- Кэш: отказ помнится 5 секунд (повторный вызов не идёт в API), затем курс помнится 60 секунд, по истечении — новый запрос.
- Порядок слоёв: одна таблица из шести сборок, два вызова в каждой. Те же слои, тот же сценарий
[503], а результаты разные:cache, retry— 0 отказов и 2 запроса,retry, cache— 2 отказа и 1 запрос; журнал снаружи ретрая — 2 строки, внутри — 3; снаружи кэша — 2, внутри — 1. - Журнал: строка на успех и на отказ с текстом ошибки.
В Python есть ещё один тест: тот же retry(...) применён через @ к обычной функции.
Где граница
- Слоёв больше пяти, у каждого свои настройки — сборку стоит вынести в конфиг или в готовую библиотеку (resilience-библиотеки,
http.RoundTripperв Go, middleware HTTP-клиента), а не плодить свои. - Ретрай с отказами в кэше — уже зачаток автоматического выключателя (circuit breaker). Если нужно «после N отказов не ходить вовсе M секунд и пускать пробный запрос», это отдельный слой с состоянием, а не настройка кэша.
- Кэш здесь не защищён от одновременных промахов: десять параллельных вызовов дадут десять запросов. Для этого нужен single-flight (в Go —
golang.org/x/sync/singleflight, в TS — кэшировать промис). - Если обёртка всего одна и других не предвидится, декоратор избыточен: повтор в три строки прямо в клиенте понятнее.
- Если слою нужно решать, пускать ли вызов вообще (лимиты, права), это скорее Заместитель; форма та же, намерение другое.
Отличия между языками
- PHP — классы-обёртки с
readonlyзависимостями; синхронный код,usleepпо умолчанию.Stack::wrapпринимает замыканияfn (RatesApi $api) => new Retrying($api, ...), потому что слой без внутреннего объекта не создать. - Go — middleware — канонический декоратор Go:
func(next) nextс адаптером-функцией. Встраивание интерфейса не нужно: у интерфейса один метод. Ошибки различаются сентинелами черезerrors.Is, поэтомуfmt.Errorf("%w: ...")вHTTPRatesсохраняет вид ошибки. В кэше мьютекс: клиент используют из многих горутин. - Python — функциональные обёртки естественнее классов: интерфейс — просто
Callable[[str], int], слой — фабрика декоратора, и@retry(3)над функцией работает без изменений.functools.wrapsсохраняет имя обёрнутой функции для журналов и отладки. Закэшированное исключение поднимается черезwith_traceback(None), чтобы трассировка не росла с каждым повтором. - TypeScript — асинхронность меняет детали: ретрай ждёт
await this.inner.rate(...)внутриtry, иначе отказ пролетит мимоcatch; кэш хранит исход ({ ok, rate }или{ ok, error }), а не промис, чтобы различать TTL для курса и для отказа.
Связанные темы
- Декоратор (базовый пример)
- Практика Заместителя: кэш репозитория товаров — кэш как заместитель с TTL и сбросом при записи
- Практика Стратегии: расчёт доставки — сборка поведения из конфига, а не слоями