Всем привет! Меня зовут Макс, и уже 14 лет как я вошел в ИТ и пока не планирую отсюда выходить. Последние 7 лет я не только сам пишу код, но и занимаюсь «выпасом котов». К написанию статьи меня побудила моя любовь к ссылкам на методы и желание поделится «кастомными» способами их использовать вне базовых классов java. Если вы задумывались об усилении гибкости приложения и преимуществах AOP, но вас отпугивают его недостатки, в статье предлагаю рассмотреть вариант получения тех же (ну или почти тех же преимуществ), но без раздражающих (по крайней мере меня) недостатков. Для использования идей из статьи не требуется каких-то особых магических знаний, достаточно знать, что такое функциональный интерфейс.
Оглавление
-
Зачем вообще меня понесло в сторону экстремальной гибкости?
-
А может у меня и так гибкий код?
-
Какие ваши требования?
-
Ну-с, начнем, пожалуй…
-
Взгляд внутрь
-
Ну и зачем все это?
Зачем вообще меня понесло в сторону экстремальной гибкости?
Моей основной работой сейчас стал запуск MVP продуктов для проверки гипотез начальства/бизнеса. С этим все неплохо, однако есть одно «но»: иногда идеи все-таки «выстреливают». Я знаю два вида развития событий после того, как MVP выстрелил:
-
Выбросить все, что вы написали. Но это значит, бизнес останется без новых доработок на квартал так точно. Не уверен, что кто-то выбирал этот путь в реальности, но в теории он существует
-
Тянуть с тем, что получилось в линейном развитии в попытках привести код к приличной архитектуре параллельно с новыми доработками. Прямой путь к выгоранию и переходу ваших сотрудников в мир иной другую контору. Ну и, конечно же, к увеличению стоимости каждой новой доработки, хоть и замечают это уже, когда исполняется программе года два, или три. До этого стоимость маскируется за «не зрелыми» процессами, командой на стадии «storming» и прочими классическими объяснениями. Основной стратегической задачей моего отдела с помощью архитектурных, организационных и технологических подходов сделать этот переход бесшовным. Возможность расширять логику обработки данных, не меняя уже написанного кода, один из архитектурных подходов, который мне нужен для достижения этой цели (не единственный, естественно).
А может у меня и так гибкий код?
Вообще гибкий код наверняка можно написать и по-другому, ниже раскрыт только мой способ, да и то в части функций. Так может, ваш код и так достаточно гибкий, и нет смысла читать нагромождение букв и кода, который я буду писать дальше?
Для начала я поделюсь тремя способами определения гибкости кода, которые знаю:
-
теоретический (системный анализ)
-
мысленный эксперимент
-
эмпирический (ретроспективный) Буквально несколько слов о каждом.
Теоретический
Первый способ идет из теории системного анализа, ознакомится можно в «Азбуке системного мышления, Медоуз» или выбрать более глубокую литературу по теме. Гибкость системы — это способностью системы оставаться системой при изменении ее функциональных и структурных элементов, или взаимосвязей между ними. Обычно в таких книгах исходят из теории, что все есть система — потому и в разработке стоит отнестись ко всему, как к системе. Гибкость должна быть применима и к методам, и к классам, и к отдельному модулю, и к приложению в целом.
Данный способ довольно сложно формализуем (точнее все участники процесса должны быть хорошо подготовлены в области логики и искусства спора). Так что скорее всего вызовет многочисленные дебаты, а результата добиться не получится.
Мысленный эксперимент
Второй способ является прикладным вариантом первого, тут достаточно уметь задавать вопросы вида «а что если .. (нужное подставить)?». Он позволяет чуть более практично подойти к оценке с точки зрения возможных сценариев. Если все ваши эксперименты увенчаются успехом — при любых изменениях потребуется изменения не более пары строчек в коде, — можно считать, что ваш код достаточно гибкий. С опытом количество таких вопросов будет расти, так что если в первый раз что-то не будет учтено, это не страшно, в следующий раз обязательно получится.
Эмпирический
Ну и третий способ, ретроспективный. Если вы сами себе говорите, или часто слышите от коллег: «Я написал такой классный код, но пришел заказчик и теперь все переписывать» — ваш код точно не гибкий. Это самый простой и не требующей дополнительной подготовки способ.
Еще три флага для определения недостаточной гибкости кода:
-
При каждом изменении в требованиях команда издает тихий глубокий вздох, или громкий нецензурный вопль
-
Чтобы внести изменения в код коллеги, надо у него спросить, как это сделать
-
Если вы внесли изменения не спросив, половина проекта решит прилечь отдохнуть Немного философии по этой теме. Наверно, согласно критерию фальсифицируемости К. Поппера — это лучший способ. Поскольку мы не можем доказать, что наш код гибкий — мы можем только опровергнуть этот факт, когда придет время. Дополнительно при постоянном использование можно рано или поздно выйти на понимание той идеи, что код не может быть абсолютно гибким, он гибок относительно тех, требований, что приходят от заказчика сейчас. Но надо быть готовым, что рано или поздно заказчик придет с «Черным лебедем»
Какие ваши требования?
Попробуем посмотреть, есть ли в мире ИТ подходы, которые могут решить мою проблему без меня, ведь ходят слухи, что в мире ИТ уже сделали абсолютно все.
Попробую сформулировать требования, которые возникли в моей голове для реализации наиболее гибкого механизма:
-
механика должна иметь возможность вызвать любой метод в проекте, не нарушая инкапсуляции
-
в любой момент можно вставить в коде предварительную обработку запроса или обработку ответа метода
-
в любой момент можно вставить в коде обертку над выполняемым методом
-
обработчиков можно вставить сколь угодно много
-
можно управлять последовательностью вызовов обработчиков из места вызова кода
-
механизм должен поддерживать преобразование типов запроса и ответа
-
вызов может быть как синхронным, так и асинхронным
-
результат мог бы быть доставлен не только в точку вызова, но и в любой другой метод приложения Если сократить до одного тезиса — я хочу как можно больше возможностей контроля и расширения исполнения существующего метода в точке его вызова.
Аспектно-ориентированное программирование
Схожими идеями обладает концепция аспекто-ориентированного программирования (АОП). Для анализа возможностей я взял несколько статей. Собственно вот этих (Spring AOP на JavaRush,AspectJ на JavaRush, Spring AOP Habr ну и дока Spring взятая за основу предыдущей статьи).
В статьях довольно подробно, хотя на мой взгляд, и не полно, раскрыт вопрос работы с AOP на примере AspectJ. Мне не хватило понимания:
-
как отработают некоторые из советов (advice) при выбросе исключения в основной функции
-
можно ли вставить несколько однотипных советов(advice) перед одной и той же функцией, не будут ли они конфликтовать
-
можно ли модифицировать, параметры приходящие на вход основной функции, достав их через механизм JoinPoint внутри совета
-
как выглядит подробная диаграмма последовательности вызовов при вставке нескольких советов одновременно Но так как моей основной задачей было именно понять возможности, то копать глубже я не стал. Кстати, последний вопрос привел меня еще к одному требованию к собственному механизму — возможность включать и отключать side effects при работе (а точнее запрещать мутации в предварительной обработке запроса или обработке ответа метода).
Вот список возможностей АОП, который у меня получился:
-
возможность вставить часть кода в следующих случаях:
-
до вызова метода
-
вместо вызова (с возможностью вызвать основной метод)
-
после вызова метода
-
всегда
-
при штатном выполнении
-
в исключительной ситуации
-
-
-
возможность произвести модификацию кода
-
при компиляции
-
в байткоде
-
в рантайме
-
В первом пункте мне кажется довольно избыточным деление вызова после выполнения метода, а также скользким момент с выбросом исключения, в частности постобработка в исключительной ситуации. Должно ли пробрасываться после обработки исключения исключение дальше, того же типа? Или же она наоборот постобработка обязана закончится без исключения?
Пока буду предполагать, что если обработка исключений и возможна, то лучше всего оставить ее в случае «замещение вызова с возможностью вызвать основной метод» из моих требований.
Теперь немного о втором пункте — модификация кода. Признаюсь честно, я не фанат модификации кода ни в каком из случаев, потому что предпочитаю WYSIWYG подход к разработке (Прим. Предпочитаю, чтобы в рантайме исполнялось ровно то, что я написал, а не что-то другое. Конечно, при этом осознавая тот факт, что мой код модифицируется и javac и jit компиляторами, но принимая его как неизбежное зло для достижения оптимизации производительности). Оригинально значение термина WYSIWYG тут
Также подобный способ не позволяет достигнуть необходимого мне «контроля». Итог, в целом этот подход может решить мою задачу, но с другого ракурса и способом, который мне не нравится.
При этом не сколько не умаляю достоинств AspectJ, как библиотеки, с помощью которой можно глубже (и, возможно, быстрее) исследовать код неизвестной вам библиотеки, ну или, уж если очень нужно реализовать механизм модификации «чужих» либ без их изменения, как в статье Напильники бывают разные или повествование про «напильник» для java программ
Библиотека vavr
Сама библиотека позиционируется как способ добавить «функциональщину» в Java. Но один из кейсов, как раз мой прикладной кейс использования: встраивание различных видов валидации до и после вызова метода. Поэтому решил посмотреть возможности хотя бы на примере обзорной статьи Функциональные коллекции в Java с Vavr: обзор и применение.
Почитав про нее могу сказать, что мои требования она не закроет. Единственное, что меня зацепило: там тоже есть про вызов в случае ошибки. Т.е. очень уж нужная всем функциональность, ну что, по правде говоря, звучит логично с учетом синтаксиса обработки исключительных ситуаций (по крайней мере в Java). Ну и, конечно, меня потянуло добавить таких фишек, как композиция, каррирование и мемоизация, но я решил не распаляться, по крайней мере на текущем этапе.
Финальная версия требований
-
Механика должна иметь возможность вызвать любой метод в проекте, не нарушая инкапсуляции
-
В любой момент можно вставить в коде предварительную обработку запроса или обработку ответа метода
-
В любой момент можно вставить в коде обертку над выполняемым методом
-
Обработчиков можно вставить сколь угодно много
-
Можно управлять последовательностью вызовов обработчиков из места вызова кода
-
Механизм должен поддерживать преобразование типов запроса и ответа
-
Вызов может быть как синхронным, так и асинхронным
-
Результат может быть доставлен не только в точку вызова, но и в любой другой метод приложения
-
Входные и выходные параметры можно защитить от мутаций
Ну-с, начнем, пожалуй…
В свое время я открыл одно замечательное свойство тестов — тесты, это не только проверка корректности работы части программы, но и первое место откуда будет вызываться код и именно в тесте можно узнать, удобно ли им будет пользоваться. А громоздкие, сложно читаемые тесты могут послужить флагом того, что вызываемые ими методы будут сложны в использовании. Потому мне и полюбился TDD — такой подход позволяет переформулировать вопрос из «как мне придется вызывать свои методы?» в вопрос «как я хочу вызывать свои методы?».
Поэтому для начала я покажу тесты, которые мой код вызывают. Проще всего это будет сделать с помощью сбора нужной строки (Листинг 1).
Листинг 1. Сбор строки
@Test void howItWorkTest(){ StringBuilder bld = new StringBuilder(); new MethodExecutor<>(this::methodBld) .addPreInterceptor(this::pre) .addWrapper(this::wrapper) .addPreInterceptor(this::pre2) .addPostInterceptor(this::post) .execute(bld); System.out.println(bld); }
Ну и собственно сами методы на которые передаются ссылки (Листинг 2).
Листинг 2. Методы изменения строки
StringBuilder methodBld(StringBuilder a) { return a.append("string"); } void pre(StringBuilder a) { a.append("pre_"); } void pre2(StringBuilder a) { a.append("pre2_"); } void post(StringBuilder a) { a.append("_post"); } StringBuilder wrapper(Function f, StringBuilder req) { req.append("wr_"); f.apply(req); req.append("_ap"); return req; }
Вот строка которую мы в итоге получим pre2_wr_pre_string_ap_post.
Как можно понять методы выполнились вот в такой последовательности:
-
pre2
-
pre
-
wrap до methodBld
-
methodBld
-
wrap после methodBld
-
post
Сделаем небольшое изменение для того, чтобы было понятно, как оно работает (Прим. такой подход использовал мой преподаватель по программированию в институте при приеме лабораторных — спрашивал, что будет делать ваш код, если поменять в нем две строчки местами: все, кто списал лабораторные, тут же отправлялись разбираться, что же делает их код на самом деле) (Листинг 3).
Листинг 3. Перенос последовательности вызовов
@Test void howItWorkTest(){ StringBuilder bld = new StringBuilder(); new MethodExecutor<>(this::methodBld) .addPreInterceptor(this::pre) .addPostInterceptor(this::post) .addWrapper(this::wrapper) .addPreInterceptor(this::pre2) .execute(bld); System.out.println(bld); }
И теперь мы получаем строку, где метод post выполняется уже внутри wrapper — pre2_wr_pre_string_post_ap.
Для тех, кто жить не может без лямбда выражений, код можно реализовать и без вспомогательных методов (Листинг 4).
Листинг 4. Все тоже, только с лямбдами
@Test void howItWorkTest() { StringBuilder bld = new StringBuilder(); new MethodExecutor<>(this::methodBld) .addPreInterceptor(r -> r.append("pre_")) .addPreInterceptor(r -> r.append("pre2_")) .addPostInterceptor(r -> r.append("_post")) .addWrapper((f, r) -> r.append("wr_").append(f.apply(r)).append("_ap")) .execute(bld); System.out.println(bld); }
Ну что ж в данном случае кажется, что кода меньше и выглядит даже читабельней.
Заметили, как последовательно развивается мир ИТ? Сначала мы говорим друг другу, давайте писать длинные и понятные названия классов и методов, чтобы было читабельно. А потом используем var, val и прочие контекстные способы определения типа переменной в компиляторах и ide, чтобы не занимало так много места на экране, чтобы было… читабельно.
Ну чтоб уйти от формата лабораторных работ, просто покажу небольшой кусочек реального кода, абсолютно не раскрывающего бизнес-логику приложения (рабочий код все ж таки), но показывающий, как просто это встраивается в реальном приложении (Листинг 5).
Листинг 5. Пример реального приложения
response = new MethodExecutor() .setMethod(dataManager::send) .addWrapper(CIRCUIT_BREAKER::execute) .execute(new AgileDataModel() .putStructure("entity", entity) .putStructure("content_type", "file") .putStructure("data", getFormatter(CSV_FORMATTER_NAME) .stringFromADM(csvContent) .getBytes(StandardCharsets.UTF_8)) .putStructure("file_name",request.getStructValue("file_name")));
Тут к уже существующему методу отправки файла по http, добавляем circuit breaker, который ограничивает вызовы стороннего сервиса, при определенных условиях. Формируем запрос и исполняем ранее написанный вызов по http, но у же с проверками состояния внешнего сервиса. Не стоит обращать внимание на прекрасные литералы в коде, это же реальный код, а не лабораторный, его писали реальные люди, под реальными сроками.
Вот так, наверно, будет выглядеть наиболее полный пример с перехватом исключений и их обработкой, а также асинхронным вызовом.
@Test void howItWorkTest() throws ExecutionException, InterruptedException, TimeoutException { StringBuilder bld = new StringBuilder(); CompletableFuture future = new MethodExecutor<>;(this::methodBld) .addPreInterceptor(r -> r.append("pre_")) .addPreInterceptor(r -> r.append("pre2_")) .addPostInterceptor(r -> r.append("_post")) .addWrapper((f, r) -> r.append("wr_").append(f.apply(r)).append("_ap")) .addWrapper(ExceptionHandler.DEFAULT .appendHandler(NullPointerException.class, (exc) -> new StringBuilder()) .appendHandler(ClassCastException.class, (exc) -> { throw new RuntimeException(exc); }) ::exceptionHandling) .executeAsync(null); System.out.println(future.get(1, TimeUnit.MINUTES)); }
Взгляд внутрь
Итак, надеюсь вам уже не терпится взглянуть внутрь. На самом деле, если вызвать этот класс просто — то внутри он еще проще (Листинг 6)
Листинг 6. Внутреннее устройство MethodExecutor (отрывок)
import java.util.LinkedList; import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; public class MethodExecutor<Req, Resp> { private final LinkedList<Consumer<Req>> preInterceptors = new LinkedList<>(); private final LinkedList<Consumer<Resp>> postInterceptors = new LinkedList<>(); private final Function<Req, Resp> method; public MethodExecutor(Function<Req, Resp> method) { this.method = method; } public Resp execute(Req req) { for (Consumer<Req> preInterceptor : preInterceptors) { preInterceptor.accept(req); } Resp resp = method.apply(req); for (Consumer<Resp> postInterceptor : postInterceptors) { postInterceptor.accept(resp); } return resp; } public MethodExecutor<Req, Resp> addWrapper(BiFunction<Function<Req, Resp>, Req, Resp> wrapper) { return new MethodExecutor<>(req -> wrapper.apply(this::execute, req)); } }
Полный код класса можно посмотреть тут
Внутри только несколько простых структур данных и пару циклов, ну ладно, еще к ним присоседилась одна лямбда.
Итак, подытожим существующие свойства:
-
preInterceptor’ы будут выполнятся в последовательности добавления до добавления следующего wrapper’а
-
postInterceptor’ы будут выполнятся в последовательности добавления до добавления следующего wrapper’а
-
wrapper’ы будут вызываться в порядке обратном добавлению (это немного нарушает мой WYSIWYG подход, но как говорилось в одном фильме: «У всех должны быть свои недостатки»)
-
код можно вызвать синхронно и асинхронно
Чуть наглядней будет посмотреть на рисунке.

Что легко и безболезненно можно расширить в данном классе:
-
добавить возможность вносить Interceptor’ы в начало цепочки вызовов
-
поиграться с Wrapper’ом таким образом, чтобы они выполнялись в порядке добавления (спорная тема, но кому как удобней)
-
дать возможность в preExecutor по какой-либо причине остановить цепочку вызовов и вернуть значение по default
-
Вернуть ответ в метод, ссылку на который передать вместе с вызовом А вот это можно добавить, чуть более болезненно:
-
дать возможность вставлять Wrapper внутрь уже существующих (одна из моих реализаций это позволяла, но там надо держать стек ссылок на методы, что усложняет реализацию, а на практике так и не применялось)
-
дать возможность Interceptor’ам работать не с реальным request, а с его копией, если делать в общем виде, придется ограничить параметры интерфейсом Cloneable — это позволит ограничить возможности по нарушению чистоты функций. Альтернативно можно реализовать класс-наследник с конкретным типом
-
дать возможность использовать не только Function, но и такие интерфейсы как Сonsumer, или даже свои собственные функциональные интерфейсы, для этого надо типизировать method executor не через типы входных и выходных параметров, а через тип функционального интерфейса с которым он работает
-
расширить функциональностью, представленной в библиотеке vavr
Ну и зачем все это?
Несмотря на то, что этот механизм является очень простым и по факту, как подсказал мой коллега, является просто композицией функций — я смог в командах добиться с помощью него довольно больших улучшений в коде проектов:
-
изоляция слоев, один слой не вызывает другой без шаблонных MethodExecutor
-
разделение кода для функциональных и нефункциональных требований
-
такие вещи, как метрики и логирование, встраиваются и изменяются отдельно от бизнес-кода. Причем в моем случае варианты по умолчанию встраиваются разом во всех сервисах всех проектов при обновление базовой библиотеки.
-
базовые таймауты, перепосылы, ограничения на количество или частоту вызовов могут быть настроены из конфигурации и при разработке «по-быстрому» разработка может об этом не заботиться, а настраивать позже по мере необходимости (прямо в продуктивной конфигурации).
-
отделение проверки данных на входе и на выходе от логики, что сильно подчищает код.
-
ну и наконец обработку рантайм эксепшенов без try catch по всему коду. В дальнейшем планирую расширять этот инструмент описанными выше доработками, чтобы получить дополнительные возможности.
Если у вас есть что предложить к добавлению в функциональность такой механики, буду рад прочитать в комментариях.
ссылка на оригинал статьи https://habr.com/ru/articles/896906/
Добавить комментарий