«Динамические» аспекты — как я писал стартер для создания аспектов прямо из конфига

—

от автора

Всем привет, я — Дмитрий Нуждин, 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@Componentpublic 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].

Если есть варианты иных шаблонов для кастомных сообщений, буду рад прочитать в комментариях.

Заключение

Хочу отметить, что есть много направлений, по которым стартер можно улучшить — добавить валидацию для всех типов выражений, новые темплейты для кастомных сообщений, придумать новые типы перехватчиков (не только логирующих). Всеми этими вещами я, возможно, когда-то займусь, но это не точно. А вообще, было бы интересно услышать и ваши предложения/мнения по поводу всего того, что я тут расписал. Мне будет интересно почитать!

Какова цель данной статьи? Хотелось поделиться с сообществом одной из задач, которая лично мне показалась интересной и весьма специфичной. Думаю, многие согласятся с тем, что когда работа превращается в рутину, такие задачи кажутся чем-то новым и необычным — как глоток свежего воздуха.

На этом у меня, пока что, всё. Возможно, у этой статьи когда-то появится вторая часть. Буду рад любой обратной связи и вашим замечаниям.

ссылка на оригинал статьи https://habr.com/ru/articles/1089254/