Делаем наш плагин для Jira Server/Data Center рабочим даже если зависимый плагин не установлен

Всем привет!

В этой статье поговорим о том, как сделать Ваш плагин для Jira Server/ Data Center рабочим, даже если зависимый плагин не установлен на экземпляре Jira, где установлен Ваш плагин.

Приведу пример. Допустим Вы пишите плагин для Jira Software (JSW) и Jira Service Desk (JSD). В этом случае Вы используете сервисы от JSW and JSD для работы с соответствующими объектами. Если Вы установите Ваш плагин на экземпляр Jira, где есть и JSW и JSD, то все будет хорошо. Ваш плагин корректно установится и будет работать. Но если на экземпляре Jira будет либо JSW, либо JSD отсутствовать, то Ваш плигин станет неактивным. Это не то, что мы хотим. Допустим нет JSD, тогда мы хотим, чтобы плагин продолжал работать с JSW. И наооборот.

Такая проблема возникает не только с JSD и JSW, но и с любым плагином, который Вы используете в своем плагине. Например, Вы пользуетесь сервисами Table Grid или Insight.

Что в этом случае делать? Конечно, можно создать два вместо одного плагина. Один, например, для JSD, в другой для JSW. Но в этом случае пользователь должен еще будет думать какой плагин поставить. В общем, такой подход не всегда возможен.

А как сделать по-другому? Чтобы все-таки все было в одном плагине?

Вот об этом мы и поговорим в этой статье.

Начнем вот с этого кода:

git clone git clone https://alex1mmm@bitbucket.org/alex1mmm/third-party-dependency-tutorial.git --branch v.1 --single-branch

Стартовый код

В стартовом коде у нас два файла.

src/main/java/ru/matveev/alexey/atlassian/tutorial/impl/MyPluginComponentImpl.java file

@Named ("myPluginComponent")
public class MyPluginComponentImpl implements MyPluginComponent
{

    private final AppCloudMigrationGateway appCloudMigrationGateway;

    @Inject
    public MyPluginComponentImpl(@ComponentImport AppCloudMigrationGateway appCloudMigrationGateway)
    {
        this.appCloudMigrationGateway = appCloudMigrationGateway;
    }

    public String getName()
    {
        if(null != this.appCloudMigrationGateway)
        {
            return "myComponent:" + appCloudMigrationGateway.getClass().getName();
        }
        
        return "myComponent";
    }
}

Здесь мы инжектим сервис AppCloudMigrationGateway в конструктор.

Второй файл – src/main/java/ru/matveev/alexey/atlassian/tutorial/Caller.java:

@Slf4j
@Named
public class Caller {
    public Caller(MyPluginComponent myPluginComponent) {
        log.error(String.format("Component Class Name: %s", myPluginComponent.getName()));
    }

}

Здесь мы инжектим наш MyPluginComponent и выводим сообщение в лог то, что возвращает метод getName.

Запускаем наш плагин

Переходим в терминале в папку нашего плагина и выполняем команду atlas-run. Jira запустится, а наш плагин будет недоступен:

Посмотрим в atlassian-jira.log и найдем вот такую ошибку:

2020-08-31 12:59:43,234+0300 ThreadPoolAsyncTaskExecutor::Thread 24 ERROR      [o.e.g.b.e.i.dependencies.startup.DependencyWaiterApplicationContextExecutor] Unable to create application context for [ru.matveev.alexey.atlassian.tutorial.third-party-dependency-tutorial], unsatisfied dependencies: none
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'caller': Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'myPluginComponent': Resolution of declared constructors on bean Class [ru.matveev.alexey.atlassian.tutorial.impl.MyPluginComponentImpl] from ClassLoader [ru.matveev.alexey.atlassian.tutorial.third-party-dependency-tutorial [231]] failed; nested exception is java.lang.NoClassDefFoundError: com/atlassian/migration/app/AppCloudMigrationGateway

Класс com/atlassian/migration/app/AppCloudMigrationGateway не найден.

Все верно JCMA не установлен же.

Установим JCMA:

Перейдем в Manage Apps и установим снова наш плагин еще раз. На этот раз плагин запустится корректно:

И мы увидим наше сообщение в логе:

2020-08-31 13:03:26,388+0300 ThreadPoolAsyncTaskExecutor::Thread 27 ERROR admin 779x2400x1 1m4naq8 0:0:0:0:0:0:0:1 /rest/plugins/1.0/ [r.m.a.atlassian.tutorial.Caller] Component Class Name: myComponent:com.sun.proxy.$Proxy3421

Отлично.

Теперь удалим JCMA и наше приложение опять будет недоступно.

Отлично. Все работает, как мы ожидаем.

Правим код

Теперь поменяем код, чтобы наш плагин работал даже если JCMA не установлен.

src/main/java/ru/matveev/alexey/atlassian/tutorial/impl/MyPluginComponentImpl.java:

@Inject
    public MyPluginComponentImpl(@ComponentImport AppCloudMigrationGateway appCloudMigrationGateway)
    {
        this.appCloudMigrationGateway = appCloudMigrationGateway;
    }

Здесь мы импортируем сервис AppCloudMigrationGateway. Нам нужно избавиться от этого импорта.

Как это сделать?

Сначала создадим файл аксессор для сервиса AppCloudMigrationGateway.

src/main/java/ru/matveev/alexey/atlassian/tutorial/JCMAAccessor.java

public class JCMAAccessor {

    public static AppCloudMigrationGateway getJCMAGateway() {
        if(ComponentAccessor.getPluginAccessor().getPlugin("com.atlassian.jira.migration.jira-migration-plugin") == null) {
            return null;
        }
        return ComponentAccessor.getOSGiComponentInstanceOfType(AppCloudMigrationGateway.class);
    }
}

Здесь мы не инжектим сервис AppCloudMigrationGateway, а получаем его из метода getOSGiComponentInstanceOfType.

Теперь перепишем src/main/java/ru/matveev/alexey/atlassian/tutorial/impl/MyPluginComponentImpl.java:

@Named ("myPluginComponent")
public class MyPluginComponentImpl implements MyPluginComponent
{

    public String getName()
    {
        if(null != JCMAAccessor.getJCMAGateway())
        {
            return "myComponent:" + JCMAAccessor.getJCMAGateway().getClass().getName();
        }
        
        return "myComponent";
    }
}

Здесь проверяем если JCMAAccessor.getJCMAGateway() возвращает не null, то возвращаем имя класса, если null, то возвращаем myComponent.

Запускаем приложение снова

Установим наше приложение. Теперь наше приложение доступно даже если нет JCMA, и мы видим наше сообщение в лог файле:

2020-08-31 13:37:24,274+0300 ThreadPoolAsyncTaskExecutor::Thread 27 ERROR admin 812x2453x5 28lz3m 0:0:0:0:0:0:0:1 /rest/plugins/1.0/installed-marketplace [r.m.a.atlassian.tutorial.Caller] Component Class Name: myComponent

Отлично. Все сделано. Вы можете посмотреть финальный код в v.2 .

Усложняем задачу

Вообще-то нам нужно не просто заинжекить сервис, а еще и листенер создать AppCloudMigrationListener.

Сделаем.

src/main/java/ru/matveev/alexey/atlassian/tutorial/api/MyPluginComponent.java

public interface MyPluginComponent extends AppCloudMigrationListener
{
    String getName();
}

Мы наследуемся от AppCloudMigrationListener

Теперь будем регистрировать наш AppCloudMigrationListener.

src/main/java/ru/matveev/alexey/atlassian/tutorial/impl/MyPluginComponentImpl.java:

@Slf4j
@Named ("myPluginComponent")
public class MyPluginComponentImpl  implements MyPluginComponent, DisposableBean
{
    public MyPluginComponentImpl() {
        JCMAAccessor.getJCMAGateway().registerListener(this);
        log.error("Listener registered");
    }

    public String getName()
    {
        if(null != JCMAAccessor.getJCMAGateway())
        {
            return "myComponent:" + JCMAAccessor.getJCMAGateway().getClass().getName();
        }
        
        return "myComponent";
    }

    @Override
    public void onStartAppMigration(String transferId, MigrationDetails migrationDetails) {

        // Retrieving ID mappings from the migration.
        // A complete guide can be found at https://developer.atlassian.com/platform/app-migration/getting-started/mappings/
        try {
            log.info("Migration context summary: " + new ObjectMapper().writeValueAsString(migrationDetails));
            PaginatedMapping paginatedMapping = JCMAAccessor.getJCMAGateway().getPaginatedMapping(transferId, "identity:user", 5);
            while (paginatedMapping.next()) {
                Map<String, String> mappings = paginatedMapping.getMapping();
                log.info("mappings = {}", new ObjectMapper().writeValueAsString(mappings));
            }
        } catch (IOException e) {
            log.error("Error retrieving migration mappings", e);
        }

        // You can also upload one or more files to the cloud. You'll be able to retrieve them through Atlassian Connect
        try {
            OutputStream firstDataStream = JCMAAccessor.getJCMAGateway().createAppData(transferId);
            // You can even upload big files in here
            firstDataStream.write("Your binary data goes here".getBytes());
            firstDataStream.close();

            // You can also apply labels to distinguish files or to add meta data to support your import process
            OutputStream secondDataStream = JCMAAccessor.getJCMAGateway().createAppData(transferId, "some-optional-label");
            secondDataStream.write("more bytes".getBytes());
            secondDataStream.close();
        } catch (IOException e) {
            log.error("Error uploading files to the cloud", e);
        }
    }

    @Override
    public String getCloudAppKey() {
        return "my-cloud-app-key";
    }

    @Override
    public String getServerAppKey() {
        return "my-server-app-key";
    }

    /**
     * Declare what categories of data your app handles.
     */
    @Override
    public Set<AccessScope> getDataAccessScopes() {
        return Stream.of(AccessScope.APP_DATA_OTHER, AccessScope.PRODUCT_DATA_OTHER, AccessScope.MIGRATION_TRACING_IDENTITY)
                .collect(Collectors.toCollection(HashSet::new));
    }

    @Override
    public void destroy() {
        JCMAAccessor.getJCMAGateway().deregisterListener(this);
        log.error("Listener deregistered");
    }

Я сделал MyPluginComponentImpl как листенер.

Конструктор:

public MyPluginComponentImpl() {
        JCMAAccessor.getJCMAGateway().registerListener(this);
        log.error("Listener registered");
    }

Регистрирую сам себя в качестве листенера. И выгружаю себя в методе destroy:

@Override
    public void destroy() {
        JCMAAccessor.getJCMAGateway().deregisterListener(this);
        log.error("Listener deregistered");
    }

Все остальные методы это имплементация методов интерфейса AppCloudMigrationListener. Для данной статьи то, что там происходит, неважно.

Запускаем снова наше приложение

Запускаем приложение и видим вот такую ошибку:

Caused by: java.lang.NoClassDefFoundError: com/atlassian/migration/app/AppCloudMigrationListener

Правильно. AppCloudMigrationListener принадлежит JCMA, а JCMA не установлен. Можно посмотреть финальный код для этой части в v.3.

Правим код

Сначала вынесем AppCloudMigrationListener в отдельный класс.

src/main/java/ru/matveev/alexey/atlassian/tutorial/MyAppCloudMigrationListener.java:

@Slf4j
public class MyAppCloudMigrationListener implements AppCloudMigrationListener {
    @Override
    public void onStartAppMigration(String transferId, MigrationDetails migrationDetails) {

        // Retrieving ID mappings from the migration.
        // A complete guide can be found at https://developer.atlassian.com/platform/app-migration/getting-started/mappings/
        try {
            log.info("Migration context summary: " + new ObjectMapper().writeValueAsString(migrationDetails));
            PaginatedMapping paginatedMapping = JCMAAccessor.getJCMAGateway().getPaginatedMapping(transferId, "identity:user", 5);
            while (paginatedMapping.next()) {
                Map<String, String> mappings = paginatedMapping.getMapping();
                log.info("mappings = {}", new ObjectMapper().writeValueAsString(mappings));
            }
        } catch (IOException e) {
            log.error("Error retrieving migration mappings", e);
        }

        // You can also upload one or more files to the cloud. You'll be able to retrieve them through Atlassian Connect
        try {
            OutputStream firstDataStream = JCMAAccessor.getJCMAGateway().createAppData(transferId);
            // You can even upload big files in here
            firstDataStream.write("Your binary data goes here".getBytes());
            firstDataStream.close();

            // You can also apply labels to distinguish files or to add meta data to support your import process
            OutputStream secondDataStream = JCMAAccessor.getJCMAGateway().createAppData(transferId, "some-optional-label");
            secondDataStream.write("more bytes".getBytes());
            secondDataStream.close();
        } catch (IOException e) {
            log.error("Error uploading files to the cloud", e);
        }
    }

    @Override
    public String getCloudAppKey() {
        return "my-cloud-app-key";
    }

    @Override
    public String getServerAppKey() {
        return "my-server-app-key";
    }

    /**
     * Declare what categories of data your app handles.
     */
    @Override
    public Set<AccessScope> getDataAccessScopes() {
        return Stream.of(AccessScope.APP_DATA_OTHER, AccessScope.PRODUCT_DATA_OTHER, AccessScope.MIGRATION_TRACING_IDENTITY)
                .collect(Collectors.toCollection(HashSet::new));
    }
}

Теперь удалим “extends AppCloudMigrationListener” из src/main/java/ru/matveev/alexey/atlassian/tutorial/api/MyPluginComponent.java:

public interface MyPluginComponent
{
    String getName();
}

Теперь создадим src/main/java/ru/matveev/alexey/atlassian/tutorial/ListenerManager.java file. Этот класс будет регистрировать и выгружать MyAppCloudMigrationListener:

@Slf4j
public class ListenerManager {

    static AppCloudMigrationListener appCloudMigrationListener = null;

    public static void registerListener() {
        appCloudMigrationListener = new MyAppCloudMigrationListener();
        JCMAAccessor.getJCMAGateway().registerListener(appCloudMigrationListener);
        log.error("Listener registered");
    }

    public static void deregisterListener() {
        appCloudMigrationListener = new MyAppCloudMigrationListener();
        JCMAAccessor.getJCMAGateway().deregisterListener(appCloudMigrationListener);
        log.error("Listener deregistered");
    }
}

Кроме того, мы пишем сообщения в лог при регистрации и выгрузке листенера.

Далее внесем изменения в src/main/java/ru/matveev/alexey/atlassian/tutorial/impl/MyPluginComponentImpl.java:

@Slf4j
@Named ("myPluginComponent")
public class MyPluginComponentImpl implements MyPluginComponent, DisposableBean {

    public MyPluginComponentImpl() {
        if (JCMAAccessor.getJCMAGateway() != null) {
            ListenerManager.registerListener();
        }
        else {
            log.error("Listener not registered");
        }
    }

    public String getName()
    {
        if(null != JCMAAccessor.getJCMAGateway())
        {
            return "myComponent:" + JCMAAccessor.getJCMAGateway().getClass().getName();
        }
        
        return "myComponent";
    }

    @Override
    public void destroy() {
        ListenerManager.deregisterListener();
    }
}

В конструкторе мы проверяем, что JCMA установлен. Если установлен, то регистрируем наш листенер. В методе destroy мы выгружаем наш листенер. Метод getName не изменился.

Запустим плагин

Если запустим плагин, то увидем вот такое сообщение в лог файле:

2020-08-31 15:58:42,907+0300 ThreadPoolAsyncTaskExecutor::Thread 64 ERROR admin 958x12356x1 28lz3m 0:0:0:0:0:0:0:1 /rest/plugins/1.0/com.atlassian.jira.migration.jira-migration-plugin-key [r.m.a.a.tutorial.impl.MyPluginComponentImpl] Listener not registered

Все правильно. JCMA не установлен, а значит листенер не зарегистрировался.

Теперь установим JCMA и снова установим наш плагин. Будет вот такой лог:

2020-08-31 16:00:29,104+0300 http-nio-2990-exec-9 ERROR admin 960x12417x1 28lz3m 0:0:0:0:0:0:0:1 /rest/plugins/1.0/com.atlassian.jira.migration.jira-migration-plugin-key [r.m.a.atlassian.tutorial.ListenerManager] Listener registered

Листенер зарегистрировался. Все верно.

Выключим наш плагин. Будет вот такое сообщение:

2020-08-31 16:01:38,078+0300 http-nio-2990-exec-1 ERROR admin 961x12427x1 28lz3m 0:0:0:0:0:0:0:1 /rest/plugins/1.0/ru.matveev.alexey.atlassian.tutorial.third-party-dependency-tutorial-key [r.m.a.atlassian.tutorial.ListenerManager] Listener deregistered

Правильно. Листенер выгрузился.

Теперь включим наше приложение и выключим JCMA:

2020-08-31 16:03:13,050+0300 ThreadPoolAsyncTaskExecutor::Thread 68 ERROR admin 963x12437x1 28lz3m 0:0:0:0:0:0:0:1 /rest/plugins/1.0/ru.matveev.alexey.atlassian.tutorial.third-party-dependency-tutorial-key [r.m.a.a.tutorial.impl.MyPluginComponentImpl] Listener not registered

Правильно. Листенер не зарегистрировался.

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

То есть у нас не отрабатывает условие, когда наш плагин установлен и мы после этого устанавливаем JCMA. Листенер в этом случае не регистрируется.

Давайте пофиксим.

Нам нужно обработать события PluginInstalledEvent и PluginEnabledEvent.

src/main/java/ru/matveev/alexey/atlassian/tutorial/PluginListener.java:

@Slf4j
public class PluginListener implements InitializingBean, DisposableBean {

    private final EventPublisher eventPublisher;

    public PluginListener(@ComponentImport EventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }
    @EventListener
    public void onPluginInstalledEvent(PluginInstalledEvent pluginInstalledEvent) {
        ListenerManager.registerListener();
    }

    @EventListener
    public void onPluginEnabledEvent(PluginEnabledEvent pluginEnabledEvent) {
        ListenerManager.registerListener();
    }


    @Override
    public void destroy() throws Exception {
        this.eventPublisher.unregister(this);
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        this.eventPublisher.register(this);
    }
}

Код достаточно простой. В PluginInstalledEvent и PluginEnabledEvent мы запускаем регистрацию нашего листенера.

Теперь если мы включим или установим JCMA, а наш плагин уже установлен в Jira, то наш листенер будет зарегистрирован.

Финальный код можно посмотреть v.4.

На этом все. Мы научились делать установку наших плагинов независимо от наличия зависимых плагинов.

If you have found a spelling error, please, notify us by selecting that text and pressing Ctrl+Enter.

Leave a Reply

%d bloggers like this:

Spelling error report

The following text will be sent to our editors: