Привет, Хабр! Меня зовут Игнат, в 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_methodkey (в теге description) - имя свойства в файле sdkonly.properties, из которой в ту же админку проекта будет подтягиваться описание модуля; зададим эту строку как
ru.samokat.atlassian.jira.tutorials.sdkcondition.rest.description=debugger controller descriptionpackage - пакет, в котором располагается наш класс реста.
Сам класс реста назовем 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-й строке метода. Для того, чтобы это заработало, нужно:
Добавить в 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>
Добавить в папку 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-запрос к адресу
убедиться, что:
наш контроллер работает, возвращая в ответе ожидаемое сообщение
“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)
TEMIMO
28.12.2023 11:52Если вы не используете аннотацию@Componentнад DebuggerController, как BPP понимает что его нужно обработать?
ignat_n Автор
28.12.2023 11:52DebuggerController лежит в пакажде ${atlassian.plugin.key}.rest, указанном в дескрипторе плагина atlassian-plugin.xml. Соответственно, он и без аннтотации сканируется, и создается бин, который обрабатывают поспроцессоры
Arty_Fact
У тебя сейчас проверка происходит напрямую по URL, соответственно, если я решу поменять порт или запустить тестовую сборку с другим Base URL, я получу нерабочий тестовый модуль.Ну и само то, что в продовом коде лежит то, что к проду не относится, мне кажется несколько странным.
Не рассматривал вариант, чтобы мавеном вычищать ненужное на этапе сборки плагина для прода?
ignat_n Автор
Мавеном нет, не рассматривал, но попробую, спасибо за подсказку. Разбирался с постобработкой бинов и, как человек, взявший в руки молоток, стал везде видеть гвозди)
А при проверке на sdk окружение исходил из того, что для моей задачи false negative норм, а вот false positive не норм, поэтому одно единственное значение baseURL поставил в соответствие sdk.