Pipes & Filters. Пример применения и реализации при помощи Spring

от автора

В данной статье речь пойдёт о применении паттерна 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.

Что не решает данный пример

  1. В описании паттерна сказано, что отдельные фильтры можно переиспользовать, создав другую цепочку фильтров (канал).
    • С одной стороны это легко осуществить, используя @Qualifier.
    • С другой стороны, задать иной порядок с помощью @Order, не получится.
  2. Для более сложных примеров придётся использовать несколько цепочек, использовать вложеные цепочки, и всё таки менять уже имеющуюся реализацию.
    • Так например задача: «для каждого носка искать пару и складывать их в один экземпляр <? extends Одежда>» плохо впишется в описанную реализацию, т.к. придётся теперь для каждого носка перебирать всё бельё и изменять начальный список данных.
    • Для решения можно написать новый интерфейс, принимающий и возвращающий List<Одежда> и передать в новую цепочку. Но нужно быть осторожным с последовательностью вызовов самих цепочек, если носки можно зашить только по отельности.

Спасибо за внимание


ссылка на оригинал статьи https://habr.com/ru/post/479464/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *