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

в 19:54, , рубрики: development, java, patterns, refactoring, spring, tutorial

В данной статье речь пойдёт о применении паттерна 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<Одежда> и передать в новую цепочку. Но нужно быть осторожным с последовательностью вызовов самих цепочек, если носки можно зашить только по отельности.

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

Автор: geaker

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js