Привет, Хабр! Меня зовут Игнат, в Samokat.tech я пишу плагины, автоматизации и интеграции для Jira. 

Как разработчик-самоучка, который до «вот этого всего» немного писал на Java, но не пользовался ни средствами сборки (привет, Maven!), ни фреймворками (привет, Spring!), и первые шаги делал по мануалам Atlassian, я сталкивался (и продолжаю) с проблемами, решений которых вендорская документация не подскажет, и эти решения приходится открывать самому. 

В этой статье мы вместе с вами напишем свою Spring-аннотацию, которая будет с помощью кастомного бин-постпроцессора (который мы тоже напишем) отключать в нашем плагине для Jira ненужный в проде отладочный функционал.

Эта статья ориентирована на начинающих разработчиков в стеке Atlassian и администраторов, пробующих себя в разработке плагинов для Jira. Те, кто до знакомства с Atlassian SDK уже разрабатывал «под ентерпрайз», не обязательно найдут здесь что-то новое, остальных – приглашаю под кат.


Зачем вообще что-то отключать?

Jira — достаточно гибкое приложение с множеством возможностей настройки и доработки функционала. Когда доработок средствами “коробки” уже не хватает, всегда можно психануть и написать свой плагин. Вендор поощряет такой подход, размещая значительное количество пошаговых инструкций для разработки плагинов “с нуля”. 

Однако этот путь все же не усыпан розами, особенно для тех, кто пытается делать свои первые шаги в области разработки.Во-первых, туториалы не всегда работают as-is. Во-вторых, Atlassian не ставит перед собой задачи научить читателя технологиям, на которых построен фреймворк. Существующие материалы просто иллюстрируют использование технологий в фреймворке на примере простейших задач – написать REST-эндпоинт, реализовать сохранение настроек в БД, разработать веб-форму и тому подобное.

Практически в каждом моем приложении есть отладочный REST-контроллер. Он удобен тем что, можно привязывать к его методам отдельные блоки разрабатываемой функциональности, что упрощает отладку, а Postman, в свою очередь — удобный инструмент, чтобы этот контроллер дергать, передавая ему необходимые параметры. 

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

Удалять отладочный класс из проекта совсем — не очень удобно, так как он точно понадобится при доработке проекта, добавлении новых фичей или поиске багов. Более удобный вариант — не включать ненужный функционал, если плагин понимает, что он работает не на SDK. Определить окружение можно, проверив baseUrl, установленный в хост-приложении; нужно только понять, как именно отключить этот отладочный функционал. 

Самый первый вариант, который я рассматривал — воспользоваться аннотацией Spring @Conditional, но при невозможности инстанциировать бин отладочного контроллера при установке плагин будет падать с ошибкой, не включаясь совсем. 

Еще один способ — повесить на все методы контроллера обычные условия, которые при их выполнении будут возвращать ошибку или просто null. Вариант рабочий, но немного “колхозный”. 

А что, если написать свою аннотацию, размещая которую на класс, мы получим именно такое поведение всех публичных методов, но без ручного прописывания проверок? Это звучит интересно! Для иллюстрации подхода я по шагам пройду создание с помощью Atlassian SDK плагина для Jira с REST-контроллером (который и будет отключаться на окружениях, отличных от SDK с помощью аннотации). Исходники выложены на Github . Установку и настройку самого Atlassian SDK опускаю. Поехали!

Разработка REST-контроллера: Atlassian SDK + Lombok и Slf4j

Создавать костяк плагина можно, взяв заготовки из уже существующих проектов, но поскольку материал рассчитан на тех, кто подобным пока что не занимался, я пройдусь по шагам создания “Skeleton-плагина” с нуля командами Atlassian SDK. 

Для начала из папки, где предполагается разместить проект, стандартной командой

atlas-create-jira-plugin

создаем Skeleton-плагин, затем задаем имена проекта и package как

Define value for groupId: : ru.samokat.atlassian.jira.tutorials             

Define value for artifactId: : sdkcondition

Define value for package:  ru.samokat.atlassian.jira.tutorials: : ru.samokat.atlassian.jira.tutorials.sdkcondition

Остальные предлагаемые опции прокликиваем по умолчанию.

SDK создает для нас заготовку проекта. Прежде, чем идти дальше в pom.xml, заполняем наименование и сайт организации и устанавливаем актуальные для нас версии хост-приложения и версии Java в разделе <properties>. Я установил следующие:

        <jira.version>8.22.0</jira.version>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>

После этого можно из папки проекта запустить команду atlas-run и убедиться, что проект билдится без ошибок, а по адресу http://localhost:2990/jira/ доступен веб-интерфейс приложения.

Теперь все готово для того, чтобы: 

  • добавить в проект REST-модуль, 

  • объявить аннотацию, которой мы будем показывать плагину, что методы этого класса должны работать только на SDK,

  • написать класс, который будет определять, в каком окружении работает хост-приложение,

  • реализовать изменение поведения методов аннотированного класса в зависимости от окружения, немного кастомизировав Spring.

Добавление REST-модуля

Добавить в плагин один из стандартных модулей можно из командной строки командой SDK:

atlas-create-jira-plugin-module

Но поскольку у меня уже есть заготовки из других проектов и так будет нагляднее, я добавлю необходимый код в проект вручную.

Сначала добавим в pom.xml необходимую зависимость

        <!-- REST module dependency -->
        <dependency>
            <groupId>com.atlassian.plugins.rest</groupId>
            <artifactId>atlassian-rest-common</artifactId>
            <version>1.0.2</version>
            <scope>provided</scope>
        </dependency>

Затем добавим в файл дескриптор-плагина atlassian-plugin.xml блок, описывающий разрабатываемый отладочный контроллер:

    <!-- REST module -->
    <rest name="${project.artifactId} REST"
          key="${atlassian.plugin.key}.rest"
          path="/sdkcondition"
          version="1.0">
        <description key="${atlassian.plugin.key}.rest.description">
            Rest endpoint to debug application
        </description>
        <package>${atlassian.plugin.key}.rest</package>
    </rest>

где: 

  • name - имя модуля, которое будет отображаться в админке проекта;

  • key - ключ модуля;

  • path - конечная часть эндпоинта данного реста; полный путь в нашем случае будет
    http://localhost:2990/jira/rest/sdkcondition/1.0/debugger_controller_class/debugger_controller_method

  • key (в теге description) - имя свойства в файле sdkonly.properties, из которой в ту же админку проекта будет подтягиваться описание модуля; зададим эту строку как
    ru.samokat.atlassian.jira.tutorials.sdkcondition.rest.description=debugger controller description

  • package - пакет, в котором располагается наш класс реста.

Сам класс реста назовем DebuggerController и определим как

package ru.samokat.atlassian.jira.tutorials.sdkcondition.rest;

import lombok.extern.slf4j.Slf4j;

import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

@Path("/debugger_controller_class")
@Slf4j
public class DebuggerController {

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    @Path("debugger_controller_method")
    public String sayHello() {
        log.debug("sayHello()");
        
        return "Hello Jira REST World";
    }

}

Тут я применил инструмент, который не используются в мануалах Atlassian, но удобен при разработке, поскольку делает код более читаемым и лаконичным. Это небольшая утилита lombok , предоставляющая аннотации, выполняющие за разработчика “механическую” работу. В частности - @Slf4j - аннотация, позволяющая не отвлекаться на бойлерплейт, инициализирующий логгер в наших классах. Ее достаточно поставить на класс, и просто начать использовать логгер с именем log в коде, как мы это делаем дальше в 20-й строке метода. Для того, чтобы это заработало, нужно:

  1. Добавить в pom-файл зависимости lombok:

    <!-- lombok dependencies -->
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>2.0.5</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-log4j12</artifactId>
            <version>2.0.5</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${org.projectlombok.version}</version>
            <scope>provided</scope>
        </dependency>

Версии зависимостей я добавляю в раздел <properties>, для lombok запишем туда

<org.projectlombok.version>1.18.24</org.projectlombok.version>

  1. Добавить в папку resource проекта еще один properties-файл для конфигурирования самих логгеров классов плагина -log4j.propertiesи указать ссылку на него в разделе <configuration> плагина jira-maven-plugi в файле pom.xml проекта:

<log4jProperties>src/main/resources/log4j.properties</log4jProperties>

Содержание файла:

log4j.rootLogger=WARN, STDOUT

log4j.appender.STDOUT=org.apache.log4j.ConsoleAppender
log4j.appender.STDOUT.layout=org.apache.log4j.PatternLayout
log4j.appender.STDOUT.layout.ConversionPattern=%-5p [%c{1}] : %m%n

log4j.appender.file=org.apache.log4j.RollingFileAppender
log4j.appender.file.File=sdkcondition.log
log4j.appender.file.layout=org.apache.log4j.PatternLayout
log4j.appender.file.layout.ConversionPattern=%-5p [%c{1}] : %m%n

log4j.logger.ru.samokat.atlassian.jira.tutorials.sdkcondition = TRACE, STDOUT, file
log4j.additivity.ru.samokat.atlassian.jira.tutorials.sdkcondition  = false

Подробно на нем останавливаться не буду; отмечу только, что в приведенном варианте логгеры всех классов пакета 

ru.samokat.atlassian.jira.tutorials.sdkcondition

настроены идентично, и что при деплое в хост-приложение (на отличное от SDK окружение) эти настройки действовать не будут. Для настроек логирования в хост-приложении нужно править log4j.properties уже самого хост-приложения.

Теперь можно, запустив в отдельном окне консоли команду

atlas-mvn package

и выполнив GET-запрос к адресу

http://localhost:2990/jira/rest/sdkcondition/1.0/debugger_controller_class/debugger_controller_method

убедиться, что:

  • наш контроллер работает, возвращая в ответе ожидаемое сообщение “Hello Jira REST World”

  • лог выводится в консоль

[INFO] [talledLocalContainer] DEBUG [DebuggerController] : sayHello()

Определение окружения с SDKChecker

Теперь, когда отладочный REST-контроллер готов и мозолит глаза и нам, и ревьюерам, можно приступать к основной части этого туториала — реализации механизма управления его поведением. 

Задействованные в этом классы расположим в пакете postprocessor. Название пакета выбрано исходя из того, что функционал построен на постобработке с помощью Spring инициализируемых в процессе загрузки плагина бинов, но я забегаю немного вперед.

Для начала создадим класс, определяющий, в каком окружении работает плагин. Определять окружение класс будет с помощью проверки base URL, который для SDK по умолчанию равен http://localhost:2990/jira. Сам класс выглядит следующим образом:

package ru.samokat.atlassian.jira.tutorials.sdkcondition.postprocessor;

import com.atlassian.jira.component.ComponentAccessor;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;

import javax.inject.Named;

@Named
@Slf4j
public class SdkChecker {
    @Getter
    private final boolean isSdkEnvironment;
    public SdkChecker() {
        log.debug("SdkChecker()");
        String baseUrl = ComponentAccessor.getApplicationProperties().getJiraBaseUrl();
        log.trace("baseUrl is {}", baseUrl);
        isSdkEnvironment = baseUrl.equals("http://localhost:2990/jira");
        log.trace("plugin is running on SDK environment - {}", isSdkEnvironment);
    }
}

В этом классе применена еще одна аннотация lombok — @Getter. Будучи поставленной на поле класса, как в нашем примере, она определяет геттер, к которому можно обращаться в любом месте проекта. Если поставить ее не на поле, а на класс — она определит геттеры для всех полей класса.

Загружаем дополненный новым классом плагин с помощью команды atlas-mvn package и по логам консоли убеждаемся, что он работает корректно.

[INFO] [talledLocalContainer] WARN  [sdkcondition] : Spring context started for bundle: ru.samokat.atlassian.jira.tutorials.sdkcondition id(236) v(1.0.0.SNAPSHOT) file:/Users/msk-hq-nb-2226/IdeaProjects/sdkcondition/target/jira/home/plugins/installed-plugins/sdkcondition-1.0.0-SNAPSHOT.jar
[INFO] [talledLocalContainer] DEBUG [SdkChecker] : SdkChecker()
[INFO] [talledLocalContainer] TRACE [SdkChecker] : baseUrl is http://localhost:2990/jira
[INFO] [talledLocalContainer] TRACE [SdkChecker] : plugin is running on SDK environment - true

Аннотация и Spring Bean Post-Processor

Саму аннотацию @SdkConditionопределить не сложно

package ru.samokat.atlassian.jira.tutorials.sdkcondition.postprocessor;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Retention(RetentionPolicy.RUNTIME)
public @interface SdkCondition {}

Единственный нюанс — RetentionPolicy.RUNTIME задана для того, чтобы Spring видел аннотацию во время работы приложения.

Теперь осталось написать постпроцессор, в котором и будет происходить вся магия.

package ru.samokat.atlassian.jira.tutorials.sdkcondition.postprocessor;

import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aopalliance.intercept.MethodInterceptor;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;

@Slf4j
@RequiredArgsConstructor
public class SdkConditionAnnotationBeanPostProcessor implements BeanPostProcessor {
    private final SdkChecker sdkConditionChecker;

    @Override
    public Object postProcessAfterInitialization(@NonNull Object bean, @NonNull String beanName) throws BeansException {
        log.debug("postProcessAfterInitialization running for bean.getClass() {} with name {}",
                  bean.getClass(), beanName);

        Class<?> beanClass = bean.getClass();
        if (beanClass.isAnnotationPresent(SdkCondition.class)) {
            log.trace("Creating a proxy for @SdkOnly bean {}", beanName);
            if (AopUtils.isAopProxy(bean)) {
                log.warn("The bean {} is already a proxy.", beanName);
                return bean;
            }
            ProxyFactory proxyFactory = new ProxyFactory(bean);
            proxyFactory.addAdvice((MethodInterceptor) invocation -> {
                if (!sdkConditionChecker.isSdkEnvironment()) {
                    log.warn("Method \"{}()\" should be called on SDK environment only. Intercepting and returning null.",
                             invocation.getMethod().getName());
                    return null;
                }
                return invocation.proceed();
            });
            return proxyFactory.getProxy();
        }
        return bean;
    }
}

Этот класс имплементирует интерфейс BeanPostProcessor. Интерфейс подсказывает Spring, что это не обычный бин, а такой, который нужно создать в первую очередь, и передавать ему другие бины сразу после их создания для постобработки. 

Сама постобработка происходит в методе postProcessAfterInitialization, который принимает бин и его имя, и возвращает фреймворку обратно бин — обработанный или замененный на прокси. В этом методе мы проверяем, аннотирован ли обрабатываемый бин интересующей нас аннотацией @SdkCondition, и если нет — то просто возвращаем его обратно в том же виде, как и получили. Если же аннотирован, то возвращаем вместо него прокси, который оборачивает все методы в проверку условия от SdkChecker. Если условие выполняется (isSdkEnvironment == true) — вызов передается оригинальному методу, и метод делает свою работу. Если же условие не выполняется (плагин работает не на SDK), то оригинальный метод не вызывается, в лог выводится предупреждение, а прокси в ответ на вызов метода просто возвращает null.

В классе применяем еще одну полезную аннотацию lombok @RequiredArgsConstructor — для создания конструктора, определяющего переменные final (в данном случае — наш класс SdkChecker). Экземпляр класса создаст и передаст конструктору Spring, а сам конструктор при этом будет создан lombok-ом. Кра-со-та!

В импорте используются классы из пакетов Spring, и для того, чтобы плагин сбилдился, необходимо добавить в раздел <dependencies> pom.xml зависимость:

        <!-- source of spring.beans and spring.aop packages for beanPostProcessor-->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
            <version>${spring.version}</version>
            <scope>provided</scope>
        </dependency>

В раздел <properties> добавляем версию зависимости:

<spring.version>5.0.10.RELEASE</spring.version>

А в раздел instructions jira-maven-plugin записываем параметр:

 <!-- Allows import packages at runtime -->
<DynamicImport-Package>*</DynamicImport-Package>

Запускаем еще раз atlas-mvn package из командной строки, чтобы проверить результат. Плагин билдится успешно, но в логах мы не видим результатов деятельности созданного класса SdkConditionAnnotationBeanPostProcessor

Для того, чтобы он инстанциировался, нужно добавить его в контекст Spring, задаваемый в конфигурационном файле plugin-context.xml в папке resources/META-INF/spring, дополнив его записью

<!-- custom bean post processor to handle SdkOnly annotation -->
        <bean class="ru.samokat.atlassian.jira.tutorials.sdkcondition.postprocessor.SdkConditionAnnotationBeanPostProcessor"/>

Теперь при билде в консоли появляются сообщения 

[INFO] [talledLocalContainer] DEBUG [SdkConditionAnnotationBeanPostProcessor] : postProcessAfterInitialization running for bean.getClass() class com.sun.proxy.$Proxy3442 with name applicationProperties
[INFO] [talledLocalContainer] DEBUG [SdkConditionAnnotationBeanPostProcessor] : postProcessAfterInitialization running for bean.getClass() class ru.samokat.atlassian.jira.tutorials.sdkcondition.impl.MyPluginComponentImpl with name myPluginComponent
[INFO] [talledLocalContainer] DEBUG [SdkConditionAnnotationBeanPostProcessor] : postProcessAfterInitialization running for bean.getClass() class org.eclipse.gemini.blueprint.service.exporter.support.internal.support.ServiceRegistrationWrapper with name myPluginComponent_osgiService

Однако, в логе не видно, чтобы наш поспроцессор обрабатывал бин-экземпляр класса DebuggerController

Это происходит потому, что класс инстанциируется фреймворком Atlassian не в процессе загрузки, а при первом его использовании. Если обратится к ресту, отправив еще один вызов из PostMan, мы увидим в логе записи:

[INFO] [talledLocalContainer] WARN  [sdkcondition] : Spring context started for bundle: ru.samokat.atlassian.jira.tutorials.sdkcondition id(236) v(1.0.0.SNAPSHOT) file:/Users/msk-hq-nb-2226/IdeaProjects/sdkcondition/target/jira/home/plugins/installed-plugins/sdkcondition-1.0.0-SNAPSHOT.jar
[INFO] [talledLocalContainer] DEBUG [SdkChecker] : SdkChecker()
[INFO] [talledLocalContainer] TRACE [SdkChecker] : baseUrl is http://localhost:2990/jira
[INFO] [talledLocalContainer] TRACE [SdkChecker] : plugin is running on SDK environment - true

Они показывают, как постпроцессор сначала обрабатывает бин, а затем уже происходит обращение к методу RESTa. При повторном вызове реста обработки постпроцессором уже не происходит, так как бин уже инстанциирован, и мы видим только обращение к его методу.

[INFO] [talledLocalContainer] DEBUG [DebuggerController] : sayHello()

В процессе разработки удобнее сразу видеть, что происходит при загрузке плагина, поэтому явно укажем для Spring, что этот класс надо инстанциировать, поставив на него аннотацию @Component. Теперь при выполнении команды atlas-mvn package мы сразу видим результат работы простпроцессора.

[INFO] [talledLocalContainer] DEBUG [SdkConditionAnnotationBeanPostProcessor] : postProcessAfterInitialization running for bean.getClass() class ru.samokat.atlassian.jira.tutorials.sdkcondition.rest.DebuggerController with name debuggerController
[INFO] [talledLocalContainer] DEBUG [SdkConditionAnnotationBeanPostProcessor] : postProcessAfterInitialization running for bean.getClass() class com.sun.proxy." class="formula inline">Proxy3463 with name applicationProperties
[INFO] [talledLocalContainer] DEBUG [SdkConditionAnnotationBeanPostProcessor] : postProcessAfterInitialization running for bean.getClass() class ru.samokat.atlassian.jira.tutorials.sdkcondition.impl.MyPluginComponentImpl with name myPluginComponent
[INFO] [talledLocalContainer] DEBUG [SdkConditionAnnotationBeanPostProcessor] : postProcessAfterInitialization running for bean.getClass() class org.eclipse.gemini.blueprint.service.exporter.support.internal.support.ServiceRegistrationWrapper with name myPluginComponent_osgiService

На самом деле — это довольно плохая практика, так как при первом вызове реста, мы все равно увидим, как класс инстанциируется еще раз, поэтому @Component на классах, объявляемых в atlassian-plugin.xml использовать не надо. Здесь я это сделал только с целью сразу видеть результат при билде в процессе разработки.

Теперь, все что осталось, это проверить, будут ли работать вызовы реста на окружениях отличных от SDK. Для этого можно или загрузить плагин на другое окружение, например, на тестовый стенд, либо просто поменять baseUrl в классе SdkChecker, чтобы проверка возвращала false.

При этом в логе мы ожидаемо увидим предупреждение

[INFO] [talledLocalContainer] WARN  [SdkConditionAnnotationBeanPostProcessor] : Method "sayHello()" should be called on SDK environment only. Intercepting and returning null.

А в ответ на запрос придет 204 ошибка.

Итоги

Теперь можно оглянуться немного назад и посмотреть, что же мы натворили:

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

Во-вторых, в процессе познакомились с пакетом lombok, позволяющем не отвлекаться на написание бойлерплейта, и сосредоточиться на основном функционале.

В-третьих, если посмотреть более общо — научились управлять поведением создаваемых классов с помощью механизмов Spring AOP и постобработки бинов Spring.

Цель статьи не только в том, чтобы рассказать, как объявить аннотаццию, решающую конкретную проблему. Скорее я хотел продемонстрировать подход: если вам не хватает функциональности, предоставляемой вендором или инструментом, можно дописывать своё. День за днем, по капле выдавливать из себя code monkey.


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

Успехов вам и с новым годом!

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


  1. Arty_Fact
    28.12.2023 11:52

    У тебя сейчас проверка происходит напрямую по URL, соответственно, если я решу поменять порт или запустить тестовую сборку с другим Base URL, я получу нерабочий тестовый модуль.Ну и само то, что в продовом коде лежит то, что к проду не относится, мне кажется несколько странным.

    Не рассматривал вариант, чтобы мавеном вычищать ненужное на этапе сборки плагина для прода?


    1. ignat_n Автор
      28.12.2023 11:52

      Мавеном нет, не рассматривал, но попробую, спасибо за подсказку. Разбирался с постобработкой бинов и, как человек, взявший в руки молоток, стал везде видеть гвозди)

      А при проверке на sdk окружение исходил из того, что для моей задачи false negative норм, а вот false positive не норм, поэтому одно единственное значение baseURL поставил в соответствие sdk.


  1. akhmelev
    28.12.2023 11:52

    На @Profile("dev") очень похоже.


  1. TEMIMO
    28.12.2023 11:52

    Если вы не используете аннотацию@Componentнад DebuggerController, как BPP понимает что его нужно обработать?


    1. ignat_n Автор
      28.12.2023 11:52

      DebuggerController лежит в пакажде ${atlassian.plugin.key}.rest, указанном в дескрипторе плагина atlassian-plugin.xml. Соответственно, он и без аннтотации сканируется, и создается бин, который обрабатывают поспроцессоры


      1. TEMIMO
        28.12.2023 11:52

        Понятно, спасибо. Интересная статья