Практика: импорт прайс-листов (Price list import)
Конвейер импорта один для всех поставщиков, а парсер зависит от формата файла: его создаёт наследник, и ровно это делает конвейер проверяемым без файлов.
Базовый пример: Фабричный метод. Там наследник просто выбирает класс продукта. В жизни продукту нужны настройки, конвейер хочется тестировать без парсинга, а где-то в системе должно жить решение «какой импортёр для этого файла».
Задача
Магазин получает прайс-листы поставщиков: один присылает CSV с ;, другой CSV с ,, третий JSON. Что бы ни пришло, дальше всё одинаково: артикул не должен быть пустым, цена должна быть числом с не более чем двумя знаками и превращается в копейки, плохие записи не валят импорт, а попадают в отчёт с номером и причиной.
Первая версия держала if ($ext === 'csv') … else … внутри одного метода import(). Валидация постепенно разошлась: в CSV-ветке цену проверяли, в JSON-ветке забыли. Тесты конвейера требовали настоящих файлов. Новый поставщик с другим разделителем означал ещё одну ветку.
Что урезано
- Файлы — строки в памяти, без чтения с диска, кодировок и BOM.
- Записи — только строки, как в CSV. JSON с числами в проекте нужно было бы нормализовать до того же вида.
- Две колонки
skuиprice, без валют, остатков и названий. - Отчёт не пишется в БД и не отправляется поставщику: это список строк.
Решение
Конвейер живёт в создателе PriceImport: получить записи от парсера, проверить каждую, собрать ImportReport из принятых PriceRow и отклонённых записей с причиной. Откуда взять парсер, создатель не знает: это фабричный метод createParser().
Продукт — PriceParser: превращает содержимое файла в записи «колонка → строка». CsvPriceParser получает разделитель, JsonPriceParser проверяет, что пришёл массив.
Наследники CsvPriceImport и JsonPriceImport переопределяют только фабричный метод, причём CSV-наследник хранит разделитель и передаёт его продукту: настройки поставщика живут в создателе, продукт получает их при создании. PriceImports::forFile — единственное место, где расширение файла превращается в импортёр.
Структура
Два уровня правил, как в базовом примере: формат файла (пустой JSON-массив, лишние ячейки) — забота продукта, смысл записи (артикул, цена) — забота создателя, и он один на все форматы. Поэтому отчёты по одинаковым данным совпадают независимо от формата.
Как это собирается
Импорт запускает обработчик загрузок: поставщик прислал файл, по имени файла PriceImports::forFile выбирает импортёр, разделитель CSV берётся из настроек поставщика (у acme запятая, у остальных умолчание ;). Импортёр сам создаёт свой парсер и прогоняет общий конвейер, обработчик получает ImportReport и раскладывает его: принятые строки в каталог, отклонённые записи в отчёт поставщику.
Файл gamma.xlsx не доходит до конвейера: для него нет импортёра, forFile бросает исключение, и обработчик помечает загрузку как пропущенную, не роняя остальные. Плохая цена в acme.csv ошибкой не считается: это строка отчёта, импорт остальных записей продолжается.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Наследники состоят из фабричного метода и, у CSV, поля с разделителем. Этого достаточно, чтобы в тесте подменить парсер: анонимный наследник (PHP), подкласс FakeImport (Python, TS) или другая функция в поле newParser (Go) кормит конвейер готовыми записями, и валидация проверяется без единого файла.
| Язык | Особенность |
|---|---|
| PHP | abstract class PriceImport, парсеры — генераторы; str_getcsv и json_decode из стандартной библиотеки; выбор по расширению через match |
| Go | Фабричный метод — поле newParser func() Parser, тест подставляет свою функцию; encoding/csv с Comma = delimiter и encoding/json сразу в []map[string]string |
| Python | ABC + @abstractmethod; csv.DictReader(delimiter=…) и json.loads; importer_for через match по суффиксу |
| TypeScript | protected abstract createParser(); CSV разбирается split, потому что в стандартной библиотеке нет CSV; Record<string, string> как тип записи |
Запуск: make test T=patterns/creational/factory-method/practice
Что проверяют тесты
Тесты одинаковы во всех языках. Одни и те же четыре записи даны как CSV и как JSON, и отчёт обязан совпасть: две принятые строки (12.50 → 1250 копеек, 7 → 700) и две отклонённые с номером записи и причиной. Второй тест кормит конвейер подменённым парсером: валидация и перевод в копейки проверяются без форматов вообще. Третий — разделитель приходит из настроек поставщика. Четвёртый — выбор импортёра по расширению не зависит от регистра, а неизвестный формат падает сразу.
Где граница
- Форматов стало пять и у каждого свои настройки — иерархия наследников превращается в параллельный зоопарк. Разумнее реестр «расширение → фабрика парсера» и один создатель без наследников: это уже Стратегия с реестром, как в практике Стратегии.
- Парсеру нужен контекст вызова (имя файла, поставщик) — фабричный метод получает параметры:
createParser(SupplierSettings $s). Это всё ещё фабричный метод. - Конвейера вокруг продукта нет, нужен только парсер — не наследуйте, передайте парсер в конструктор. Фабричный метод оправдан, когда создатель владеет созданием: настройками, временем жизни, ресурсами продукта.
forFileзнает все наследники, и это нормально ровно до тех пор, пока он один. Если такиеmatchпоявляются в нескольких местах, выбор пора вынести в реестр или контейнер.
Отличия между языками
- PHP — парсеры как генераторы: конвейер читает записи по одной, большой файл не держится в памяти целиком. Выбор по расширению —
matchсthrowвdefault. - Go — наследования нет, поэтому подмена парсера в тесте — просто другая функция в поле
newParserтого же пакета; из коробки видно, что фабричный метод здесь = внедрённая зависимость.encoding/jsonдекодирует прямо в[]Record, парсер JSON умещается в три строки. - Python —
csv.DictReaderуже отдаёт записи словарями, парсер CSV — одна строка;Protocolдля парсера, поэтомуFakeParserв тесте ничего не наследует. - TypeScript — единственный язык без CSV в стандартной библиотеке, поэтому CSV разбирается
split, без кавычек и экранирования; в проекте здесь была бы библиотека, и именно её создание и прячет фабричный метод.
Связанные темы
- Фабричный метод (базовый пример)
- Практика Стратегии: расчёт доставки — куда уходит выбор, когда наследников становится много