В данной статье речь пойдёт о применении паттерна Pipes & Filters.
Для начала мы разберём пример функции, которую позже перепишем с помощью выше упомянутого паттерна. Изменения в коде будут происходить постепенно и каждый раз мы будем создавать работоспособный вариант, пока не остановимся на решении с помощью DI (в данном примере Spring).
Таким образом мы создадим несколько решений, предоставив возможность использовать любое.
В конце мы сравним начальную и конечную реализации, посмотрим на примеры применения в реальных проектах и подведём итог.
Задача
Допустим, у нас есть куча одежды, которую мы получаем из сушки и которую теперь надо переместить в шкаф. Получается, что данные (одежда) поступают из отдельного сервиса и задача состоит в том, чтобы эти данные предоставить клиенту в нужном виде (в шкафу, из которого он сможет доставать одежду).
В большинстве случаев нельзя использовать получаемые данные в том виде, в котором они поступают к нам. Эти данные нужно проверить, трансформировать, отсортировать и т.д.
Допустим, что клиент выдвигает требование, что одежда должна быть поглажена, если она мята.
Тогда мы впервые создаём Modifier, в котором прописываем изменения:
public class Modifier { public List<Одежда> modify(List<Одежда> одежда){ гладить(одежда); return одежда; } private void гладить(List<Одежда> одежда) { одежда.stream() .filter(Одежда::мятая) .forEach(o -> { //глажу }); } }
На данном этапе всё просто и ясно. Напишем тест, который проверяет, что вся мятая одежда была поглажена.
Но со временем появляются новые требования и каждый раз расширяется функционал класса Modifier:
- Не класть в шкаф грязное бельё
- Рубашки, пиджаки и брюки должны висеть на плечиках
- Дырявые носки нужно сначала зашить
- …
Последовательность изменений тоже важна. Например, нельзя сначала повесить одежду на плечики, а потом гладить.
Тем самым в какой-то момент Modifier может принять следующий вид:
public class Modifier { private static final Predicate<Одежда> ДОЛЖНО_ВИСЕТЬ_НА_ПЛЕЧИКАХ = ((Predicate<Одежда>)Рубашка.class::isInstance) .or(Брюки.class::isInstance) .or(Пиджак.class::isInstance) ; public List<Одежда> modify(List<Одежда> одежда){ зашитьНоски(одежда); гладить(одежда); выброситьГрязное(одежда); повеситьНаПлечики(одежда); //ещё Х шагов return одежда; } private void зашитьНоски(List<Одежда> одежда) { одежда.stream() .filter(Носок.class::isInstance) .map(Носок.class::cast) .filter(Носок::порван) .forEach(o -> { //зашиваю }); } private void повеситьНаПлечики(List<Одежда> одежда) { одежда.stream() .filter(ДОЛЖНО_ВИСЕТЬ_НА_ПЛЕЧИКАХ) .forEach(o -> { //вешаю на плечики }); } private void выброситьГрязное(List<Одежда> одежда) { одежда.removeIf(Одежда::грязная); } private void гладить(List<Одежда> одежда) { одежда.stream() .filter(Одежда::мятая) .forEach(o -> { //глажу }); } //остальные шаги }
Соответственно более сложными стали и тесты, которые теперь должны как минимум проверить каждый шаг по отдельности.
И когда поступает новое требование, взглянув на код, мы решаем, что наступила пора для Refactoring.
Refactoring
Первое, что бросается в глаза, это частый перебор всей одежды. Так что первым шагом мы всё перемещаем в один цикл, а так же переносим проверку на чистоту в начало цикла:
public class Modifier { private static final Predicate<Одежда> ДОЛЖНО_ВИСЕТЬ_НА_ПЛЕЧИКАХ = ((Predicate<Одежда>)Рубашка.class::isInstance) .or(Брюки.class::isInstance) .or(Пиджак.class::isInstance) ; public List<Одежда> modify(List<Одежда> одежда){ List<Одежда> result = new ArrayList<>(); for(var o : одежда){ if(o.грязная()){ continue; } result.add(o); зашитьНоски(o); гладить(o); повеситьНаПлечики(o); //ещё Х шагов } return result; } private void зашитьНоски(Одежда одежда) { if(одежда instanceof Носок){ //зашиваю (Носок) одежда } } private void повеситьНаПлечики(Одежда одежда) { if(ДОЛЖНО_ВИСЕТЬ_НА_ПЛЕЧИКАХ.test(одежда)){ //вешаю на плечики } } private void гладить(Одежда одежда) { if(одежда.мятая()){ //глажу } } //остальные шаги }
Теперь время обработки одежды сокращается, но код до сих пор слишком длинный для одного класса и для тела цикла. Попробуем сократить сначала тело цикла.
-
Можно все вызовы после проверки на чистоту вынести в отдельный метод
modify(Одежда о):public List<Одежда> modify(List<Одежда> одежда){ List<Одежда> result = new ArrayList<>(); for(var o : одежда){ if(o.грязная()){ continue; } result.add(o); modify(o); } return result; } private void modify(Одежда o) { зашитьНоски(o); гладить(o); повеситьНаПлечики(o); //ещё Х шагов } -
Можно же соединить все вызовы в один
Consumer:private Consumer<Одежда> modification = ((Consumer<Одежда>) this::зашитьНоски) .andThen(this::гладить) .andThen(this::повеситьНаПлечики); //ещё Х шагов public List<Одежда> modify(List<Одежда> одежда){ return одежда.stream() .filter(o -> !o.грязная()) .peek(modification) .collect(Collectors.toList()); }Отсупление: peek
Я использовал peek для краткости. Sonar на такой код скажет, что так делать не стоит, т.к. в Javadoc к peek прописано, что метод существует в первую очередь для debug’a. Но если переписать на map: .map(o -> {modification.accept(o);return o;}), то IDEA скажет, что лучше использовать peek
Отсупление: Consumer
Пример с Consumer (и последующий с Function) даны, чтобы показать возможности языка.
Теперь тело цикла стало короче, но до сих пор сам класс ещё слишком велик и содержит в себе слишком много информации (знания о всех шагах).
Попробуем решить эту проблему, используя уже устоявшиеся паттерны программирования. В данном случае мы воспользуемся Pipes & Filters.
Pipes & Filters
Шаблон каналов и фильтров описывает подход, в котором входящие данные проходят несколько этапов обработки.
Попробуем применить этот подход к нашему коду
Шаг 1
На самом деле, наш код уже близок к этому паттерну. Полученные данные проходят несколько независимых шагов. Пока что, каждый метод это фильтр, а сам modify описывает канал, отсеяв сначала всю грязную одежду.
Теперь же перенесём каждый шаг в отдельный класс и посмотрим, что у нас получится:
public class Modifier { private final Утюг утюг; private final Плечики плечики; private final НиткаИголка ниткаИголка; //остальные шаги public Modifier(Плечики плечики, НиткаИголка ниткаИголка, Утюг утюг //остальные шаги ) { this.утюг = утюг; this.плечики = плечики; this.ниткаИголка = ниткаИголка; //остальные шаги } public List<Одежда> modify(List<Одежда> одежда) { return одежда.stream() .filter(o -> !o.грязная()) .peek(o -> { ниткаИголка.зашить(o); утюг.гладить(o); плечики.повесить(o); //остальные шаги }) .collect(Collectors.toList()); } }
Тем самым мы разместили код в отдельных классах, упростив тесты для отдельных преобразований (и создав возможность переиспользования шагов). Порядок вызовов определяет последовательность шагов.
Но сам класс до сих пор знает все отдельные шаги, управляет порядком и имеет тем самым огромный список зависимостей. К тому, чтобы добавить новый шаг, мы будем вынужденны не только написать новый класс, но и добавить его в Modfier.
Шаг 2
Упростим код, используя Spring.
Для начала создадим интерфейс для каждого отдельного шага:
interface Modification { void modify(Одежда одежда); }
Сам Modifier теперь будет намного короче:
public class Modifier { private final List<Modification> steps; @Autowired public Modifier(List<Modification> steps) { this.steps = steps; } public List<Одежда> modify(List<Одежда> одежда) { return одежда.stream() .filter(o -> !o.грязная()) .peek(o -> { steps.forEach(m -> m.modify(o)); }) .collect(Collectors.toList()); } }
Теперь, чтобы добавить новый шаг, нужно всего лишь написать новый класс реализовывающий интерфейс Modification и поставить над ним @Component. Spring сам его найдёт и добавит в список.
Сам Modifer ничего не знает об отдельных шагах, за счёт чего создаётся «слабая связь» между компонентами.
Сложность лишь в том, чтобы задать последовательность. Для этого в Spring существует аннотация @Order, в которую можно передать значение int. Список сортируется по возрастанию.
Таким образом может случится, что добавив новый шаг в середине списка, придётся изменить значения сортировки для уже существующих шагов.
Можно было бы обойтись и без Spring, если в конструктор Modifier вручную передавать все известные имплементации. Это поможет решить проблему сортировки, но снова усложнит добавление новых шагов.
Шаг 3
Теперь же вынесем проверку на чистоту в отдельный шаг. Для этого перепишим наш интерфейс так, чтобы он всегда возвращал значение:
interface Modification { Одежда modify(Одежда одежда); }
Проверка на чистоту:
@Component @Order(Ordered.HIGHEST_PRECEDENCE) class CleanFilter implements Modification { Одежда modify(Одежда одежда) { if(одежда.грязная()){ return null; } return одежда; } }
Сам же Modifier.modify:
public List<Одежда> modify(List<Одежда> одежда) { return одежда.stream() .map(o -> { var modified = o; for(var step : steps){ modified = step.modify(o); if(modified == null){ return null; } } return modified; }) .filter(Objects::nonNull) .collect(Collectors.toList()); }
В этой версии Modifier не имеет никакой информации о данных. Он просто передаёт их в каждый известный шаг и собирает результаты.
Если один из шагов возвращает null, то обработка для этой одежды прерывается.
Похожий принцип используется в Spring для HandlerInterceptor’ов. Перед и после вызова контроллера вызываются все подходящие для этой URL Interceptor’ы. При этом в метод preHandle возвращает true или false, чтобы указать, может ли продолжаться обработка и вызов последующих Interceptor’ов
Шаг N
Следующим шагом можно добавить в интерфейс Modification метод matches, в котором бы производилась проверка шагов к отдельному аттрибуту одежды:
interface Modification { Одежда modify(Одежда одежда); default matches(Одежда одежда) {return true;} }
За счёт этого можно слегка упростить логику в методах modify, переместив проверки на классы и свойства в отдельный метод.
Похожий подход применяется в Spring (Request)Filter, но основная разница в том, что каждый Filter является обёрткой вокруг следующего и явно вызывает FilterChain.doFilter для продолжения обработки.
Итого
Конечный результат сильно отличается от начального варианта. Сравнив их можно сделать следующие выводы:
- Реализация на основе Pipes & Filters упрощает сам класс
Modifier. - Лучше распределены обязаности и «слабые» связи между компонентами.
- Проще тестировать отдельные шаги.
- Проще добавлять и удалять шаги.
- Немного сложнее тестировать целую цепочку фильтров. Нужны уже IntegrationTests.
- «Больше» классов
В конечном итоге более удобный и гибкий вариант, чем изначальный.
К тому же можно просто распараллелить обработку данных, используя тот же parallelStream.
Что не решает данный пример
- В описании паттерна сказано, что отдельные фильтры можно переиспользовать, создав другую цепочку фильтров (канал).
- С одной стороны это легко осуществить, используя
@Qualifier. - С другой стороны, задать иной порядок с помощью
@Order, не получится.
- С одной стороны это легко осуществить, используя
- Для более сложных примеров придётся использовать несколько цепочек, использовать вложеные цепочки, и всё таки менять уже имеющуюся реализацию.
- Так например задача: «для каждого носка искать пару и складывать их в один экземпляр <? extends Одежда>» плохо впишется в описанную реализацию, т.к. придётся теперь для каждого носка перебирать всё бельё и изменять начальный список данных.
- Для решения можно написать новый интерфейс, принимающий и возвращающий List<Одежда> и передать в новую цепочку. Но нужно быть осторожным с последовательностью вызовов самих цепочек, если носки можно зашить только по отельности.
Спасибо за внимание
ссылка на оригинал статьи https://habr.com/ru/post/479464/
Добавить комментарий