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, достаточно:
- добавить тип:
TXT
- создать новую стратегию:
@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(), его можно вообще убрать и перенести соответствующую логику непосредственно в стратегии. Тогда каждая стратегия будет полностью отвечать за свой формат.