Всем привет, я — Дмитрий Нуждин, Backend Java разработчик в ГК Иннотех. Хотел поделиться небольшой историей о том, как я писал spring boot стартер для создания сквозной логики в приложениях через конфигурацию приложения (application.yaml), не затрагивая исходный код.
Сначала проведу небольшой экскурс в аспекты для тех, кто не сталкивался/редко сталкивался с ними, для того, чтобы появилось понимание, зачем вообще был создан стартер «динамических» аспектов. Затем уже расскажу про сам стартер и решаемые им задачи. Отдельно хочу отметить, что в механизм работы самих аспектов в спринге погружаться не планирую, ведь на эту тему написаны сотни статей, да и у спринга есть неплохая документация на этот счёт.
Исходный код стартера на Github
Стартер можно подтянуть с Github Packages:
<dependency> <groupId>com.nuzhd</groupId> <artifactId>spring-boot-dynamic-aspects-starter</artifactId> <version>1.0.0</version> </dependency>
Как мы обычно создаем аспекты с помощью Spring AOP
Допустим, у нас есть некий OrderService, занимающийся в приложении всем, что связано с заказами:
public class OrderService { public String getOrderStatus(Long orderId) { ... } public Order createOrder(OrderCreateDto dto) { var order = new Order(); ... return order; } }
И вот нам прилетает задача — добавить логирование на методы OrderService, а конкретно — логировать входные параметры и результаты работы методов. Сама задача тривиальная и мы сразу берёмся за неё. Мы, как полагается настоящему инженеру, всегда знаем несколько способов решения проблемы, поэтому в данной ситуации воспользуемся Spring AOP, реализовав сквозную логику логирования с помощью аспекта, который будет выполняться до и после вызова любого из методов OrderService (важное уточнение — для private методов аспекты не сработают, таковы ограничения Spring, связанные с его механизмами проксирования).
Без лишних слов, перейдем к коду:
@Aspect @Component public class OrderServiceAspect { private static final Logger LOGGER = LoggerFactory.getLogger(OrderServiceAspect.class); @Around("execution(* com.example.sevice.OrderService.*(..))") public void beforeCallAtMethod1(JoinPoint jp) { LOGGER.debug("Логирование до вызова метода, аргументы: {}", jp.getArgs()); var result = jp.proceed(); LOGGER.debug("Логирование после вызова метода, результат: {}", result); return result; } }
Выше я привел самый простой пример того, как мы могли бы создать аспект для логирования, часто используют аннотации типа @Pointcut, другие типы выражений (@Before, @After) и так далее и тому подобное
В целом, на этом задача закончена — при вызове public методов OrderService их аргументы и результаты работы будут успешно логироваться (конечно, если метод выкинет Exception, результат его работы мы не залогируем, что логично). И вроде бы все хорошо, но через пару недель логирование нас просят убрать — им пользовались только тестировщики на стенде для проведения регресса. Что‑ж... не проблема, просто удаляем аспект и живем дальше.
А можно без этого???
Что если я скажу вам, что можно было не трогать исходный код вообще, а вынести создание аспекта в конфигурацию приложения. В этом случае достаточно перезагрузить под, и новая сквозная логика (логирование, в нашем кейсе) заработает как ни в чем не бывало. Разработчика при таком раскладе можно вообще не привлекать к подобного рода задаче, а просто объяснить один раз тестировщикам, как посредством конфига приложения создать эти самые «динамические аспекты». Дальше я как раз и собираюсь описать, как можно упаковать такой функционал в отдельный стартер, который и будет заниматься созданием наших аспектов из конфига.
Текущие ограничения стартера
Прежде всего, стоит сделать небольшую оговорку — стартер в его текущей реализации может добавлять только логирование — то есть мы можем динамически создать аспект типа @Around, @Before или @After, который будет выполнять только логирование вокруг, до или после метода соответственно (с поддержкой некоторых темплейтов). Дело в том, что изначально мы хотели убрать логи из кода — поэтому стартер создавался именно с такой мыслью. Но если предполагать дальнейшее развитие стартера как самостоятельного проекта, то можно придумать и множество других задач, которые будут выполнять аспекты — измерение времени выполнения методов, подмена аргментов/результата выполнения метода — ограничения зависят только от нашей фантазии. Конечно, это потребует некоторой переработки кодовой базы, но это лишь вопрос времени и ресурсов.
Реализация стартера
Я начну с сущностей, которыми оперирует стартер, их немного:
public class DynamicAspectDto { private String expression; private String customBeforeMessage; private String customAfterMessage; public String getExpression() { return expression; } public void setExpression(String expression) { this.expression = expression; } public String getCustomBeforeMessage() { return customBeforeMessage; } public void setCustomBeforeMessage(String customBeforeMessage) { this.customBeforeMessage = customBeforeMessage; } public String getCustomAfterMessage() { return customAfterMessage; } public void setCustomAfterMessage(String customAfterMessage) { this.customAfterMessage = customAfterMessage; } }
Это обычный DTO, представляющий собой конкретный аспект. Как видно, тут всего три поля, expression — само pointcut выражение, его синтаксис никак не отличается от тех выражений что мы используем при создании обычных аспектов в Java коде. Поясню по поводу сообщений (customBeforeMessage и customAfterMessage) — в этих полях вы можете указать свои сообщения, которые будут попадать в лог, и тут поддерживаются некоторые темплейты, о которых я расскажу позже. Однако, использование этих полей необязательно и в конфиге их можно опустить — стартер возьмет стандартные сообщения, заданные в его properties файлах.
Дальше у нас просто енамка с типом аспектов, которые мы можем создать
public enum AdviceType { BEFORE, AFTER, AROUND }
Поддержка других типов, если появится в них необходимость, в перспективе может быть добавлена в стартер. Но нам пока что хватает этих, не жалуемся.
Ну и последнее, это так называемые Designators (не знаю, как это лучше перевести на русский), они сообщают механизму Spring AOP, чему именно должен соответствовать pointcut. Вообще в самом AOP их огромное множество, но именно Spring AOP поддерживает лишь 9 из них, добавляя свой собственный тип — bean:
public enum DesignatorType { EXECUTION("execution"), WITHIN("within"), THIS("this"), TARGET("target"), ARGS("args"), AT_TARGET("@target"), AT_ARGS("@args"), AT_WITHIN("@within"), AT_ANNOTATION("@annotation"), BEAN("bean"), INVALID("invalid"); private final String value; public String getValue() { return value; } public static String[] getValues() { return Arrays.stream(DesignatorType.values()) .filter(designatorType -> !INVALID.equals(designatorType)) .map(DesignatorType::getValue) .toArray(String[]::new); } public static DesignatorType fromValue(String designator) { return Arrays.stream(DesignatorType.values()) .filter(designatorType -> !INVALID.equals(designatorType)) .filter(designatorType -> designatorType.value.equalsIgnoreCase(designator)) .findFirst() .orElse(DesignatorType.INVALID); } DesignatorType(String value) {this.value = value;} }
Также добавлен INVALID тип, говорящий нам о том, что в выражении указан некорректный Designator.
Важный класс CutsomPointcutExpession:
public class CustomPointcutExpression extends AspectJExpressionPointcut { private static final Logger LOGGER = LoggerFactory.getLogger(CustomPointcutExpression.class); private final Map<DesignatorType, PointcutValidationService> validators; private final MessageSource messageSource; public CustomPointcutExpression(Map<DesignatorType, PointcutValidationService> validators, MessageSource messageSource) { this.validators = validators; this.messageSource = messageSource; } @Override protected void onSetExpression(String expression) throws IllegalArgumentException { DesignatorType designatorType = DesignatorType.fromValue( StringUtils.trim( StringUtils.substringBefore(expression, "(") ) ); if (INVALID.equals(designatorType)) { throw new IllegalArgumentException(messageSource.getMessage( INVALID_DESIGNATOR_KEY, new Object[] {Arrays.toString(DesignatorType.getValues())}, Locale.ROOT) ); } String expressionBody = extractExpression(expression, designatorType); var validator = validators.get(designatorType); if (validator == null) { LOGGER.warn(messageSource.getMessage(VALIDATOR_NOT_FOUND, new Object[] {designatorType}, Locale.ROOT)); return; } validator.validateExpression(expressionBody); } }
Здесь мы наследуемся от спрингового AspectJExpressionPointcut, переопределяя его метод onSetExpression(String expression), в который помещаем свою логику валидации pointcut выражений.
Валидаторы лежат в отдельном пакете и наследуются от PointcutValidationService. Здесь мы получаем нужный нам валидатор в зависимости от Designator'а и валидируем выражение с помощью него. Пока что из валидаторов реализован лишь ExecutionPointcutValidationService для валидации execution выражений (они наиболее часто используемые), остальные валидаторы — заглушки.
Итак, у нас есть некие сущности, которые помогут нам удобно смаппить конфиги из приложения, использующего стартер, и динамически создать с помощью них бины, содержащие в себе саму логику аспектов. Именно этим и займется наш класс DynamicAspectsCreator:
public class DynamicAspectsCreator implements BeanFactoryPostProcessor { private static final Logger LOGGER = LoggerFactory.getLogger(DynamicAspectsCreator.class); private final Map<AdviceType, Set<DynamicAspectDto>> adviceTypeToAspectDtos = new EnumMap<>(AdviceType.class); private static final String POINTCUTS_BASE_PATH = "app.dynamic-aspects.pointcuts"; private static final String POINTCUTS_AROUND_PATH = "app.dynamic-aspects.pointcuts.around"; private static final String POINTCUTS_BEFORE_PATH = "app.dynamic-aspects.pointcuts.before"; private static final String POINTCUTS_AFTER_PATH = "app.dynamic-aspects.pointcuts.after"; private static final String POINTCUTS_EMPTY_KEY = "dynamic.aspects.expressions.empty"; private static final String CANT_CREATE_ASPECT_KEY = "dynamic.aspects.error.cant-create-aspect"; private static final String CREATE_ASPECT_SUCCESS_KEY = "dynamic.aspects.success.created-aspect"; private final Map<DesignatorType, PointcutValidationService> validators; private final MessageSource messageSource; public DynamicAspectsCreator( Environment environment, MessageSource messageSource, Map<DesignatorType, PointcutValidationService> validators ) { var binder = Binder.get(environment); adviceTypeToAspectDtos.putAll( Map.of( AdviceType.AROUND, binder.bind(POINTCUTS_AROUND_PATH, Bindable.setOf(DynamicAspectDto.class)).orElse(Set.of()), AdviceType.BEFORE, binder.bind(POINTCUTS_BEFORE_PATH, Bindable.setOf(DynamicAspectDto.class)).orElse(Set.of()), AdviceType.AFTER, binder.bind(POINTCUTS_AFTER_PATH, Bindable.setOf(DynamicAspectDto.class)).orElse(Set.of()) ) ); if (adviceTypeToAspectDtos.entrySet().stream().allMatch(entry -> entry.getValue().isEmpty())) { LOGGER.warn(messageSource.getMessage(POINTCUTS_EMPTY_KEY, new Object[] {POINTCUTS_BASE_PATH}, Locale.ROOT)); } this.messageSource = messageSource; this.validators = validators; } @Override public void postProcessBeanFactory(@NotNull ConfigurableListableBeanFactory beanFactory) throws BeansException { for (var entry : adviceTypeToAspectDtos.entrySet()) { createAdvices(beanFactory, entry.getKey(), entry.getValue()); } } private void createAdvices(ConfigurableListableBeanFactory beanFactory, AdviceType adviceType, Set<DynamicAspectDto> aspects) { for (var aspect : aspects) { var pointcut = new CustomPointcutExpression(validators, messageSource); try { pointcut.setExpression(aspect.getExpression()); } catch (IllegalArgumentException e) { LOGGER.error(messageSource.getMessage( CANT_CREATE_ASPECT_KEY, new Object[] {aspect.getExpression(), e.getMessage()}, Locale.ROOT ) ); continue; } var beanDefinition = BeanDefinitionBuilder.genericBeanDefinition(DefaultPointcutAdvisor.class) .addPropertyValue("pointcut", pointcut) .addPropertyValue("advice",createInterceptor(adviceType, aspect)) .setRole(ROLE_INFRASTRUCTURE) .getBeanDefinition(); ((BeanDefinitionRegistry) beanFactory).registerBeanDefinition( UUID.randomUUID().toString(), beanDefinition ); LOGGER.info(messageSource.getMessage(CREATE_ASPECT_SUCCESS_KEY, new Object[] {aspect.getExpression()}, Locale.ROOT) ); } } private MethodInterceptor createInterceptor(AdviceType adviceType, DynamicAspectDto aspect) { return switch (adviceType) { case BEFORE -> new MethodBeforeInterceptor(messageSource, aspect.getCustomBeforeMessage()); case AFTER -> new MethodAfterInterceptor(messageSource, aspect.getCustomAfterMessage()); case AROUND -> new MethodAroundInterceptor(messageSource, aspect.getCustomBeforeMessage(), aspect.getCustomAfterMessage() ); }; } }
Особенности реализации, которые стоит рассмотреть:
Наследуемся от
BeanFactoryPostProcessor— с его помощью можно динамически добавлять новые bean‑определения или заменять одни bean‑определения на другие (нам нужно первое). Таким образом, мы динамически регистрируем нужные намBeanDefinition, которые содержат в себе логику динамических аспектов.Наследование от вышеупомянутого процессора лишает нас возможности читать конфиги через
@Value, поэтому используем здесь спринговыйBinder, по сути решающий ту же задачу — получение значений из конфигов приложения. Для его создания нам нужен текущийEnvironmentприложения.Сами аспекты складываем в
EnumMapс ключомAdviceType— тип аспекта (вокруг/до/после метода) и значениемSet<DynamicAspectDto>— множество аспектов, относящихся к конкретному типу. Таким образом, для каждого аспекта будет понятно, когда именно он должен вызываться. Формат данных тут — спорная вещь, и когда‑то, возможно, он будет пересмотрен.Для создания бина создаем сначала
BeanDefinitionс типомDefaultPointcutAdvisor, и проставляем ему два свойства: pointcut — объектAspectJExpressionPointcut(мы используемCustomPointcutExpression— его наследник) и advice — туда кладем интерсептор, содержащий саму логику аспекта. В стартере сейчас 3 интерсептора для 3 поддерживаемых типов аспектов. Все, что нас остается — зарегистрировать с помощьюBeanDefinitionRegistryнашBeanDefinition. После этого действия можно сказать, что наш динамический аспект создан и готов к работе.
А вот пример одного из перехватчиков — MethodAroundInterceptor
public class MethodAroundInterceptor implements MethodInterceptor { private static final Logger LOGGER = LoggerFactory.getLogger(MethodAroundInterceptor.class); private static final String METHOD_CALLED_KEY = "dynamic.aspects.info.method_called"; private static final String METHOD_EXECUTED_KEY = "dynamic.aspects.info.method_executed"; private final String customBeforeMessage; private final String customAfterMessage; private final CompiledTemplate beforeTemplate; private final CompiledTemplate afterTemplate; private final MessageSource messageSource; public MethodAroundInterceptor( MessageSource messageSource, String customBeforeMessage, String customAfterMessage) { this.messageSource = messageSource; this.customBeforeMessage = customBeforeMessage; this.customAfterMessage = customAfterMessage; this.beforeTemplate = CustomMessagesProcessor.compile(customBeforeMessage); this.afterTemplate = CustomMessagesProcessor.compile(customAfterMessage); } @Nullable @Override public Object invoke(@NotNull MethodInvocation invocation) throws Throwable { var fullMethodName = extractFullMethodName(invocation); var argsMessage = hasArgs(invocation) ? getArgsAndValues(invocation) : "[NO ARGS]"; LOGGER.info( StringUtils.isBlank(customBeforeMessage) ? messageSource.getMessage(METHOD_CALLED_KEY, new Object[] {fullMethodName, argsMessage}, Locale.ROOT) : beforeTemplate.render(invocation, argsMessage, null) ); var result = invocation.proceed(); var returnValue = hasReturnValue(invocation) ? result : "[NO RETURN VALUE]"; LOGGER.info( StringUtils.isBlank(customAfterMessage) ? messageSource.getMessage(METHOD_EXECUTED_KEY, new Object[] {fullMethodName, returnValue}, Locale.ROOT) : afterTemplate.render(invocation, argsMessage, returnValue) ); return result; } }
Оставшиеся два интерсептора по сути являются частичными копиями приведенного выше, реализуя только логику до/после вызова целевого метода.
Пример конфигурации
Итак, немного пролив свет на то, как устроен стартер изнутри, я бы хотел показать пример типичной конфигурации, которая может быть использована для создания «динамического» аспекта:
dynamic-aspects: enabled: true pointcuts: around: - expression: execution(* com.example.Service.myMethod(..)) custom_before_message: "Метод {methodName} был вызван с аргументами\n{args}" custom_after_message: "Метод {fullMethodName} успешно выполнился и вернул\n{retVal}" before: after:
Как видно из конфига, функционал стартера можно выключать, дабы не нагружать приложение сканированием и созданием лишних бинов. Далле, мы создали один аспект с типом AROUND с выражением, указанным в expression. Если выражение оказывается некорректным (повторюсь, что на данный момент более менее тщательно валидируются лишь execution выражения — остальное — на ващей совести), то мы увидим в логах сообщение о том, что аспект создан не был, и причину. Само приложение спокойно запустится. Секции before и after в целом можно тоже не указывать, так как они пусты. Я добавил их в пример для наглядности.
Кастомные сообщения также не являются обязательными — стартер использует стандартные из своих properties файлов. Таким образом, минимальный конфиг с одним аспектом может выглядеть так:
dynamic-aspects: enabled: true pointcuts: around: - expression: execution(* com.example.Service.myMethod(..))
Шаблонизация кастомных сообщений
На текущий момент в сообщениях поддерживаются следующие шаблоны:
{methodName} — Имя метода без пакета (Simple Method Name)
{fullMethodName} — Полное имя метода (Qualified Method Name)
{args} — Параметры метода в виде Map (ключ — имя аргумента, значение — значение аргумента). Если метод ничего не принимает, то увидим [NO_ARGS].
{retVal} — Значение, которое вернул метод. Если метод ничего не возвращает, то увидим [NO RETURN VALUE]
{arg:ARG_NAME} — Значение конкретного аргумента метода (ARG_NAME должно совпадать с именем метода в сигнатуре). При некорректном ARG_NAME мы увидим [UNKNOWN_ARG:ARG_NAME].
Если есть варианты иных шаблонов для кастомных сообщений, буду рад прочитать в комментариях.
Заключение
Хочу отметить, что есть много направлений, по которым стартер можно улучшить — добавить валидацию для всех типов выражений, новые темплейты для кастомных сообщений, придумать новые типы перехватчиков (не только логирующих). Всеми этими вещами я, возможно, когда‑то займусь, но это не точно. А вообще, было бы интересно услышать и ваши предложения/мнения по поводу всего того, что я тут расписал. Мне будет интересно почитать!
Какова цель данной статьи? Хотелось поделиться с сообществом одной из задач, которая лично мне показалась интересной и весьма специфичной. Думаю, многие согласятся с тем, что когда работа превращается в рутину, такие задачи кажутся чем‑то новым и необычным — как глоток свежего воздуха.
На этом у меня, пока что, всё. Возможно, у этой статьи когда‑то появится вторая часть. Буду рад любой обратной связи и вашим замечаниям.

