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

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

Заключение

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

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

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

Комментарии (0)