handbookНаписать

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

база: Мост

Практика: отчёты в разных форматах (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 превращает строку из запроса в рендерер и падает с понятной ошибкой на незнакомом формате.

Структура ​

bridge

picks

«interface»

Renderer

+heading(text) : string

+table(headers, rows) : string

+note(text) : string

HtmlRenderer

CsvRenderer

MarkdownRenderer

«abstract»

Report

#renderer: Renderer

+render() : string

#compose(title, headers, rows, note) : string

SalesReport

StockReport

«function»

rendererFor

+rendererFor(format) : Renderer

Форматы несут всю специфику записи. 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 в панели кода.

Реализации ​

Отчёты — сторона, которую чаще всего трогает бизнес: новая колонка, новый итог, новый порог. Каждый отчёт — один класс, сколько бы форматов ни было. Порог «мало на складе» и формат денег живут здесь же, потому что это правила отчёта, а не формата.

ЯзыкОсобенность
PHPabstract class Report с protected compose(); CSV через fputcsv в php://memory, HTML через htmlspecialchars; выбор Formats::for() через match
GoРендереры — пустые структуры; отчёты встраивают Report; CSV через encoding/csv, HTML через html.EscapeString; ошибка ErrUnknownFormat с %w
PythonRenderer — 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 прогоняет новый отчёт по всем форматам.

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

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