handbookНаписать

patterns/behavioral/template-method/practice/README.md · практика · 4 языка · 29 тестов

база: Шаблонный метод

Практика: экспорт платёжного реестра (Payment registry export) ​

Реестр платежей уходит в разные банки в разных форматах, но проверка платежей, итоговая строка и контрольная сумма одни на всех, и ни один банк не может их обойти.

Базовый пример: Шаблонный метод. Там шаги — безобидные строки, и защищать в шаблоне нечего. В жизни шаблон держит правила, которые важнее формата: битый платёж не должен уйти ни в один банк, а итог и контрольная сумма считаются одинаково, как бы банк ни раскладывал поля.

Задача ​

Бухгалтерия раз в день выгружает реестр выплат и отправляет его в банк. Первый банк принимал текстовый файл с ;, второй, подключённый через полгода, хочет |, другой набор колонок и не берёт платежи меньше 100 рублей. Экспорт для второго банка сделали копией первого и поправили поля. Через месяц в первый экспорт добавили проверку номера счёта, а во второй забыли, и банк вернул реестр целиком из-за одной опечатки в счёте.

Неизменная часть здесь длиннее изменчивой: проверить платежи, отобрать нужные, разложить по строкам, подвести итог, посчитать контрольную сумму. Банк отличается только тем, какие поля в заголовке и строке и чем их разделять. Это и есть форма шаблонного метода.

Что урезано ​

  • Реестр — строка в памяти, без файлов, кодировок (банки любят CP1251) и подписи.
  • Платёж — четыре поля; нет БИК, ИНН, получателя и даты.
  • Деньги — целые копейки и одна валюта.
  • Контрольная сумма — CRC32 тела реестра. Реальные банки требуют свою, но это ещё один шаг шаблона, а не другая схема.
  • Банки вымышленные: NorthBank и SouthBank.

Решение ​

RegistryExport владеет алгоритмом export():

  1. проверить каждый платёж: сумма положительная, счёт из 20 цифр;
  2. отобрать платежи не меньше минимальной суммы банка;
  3. собрать заголовок и строки из полей, которые даёт банк, склеить разделителем банка;
  4. подвести итог: количество и сумма отобранных платежей, CRC32 заголовка и строк.

Шаги трёх видов. Абстрактные headerFields() и lineFields() банк обязан реализовать. Крючки separator() (по умолчанию ;) и minAmountCents() (по умолчанию 0, то есть без фильтра) банк меняет, только если нужно. Сам export() и проверка платежа checkPayment() объявлены final: банк не может ни поменять порядок, ни ослабить проверку.

Проверка стоит до выборки намеренно: платёж с нулевой суммой — ошибка данных, а не повод тихо отфильтровать его по минимуму банка. Поэтому южный банк с минимумом 100 рублей падает на нулевом платеже так же, как северный.

Склейку полей тоже делает шаблон, а не банк: если поле содержит разделитель (Invoice 15; advance при разделителе ;), реестр не собирается, а ошибка называет платёж. Банк возвращает список полей и не может забыть это правило, потому что строку он вообще не собирает.

Структура ​

checks

«abstract»

RegistryExport

+export(registryNo, payments) : string

#checkPayment(payment)

#headerFields(registryNo) : string[]

#lineFields(n, payment) : string[]

#separator() : string

#minAmountCents() : int

NorthBankExport

#headerFields(registryNo) : string[]

#lineFields(n, payment) : string[]

SouthBankExport

#headerFields(registryNo) : string[]

#lineFields(n, payment) : string[]

#separator() : string

#minAmountCents() : int

Payment

+id: string

+account: string

+amountCents: int

+purpose: string

NorthBankExport пользуется обоими крючками по умолчанию и пишет сумму в рублях, SouthBankExport переопределяет оба крючка и пишет сумму в копейках. Итоговая строка у обоих одинаковая по составу: TOTAL, количество, сумма в рублях, контрольная сумма, разделитель — банка.

Как это собирается ​

Экспорт запускает ночная задача. Выплаты дня уже сгруппированы по банку получателя, а экспортёры задача берёт из контейнера по коду банка: north - NorthBankExport, south - SouthBankExport. Задача не знает форматов и правил проверки: она вызывает export() и отправляет то, что вернулось.

Ошибки задача разбирает сама. В северной группе у платежа P-2 в счёте 19 цифр: шаблон отклоняет весь реестр, и задача возвращает его бухгалтерии, но не останавливается. Южный реестр уходит, а мелкий платёж P-4 в него не попадает - это крючок минимальной суммы банка. Для банка east экспортёра нет, и эта группа ждёт, пока его подключат.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

Банки состоят из двух методов с полями и, у южного, двух крючков. Новый банк — новый наследник на десяток строк, и тесты показывают это прямо: наследник PlainBankExport, объявленный в тесте, получает ту же проверку платежей, хотя не написал для неё ни строчки.

ЯзыкОсобенность
PHPfinal public function export() и final protected function checkPayment(); формат рублей — final protected static помощник для банков; CRC32 — hash('crc32b')
GoНаследования нет: шаблон — функция Export(bank Bank, …), шаги — интерфейс Bank, умолчания крючков — встраиваемая DefaultHooks; проверка — неэкспортируемая функция; ошибки — сентинелы ErrInvalidPayment, ErrSeparator
PythonABC + @abstractmethod, @final на export и check_payment; zlib.crc32; RegistryError от ValueError
TypeScriptfinal нет: проверка и склейка — private, их не переопределить и не вызвать из банка; crc32 из node:zlib

Запуск: make test T=patterns/behavioral/template-method/practice

Что проверяют тесты ​

Тесты одинаковы во всех языках и сверяют реестр целиком, вплоть до контрольной суммы: три платежа на 1500.00, 50.50 и 12500.00 рублей. Северный банк выгружает все три (TOTAL;3;14050.50;e0dbbd29), южный отбрасывает платёж на 50.50 по своему минимуму (TOTAL|2|14000.00|6e22e7c8). Совпадение CRC32 во всех четырёх языках заодно показывает, что контрольная сумма считается по одним и тем же байтам.

Второй тест — общая проверка: нулевая сумма и короткий счёт падают с одной и той же ошибкой у северного, южного и тестового PlainBankExport. Южный банк падает на нулевом платеже, хотя его минимум этот платёж и так отбросил бы: проверка идёт до выборки. Третий — разделитель внутри поля: назначение Invoice 15; advance ломает реестр с ; и спокойно проходит в реестр с |. PHP-тест дополнительно проверяет, что export() и checkPayment() объявлены final.

Где граница ​

  • Банков стало много, и у них различаются не только поля, но и сами шаги: где-то нет итоговой строки, где-то два заголовка, где-то XML. Шаблон обрастает крючками «а нужен ли этот шаг», и его уже трудно читать. Пора разбить экспорт на части: формат строки и формат файла — отдельные стратегии, а общий конвейер принимает их в конструкторе.
  • Банку нужно выбирать поля в зависимости от конфигурации (другой набор колонок для зарплатного реестра). Это не повод наследовать NorthBankSalaryExport: такие варианты комбинируются, и наследование даст N x M классов, как в практике Моста.
  • Если формат один и не меняется, иерархия не нужна: функция с проверкой и склейкой проще.

Сравнение с практикой Фабричного метода (импорт прайс-листов). Там тоже общий конвейер в базе, но наследник переопределяет один шаг — создание парсера, и дальше конвейер работает с созданным объектом. Здесь ничего не создаётся: наследник поставляет сами шаги алгоритма (поля заголовка и строк) и настраивает его крючками. Фабричный метод — частный случай шаблонного, где изменчивый шаг отвечает за создание продукта.

Отличия между языками ​

  • PHP — final защищает и шаблон, и проверку на уровне языка: наследник с checkPayment() не загрузится. Помощник rubles() тоже final, банки его используют, но не меняют.
  • Go — классической формы нет, и Go-версия честнее остальных: Export — функция, а не метод, поэтому переопределить её нельзя, а checkPayment и join неэкспортируемые и банку недоступны вовсе. Шаги банк поставляет через интерфейс Bank (композиция вместо наследования), а умолчания крючков даёт встроенная DefaultHooks: NorthBank встраивает её и получает ; и нулевой минимум, SouthBank объявляет оба метода сам. Шаблон не может жить методом DefaultHooks: встроенная структура не видит методов внешней и вызвала бы свои умолчания вместо банковских.
  • Python — @final только для тайпчекера, в рантайме наследник может переопределить export(). Склейка названа _join по соглашению; настоящей защиты нет, есть договорённость.
  • TypeScript — final нет, export() технически переопределить можно. Зато private работает строго: банк не может объявить свой checkPayment (компилятор ругается на конфликт приватного члена) и не может его вызвать. noImplicitOverride требует override на крючках, так что случайно затереть метод базы не получится.

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

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