Практика: экспорт платёжного реестра (Payment registry export)
Реестр платежей уходит в разные банки в разных форматах, но проверка платежей, итоговая строка и контрольная сумма одни на всех, и ни один банк не может их обойти.
Базовый пример: Шаблонный метод. Там шаги — безобидные строки, и защищать в шаблоне нечего. В жизни шаблон держит правила, которые важнее формата: битый платёж не должен уйти ни в один банк, а итог и контрольная сумма считаются одинаково, как бы банк ни раскладывал поля.
Задача
Бухгалтерия раз в день выгружает реестр выплат и отправляет его в банк. Первый банк принимал текстовый файл с ;, второй, подключённый через полгода, хочет |, другой набор колонок и не берёт платежи меньше 100 рублей. Экспорт для второго банка сделали копией первого и поправили поля. Через месяц в первый экспорт добавили проверку номера счёта, а во второй забыли, и банк вернул реестр целиком из-за одной опечатки в счёте.
Неизменная часть здесь длиннее изменчивой: проверить платежи, отобрать нужные, разложить по строкам, подвести итог, посчитать контрольную сумму. Банк отличается только тем, какие поля в заголовке и строке и чем их разделять. Это и есть форма шаблонного метода.
Что урезано
- Реестр — строка в памяти, без файлов, кодировок (банки любят CP1251) и подписи.
- Платёж — четыре поля; нет БИК, ИНН, получателя и даты.
- Деньги — целые копейки и одна валюта.
- Контрольная сумма — CRC32 тела реестра. Реальные банки требуют свою, но это ещё один шаг шаблона, а не другая схема.
- Банки вымышленные:
NorthBankиSouthBank.
Решение
RegistryExport владеет алгоритмом export():
- проверить каждый платёж: сумма положительная, счёт из 20 цифр;
- отобрать платежи не меньше минимальной суммы банка;
- собрать заголовок и строки из полей, которые даёт банк, склеить разделителем банка;
- подвести итог: количество и сумма отобранных платежей, CRC32 заголовка и строк.
Шаги трёх видов. Абстрактные headerFields() и lineFields() банк обязан реализовать. Крючки separator() (по умолчанию ;) и minAmountCents() (по умолчанию 0, то есть без фильтра) банк меняет, только если нужно. Сам export() и проверка платежа checkPayment() объявлены final: банк не может ни поменять порядок, ни ослабить проверку.
Проверка стоит до выборки намеренно: платёж с нулевой суммой — ошибка данных, а не повод тихо отфильтровать его по минимуму банка. Поэтому южный банк с минимумом 100 рублей падает на нулевом платеже так же, как северный.
Склейку полей тоже делает шаблон, а не банк: если поле содержит разделитель (Invoice 15; advance при разделителе ;), реестр не собирается, а ошибка называет платёж. Банк возвращает список полей и не может забыть это правило, потому что строку он вообще не собирает.
Структура
NorthBankExport пользуется обоими крючками по умолчанию и пишет сумму в рублях, SouthBankExport переопределяет оба крючка и пишет сумму в копейках. Итоговая строка у обоих одинаковая по составу: TOTAL, количество, сумма в рублях, контрольная сумма, разделитель — банка.
Как это собирается
Экспорт запускает ночная задача. Выплаты дня уже сгруппированы по банку получателя, а экспортёры задача берёт из контейнера по коду банка: north - NorthBankExport, south - SouthBankExport. Задача не знает форматов и правил проверки: она вызывает export() и отправляет то, что вернулось.
Ошибки задача разбирает сама. В северной группе у платежа P-2 в счёте 19 цифр: шаблон отклоняет весь реестр, и задача возвращает его бухгалтерии, но не останавливается. Южный реестр уходит, а мелкий платёж P-4 в него не попадает - это крючок минимальной суммы банка. Для банка east экспортёра нет, и эта группа ждёт, пока его подключат.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Банки состоят из двух методов с полями и, у южного, двух крючков. Новый банк — новый наследник на десяток строк, и тесты показывают это прямо: наследник PlainBankExport, объявленный в тесте, получает ту же проверку платежей, хотя не написал для неё ни строчки.
| Язык | Особенность |
|---|---|
| PHP | final public function export() и final protected function checkPayment(); формат рублей — final protected static помощник для банков; CRC32 — hash('crc32b') |
| Go | Наследования нет: шаблон — функция Export(bank Bank, …), шаги — интерфейс Bank, умолчания крючков — встраиваемая DefaultHooks; проверка — неэкспортируемая функция; ошибки — сентинелы ErrInvalidPayment, ErrSeparator |
| Python | ABC + @abstractmethod, @final на export и check_payment; zlib.crc32; RegistryError от ValueError |
| TypeScript | final нет: проверка и склейка — 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на крючках, так что случайно затереть метод базы не получится.
Связанные темы
- Шаблонный метод (базовый пример)
- Практика Фабричного метода: импорт прайс-листов — конвейер с одним шагом создания вместо набора шагов
- Стратегия (Strategy) — куда уходят шаги, когда их нужно комбинировать