Практика: отчёты в разных форматах (Report renderers)
Два отчёта и три формата вывода без шести классов: отчёт решает, что показать, рендерер — как это записать, и новый формат не трогает отчёты.
Базовый пример: Мост. Там обе стороны тривиальны и пару выбирает тест. В жизни реализация приходит из запроса (?format=csv), у каждого формата своя возня с экранированием, а некоторые форматы просто не умеют выразить часть отчёта.
Задача
В админке магазина есть отчёт о продажах и отчёт об остатках. Сначала их смотрели в браузере (HTML), потом бухгалтерия попросила выгрузку в CSV, потом менеджеры захотели вставлять отчёты в базу знаний (Markdown). Первая версия росла наследованием: HtmlSalesReport, CsvSalesReport, HtmlStockReport… На третьем формате классов стало шесть, расчёт выручки повторялся в трёх из них, а экранирование HTML в отчёте об остатках забыли, и название товара с < сломало страницу.
Две оси изменений тут независимы: какие данные и итоги в отчёте и как записать заголовок, таблицу и примечание в конкретном формате. Мост разводит их по разным иерархиям.
Что урезано
- Данные приходят готовыми списками, без БД и периодов.
- Деньги — целые копейки и одна валюта; форматирование суммы зашито в отчёт.
- Три примитива вывода: заголовок, таблица, примечание. Нет графиков, стилей и вложенных секций.
- CSV с переводом строки
\n, а не\r\nиз RFC 4180; кодировка не обсуждается. - PDF из идеи в дорожной карте выпал: без сторонней библиотеки его не сделать, а схема от этого не меняется.
Решение
Реализация — интерфейс Renderer с тремя примитивами: heading, table, note. Каждый возвращает готовый кусок текста или пустую строку, если формат этот блок выразить не может. Это главное отличие от учебного примера: реализации неравноценны, и контракт говорит, как об этом сообщить.
Абстракция Report держит рендерер и собирает документ в compose(): просит у рендерера три блока, выбрасывает пустые, склеивает остальные через пустую строку. Отчёты SalesReport и StockReport считают своё (выручку и итог, товары ниже порога) и отдают в compose() уже строки: ни один отчёт не видит ни тегов, ни кавычек.
Выбор формата rendererFor превращает строку из запроса в рендерер и падает с понятной ошибкой на незнакомом формате.
Структура
Форматы несут всю специфику записи. HtmlRenderer экранирует &, <, >. CsvRenderer берёт из таблицы только данные: заголовок и примечание в CSV некуда положить, поэтому он возвращает для них пустую строку, и выгрузка остаётся чистой таблицей, которую откроет любая программа. Кавычки вокруг "Bolt, M6" расставляет стандартная библиотека там, где она есть. MarkdownRenderer экранирует |, иначе ячейка развалит таблицу.
Выбор формата
rendererFor — единственное место, где строка из запроса становится объектом. Он нормализует ввод (" CSV " тоже CSV) и на неизвестном формате бросает ошибку, в тексте которой есть и что пришло, и что допустимо. Отчёты о выборе ничего не знают: контроллер передаёт выбранный рендерер в конструктор отчёта вместе с данными. В PHP та же роль — статический Formats::for(), в Python — renderer_for().
Как это собирается
Обе оси моста сходятся в контроллере выгрузки GET /reports/{name}?format={format}. Имя отчёта выбирает фабрику из карты reports, формат из запроса превращается в рендерер через rendererFor, и только потом отчёт создаётся с этим рендерером и зовёт render(). Данные в примере - литералы, в приложении их достаёт из базы тот же контроллер или сервис отчётов.
Ошибки разбираются на уровне контроллера и становятся ответами: незнакомый отчёт - 404, незнакомый формат - 400 с текстом UnknownFormat, где перечислены допустимые форматы. HTML большими буквами проходит: нормализация живёт в выборе формата, а не в каждом месте вызова. В выводе первая строка каждого ответа: Markdown начинается с заголовка, CSV - сразу с таблицы, потому что заголовок этот формат не выражает.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Отчёты — сторона, которую чаще всего трогает бизнес: новая колонка, новый итог, новый порог. Каждый отчёт — один класс, сколько бы форматов ни было. Порог «мало на складе» и формат денег живут здесь же, потому что это правила отчёта, а не формата.
| Язык | Особенность |
|---|---|
| PHP | abstract class Report с protected compose(); CSV через fputcsv в php://memory, HTML через htmlspecialchars; выбор Formats::for() через match |
| Go | Рендереры — пустые структуры; отчёты встраивают Report; CSV через encoding/csv, HTML через html.EscapeString; ошибка ErrUnknownFormat с %w |
| Python | Renderer — Protocol, Report — ABC; CSV через модуль csv, HTML через html.escape; реестр-словарь классов |
| TypeScript | Структурный интерфейс; экранирование HTML и CSV руками (в стандартной библиотеке нет); реестр-объект фабрик |
Запуск: make test T=patterns/structural/bridge/practice
Что проверяют тесты
Тесты одинаковы во всех языках и на одних данных: болты "Bolt, M6" 3 x 2.50 и гайки 10 x 0.40, на складе 120 болтов и 3 гайки.
- Отчёт о продажах в Markdown — точный текст целиком: выручка 7.50 и 4.00, итог 11.50.
- Отчёт об остатках в CSV — только таблица: заголовка и примечания нет, название с запятой в кавычках.
- HTML экранирует
Nuts & <bolts>в ячейке и в итоге стоит<p>Total: 1.00</p>. - Новый формат без правки отчётов: рендерер с табуляциями объявлен прямо в тесте и работает с обоими отчётами, примечание «Low stock: N-2» на месте.
- Новый отчёт без правки форматов:
ReturnsReportиз теста выводится во все три формата. - Формат из запроса:
" CSV "даёт CSV,"pdf"— ошибку с текстомunknown report format "pdf": use html, csv or md.
Четвёртый и пятый случаи — суть моста: каждая ось расширяется, не открывая файлы другой.
Где граница
- Примитивов три, и они подходят всем форматам. Когда понадобятся графики, которые есть в HTML и нет в CSV, или вложенные секции, интерфейс
Rendererначнёт расти, и каждый новый метод придётся реализовать во всех форматах. Тогда отчёт лучше описывать деревом документа (Компоновщик), а форматы делать обходом этого дерева (Посетитель). - Если форматов станет много и они «почти одинаковые» (CSV с
;, TSV), их удобнее параметризовать, чем плодить классы. - Когда отчётов один-два и формат один, мост избыточен: функция
renderSalesHtml()честнее. - Для настоящего PDF или XLSX рендерер станет адаптером над сторонней библиотекой; мост от этого не меняется, меняется только одна реализация.
Отличия между языками
- PHP —
fputcsvпишет в поток, поэтому нуженphp://memory; параметрescape: ''обязателен с PHP 8.4, иначе deprecation-предупреждение о нестандартном экранировании.matchсdefault => throwделает выбор одним выражением. - Go — наследования нет:
SalesReportвстраиваетReportи вызываетcomposeкак свой метод. Рендереры без состояния — пустые структуры, их можно сравнивать==, что и делает тест выбора формата.csv.Writerпишет вstrings.Builder, который не возвращает ошибок записи. - Python —
Protocolдля рендерера (тестовому не нужен базовый класс) иABCдля отчёта (забытыйrender()упадёт при создании).csv.writerсlineterminator="\n", иначе будет\r\n. - TypeScript — в стандартной библиотеке нет ни CSV, ни экранирования HTML, поэтому оба написаны руками в три строки. Тестовый рендерер — объектный литерал;
it.eachпрогоняет новый отчёт по всем форматам.
Связанные темы
- Мост (базовый пример)
- Адаптер (Adapter) — рендерер над сторонней библиотекой PDF был бы адаптером внутри моста
- Компоновщик (Composite) — куда расти, когда отчёт становится деревом секций
- Практика Абстрактной фабрики: платёжные провайдеры — тоже выбор реализации по данным из запроса, но целым семейством