- PVSM.RU - https://www.pvsm.ru -
В данной статье речь пойдёт о применении паттерна 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.
Первое, что бросается в глаза, это частый перебор всей одежды. Так что первым шагом мы всё перемещаем в один цикл, а так же переносим проверку на чистоту в начало цикла:
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
.
Шаблон каналов и фильтров [1] описывает подход [2], в котором входящие данные проходят несколько этапов обработки.
Попробуем применить этот подход к нашему коду
На самом деле, наш код уже близок к этому паттерну. Полученные данные проходят несколько независимых шагов. Пока что, каждый метод это фильтр, а сам 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
.
Упростим код, используя 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 вручную передавать все известные имплементации. Это поможет решить проблему сортировки, но снова усложнит добавление новых шагов.
Теперь же вынесем проверку на чистоту в отдельный шаг. Для этого перепишим наш интерфейс так, чтобы он всегда возвращал значение:
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'ов
Следующим шагом можно добавить в интерфейс Modification
метод matches
, в котором бы производилась проверка шагов к отдельному аттрибуту одежды:
interface Modification {
Одежда modify(Одежда одежда);
default matches(Одежда одежда) {return true;}
}
За счёт этого можно слегка упростить логику в методах modify
, переместив проверки на классы и свойства в отдельный метод.
Похожий подход применяется в Spring (Request)Filter, но основная разница в том, что каждый Filter является обёрткой вокруг следующего и явно вызывает FilterChain.doFilter для продолжения обработки.
Конечный результат сильно отличается от начального варианта. Сравнив их можно сделать следующие выводы:
Modifier
.В конечном итоге более удобный и гибкий вариант, чем изначальный.
К тому же можно просто распараллелить обработку данных, используя тот же parallelStream.
@Qualifier
.@Order
, не получится.Автор: geaker
Источник [3]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/java/340127
Ссылки в тексте:
[1] Шаблон каналов и фильтров: https://docs.microsoft.com/ru-ru/azure/architecture/patterns/pipes-and-filters
[2] описывает подход: https://medium.com/nuances-of-programming/%D0%BA%D1%80%D0%B0%D1%82%D0%BA%D0%B8%D0%B9-%D0%BE%D0%B1%D0%B7%D0%BE%D1%80-10-%D0%BF%D0%BE%D0%BF%D1%83%D0%BB%D1%8F%D1%80%D0%BD%D1%8B%D1%85-%D0%B0%D1%80%D1%85%D0%B8%D1%82%D0%B5%D0%BA%D1%82%D1%83%D1%80%D0%BD%D1%8B%D1%85-%D1%88%D0%B0%D0%B1%D0%BB%D0%BE%D0%BD%D0%BE%D0%B2-%D0%BF%D1%80%D0%B8%D0%BB%D0%BE%D0%B6%D0%B5%D0%BD%D0%B8%D0%B9-81647be5c46f
[3] Источник: https://habr.com/ru/post/479464/?utm_campaign=479464&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.