Рефакторинг DocumentService с паттерном Стратегия

8. Рефакторинг DocumentService с использованием паттерна Strategy

Условие задачи:
Дан DocumentService, в котором выбор способа чтения документа жёстко реализован через цепочку if-else.

Необходимо:

  • провести code review текущей реализации;

  • выделить её плюсы и минусы;

  • провести рефакторинг с использованием паттерна Strategy;

  • сделать добавление новых форматов без изменения DocumentService;

  • добавить отдельную стратегию для чтения PDF.

Код:

@Component
public class DocumentService {

    public InputStream readDocument(String type) {
        DocumentReader reader = new DocumentReader();

        if (type.equals("PDF")) {
            return reader.readPdf();
        } else if (type.equals("DOCX")) {
            return reader.readDocx();
        } else if (type.equals("XLSX")) {
            return reader.readXlsx();
        } else {
            return null;
        }
    }
}

Спойлеры к решению

Подсказки
💡 Цепочка if-else связывает DocumentService со всеми поддерживаемыми форматами.
💡 При добавлении нового формата приходится изменять существующий сервис — нарушается OCP.
💡 new DocumentReader() внутри Spring-сервиса ухудшает тестируемость и обходит Dependency Injection.
💡 Возврат null для неизвестного типа лучше заменить явным исключением.
💡 Каждому формату можно предоставить отдельную реализацию общего интерфейса DocumentReaderStrategy.
💡 Spring умеет автоматически внедрять коллекцию всех реализаций интерфейса.

Решение

У текущего решения есть один очевидный плюс: для нескольких форматов оно простое и легко читается.

Но по мере роста количества типов появляются проблемы:

  • DocumentService знает обо всех форматах;

  • каждый новый формат требует изменения readDocument();

  • растёт цепочка if-else;

  • DocumentReader создаётся через new, а не внедряется как зависимость;

  • код сложнее изолированно тестировать;

  • неизвестный формат приводит к null, из-за чего ошибка проявится позже.

Для рефакторинга введём общий тип документа:

public enum DocumentType {
    PDF,
    DOCX,
    XLSX
}

Интерфейс стратегии:

public interface DocumentReaderStrategy {

    DocumentType getType();

    InputStream read();
}

Предположим, существующий DocumentReader содержит непосредственно логику чтения и зарегистрирован как Spring-бин:

@Component
public class DocumentReader {

    public InputStream readPdf() {
        // чтение PDF
        return null;
    }

    public InputStream readDocx() {
        // чтение DOCX
        return null;
    }

    public InputStream readXlsx() {
        // чтение XLSX
        return null;
    }
}

Стратегия для PDF:

@Component
public class PdfDocumentReaderStrategy
        implements DocumentReaderStrategy {

    private final DocumentReader documentReader;

    public PdfDocumentReaderStrategy(
            DocumentReader documentReader
    ) {
        this.documentReader = documentReader;
    }

    @Override
    public DocumentType getType() {
        return DocumentType.PDF;
    }

    @Override
    public InputStream read() {
        return documentReader.readPdf();
    }
}

Аналогично для DOCX:

@Component
public class DocxDocumentReaderStrategy
        implements DocumentReaderStrategy {

    private final DocumentReader documentReader;

    public DocxDocumentReaderStrategy(
            DocumentReader documentReader
    ) {
        this.documentReader = documentReader;
    }

    @Override
    public DocumentType getType() {
        return DocumentType.DOCX;
    }

    @Override
    public InputStream read() {
        return documentReader.readDocx();
    }
}

И XLSX:

@Component
public class XlsxDocumentReaderStrategy
        implements DocumentReaderStrategy {

    private final DocumentReader documentReader;

    public XlsxDocumentReaderStrategy(
            DocumentReader documentReader
    ) {
        this.documentReader = documentReader;
    }

    @Override
    public DocumentType getType() {
        return DocumentType.XLSX;
    }

    @Override
    public InputStream read() {
        return documentReader.readXlsx();
    }
}

Теперь DocumentService получает все стратегии от Spring и формирует из них lookup-map:

@Service
public class DocumentService {

    private final Map<DocumentType, DocumentReaderStrategy> strategies;

    public DocumentService(
            List<DocumentReaderStrategy> strategies
    ) {
        Map<DocumentType, DocumentReaderStrategy> strategyMap =
                new EnumMap<>(DocumentType.class);

        for (DocumentReaderStrategy strategy : strategies) {
            DocumentReaderStrategy previous =
                    strategyMap.put(strategy.getType(), strategy);

            if (previous != null) {
                throw new IllegalStateException(
                        "Несколько стратегий для типа "
                                + strategy.getType()
                );
            }
        }

        this.strategies = Collections.unmodifiableMap(strategyMap);
    }

    public InputStream readDocument(String type) {
        if (type == null || type.isBlank()) {
            throw new IllegalArgumentException(
                    "Тип документа не указан"
            );
        }

        DocumentType documentType;

        try {
            documentType = DocumentType.valueOf(
                    type.trim().toUpperCase(Locale.ROOT)
            );
        } catch (IllegalArgumentException e) {
            throw new IllegalArgumentException(
                    "Неподдерживаемый тип документа: " + type,
                    e
            );
        }

        DocumentReaderStrategy strategy =
                strategies.get(documentType);

        if (strategy == null) {
            throw new IllegalArgumentException(
                    "Нет стратегии для типа: " + documentType
            );
        }

        return strategy.read();
    }
}

Теперь выбор реализации выполняется не через:

if (type.equals("PDF")) {
    ...
} else if (type.equals("DOCX")) {
    ...
}

а через зарегистрированную стратегию:

DocumentReaderStrategy strategy =
        strategies.get(documentType);

return strategy.read();

При добавлении нового формата, например TXT, достаточно:

  1. добавить тип:
TXT
  1. создать новую стратегию:
@Component
public class TxtDocumentReaderStrategy
        implements DocumentReaderStrategy {

    @Override
    public DocumentType getType() {
        return DocumentType.TXT;
    }

    @Override
    public InputStream read() {
        // чтение TXT
        return ...;
    }
}

DocumentService при этом менять не требуется.

Это и есть основная идея паттерна Strategy:

DocumentService
DocumentReaderStrategy
       ├── PdfDocumentReaderStrategy
       ├── DocxDocumentReaderStrategy
       └── XlsxDocumentReaderStrategy

Spring автоматически обнаруживает все реализации:

List<DocumentReaderStrategy> strategies

и передаёт их через constructor injection.

Использование Map<DocumentType, DocumentReaderStrategy> позволяет выбирать стратегию за O(1) в среднем и не выполнять линейный поиск по списку при каждом вызове.

Основные улучшения после рефакторинга:

  • устранена цепочка if-else;

  • DocumentService больше не зависит от конкретных реализаций;

  • соблюдается Open/Closed Principle;

  • зависимости создаются Spring, а не через new;

  • новые форматы добавляются отдельными стратегиями;

  • неизвестный формат приводит к понятному исключению, а не к null;

  • стратегии легко тестируются отдельно;

  • проверяется ситуация, когда случайно зарегистрированы две стратегии для одного типа.

Если DocumentReader фактически не содержит общей инфраструктуры, а только методы readPdf(), readDocx() и readXlsx(), его можно вообще убрать и перенести соответствующую логику непосредственно в стратегии. Тогда каждая стратегия будет полностью отвечать за свой формат.