handbookНаписать

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

Практика: клиент курсов валют (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) применяет слои так, что первый в списке — внешний. Порядок читается слева направо, как путь вызова.

Структура ​

wraps

wraps

wraps

layers

«interface»

RatesApi

+rate(currency) : int

HttpRates

-transport: fn

+rate(currency) : int

Retrying

-inner: RatesApi

-attempts: int

-sleep: fn

Caching

-inner: RatesApi

-ttl: int

-errorTtl: int

-now: fn

Logging

-inner: RatesApi

-write: fn

Stack

+wrap(api, layers) : RatesApi

Все внешние зависимости слоёв — функции в конструкторе: транспорт, 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
GoMiddleware 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 для курса и для отказа.

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

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