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