handbookНаписать

patterns/creational/factory-method/practice/README.md · практика · 4 языка · 23 теста

Практика: импорт прайс-листов (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 — единственное место, где расширение файла превращается в импортёр.

Структура ​

uses

returns

creates

creates

picks by extension

«abstract»

PriceImport

#createParser() : PriceParser

+run(content) : ImportReport

CsvPriceImport

-delimiter: string

#createParser() : PriceParser

JsonPriceImport

#createParser() : PriceParser

«interface»

PriceParser

+parse(content) : records

CsvPriceParser

-delimiter: string

JsonPriceParser

ImportReport

+rows: PriceRow[]

+rejected: string[]

PriceImports

+forFile(filename) : PriceImport

Два уровня правил, как в базовом примере: формат файла (пустой JSON-массив, лишние ячейки) — забота продукта, смысл записи (артикул, цена) — забота создателя, и он один на все форматы. Поэтому отчёты по одинаковым данным совпадают независимо от формата.

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

Импорт запускает обработчик загрузок: поставщик прислал файл, по имени файла PriceImports::forFile выбирает импортёр, разделитель CSV берётся из настроек поставщика (у acme запятая, у остальных умолчание ;). Импортёр сам создаёт свой парсер и прогоняет общий конвейер, обработчик получает ImportReport и раскладывает его: принятые строки в каталог, отклонённые записи в отчёт поставщику.

Файл gamma.xlsx не доходит до конвейера: для него нет импортёра, forFile бросает исключение, и обработчик помечает загрузку как пропущенную, не роняя остальные. Плохая цена в acme.csv ошибкой не считается: это строка отчёта, импорт остальных записей продолжается.

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

Реализации ​

Наследники состоят из фабричного метода и, у CSV, поля с разделителем. Этого достаточно, чтобы в тесте подменить парсер: анонимный наследник (PHP), подкласс FakeImport (Python, TS) или другая функция в поле newParser (Go) кормит конвейер готовыми записями, и валидация проверяется без единого файла.

ЯзыкОсобенность
PHPabstract class PriceImport, парсеры — генераторы; str_getcsv и json_decode из стандартной библиотеки; выбор по расширению через match
GoФабричный метод — поле newParser func() Parser, тест подставляет свою функцию; encoding/csv с Comma = delimiter и encoding/json сразу в []map[string]string
PythonABC + @abstractmethod; csv.DictReader(delimiter=…) и json.loads; importer_for через match по суффиксу
TypeScriptprotected 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, без кавычек и экранирования; в проекте здесь была бы библиотека, и именно её создание и прячет фабричный метод.

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

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