[Из песочницы] Лучше в райнтайме, чем никогда: расширяем API JIRA «на лету»

Что делать, если имеющегося в приложении API для решения задачи недостаточно, а возможности оперативно провести изменения в код нет?

2fa67d0e1abc455385d45f4e5db19e64.jpg

Последней надеждой в этой ситуации может быть применение средств пакета java.lang.instrument. Всем, кому интересно, что и как в Java можно сделать с кодом в уже запущенной VM, добро пожаловать под кат.

На Хабре уже есть статьи про работу с байткодом:

  • Java Agent на службе JVM
  • Теория и практика AOP. Как мы это делаем в Яндексе
  • Аспектно-ориентированное программирование. Основы

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

В этой статье я покажу, как можно выполнить инструментацию приложения java агента (и OSGi, и библиотеки Byte Buddy), с целью добавления нового функционала в приложение. Статья будет интересна прежде всего людям, работающим с JIRA, но используемый подход достаточно универсален и может быть применен и к другим платформам.

Задача


Итак, после двух-трех дней исследования API JIRA понимаем, что нормально реализовать кросс-валидацию значений полей (в случае когда допустимые значения одного поля зависят от значения другого поля) при создании/изменении задачи не получится никак кроме как через валидацию перехода. Подход рабочий, но сложный и не удобный, поэтому в свободное от работы время я решил продолжить исследование, чтобы иметь план Б.

На недавнем Джокере был доклад Rafael Winterhalter про библиотеку Byte Buddy, которая оборачивает мощный низкоуровневый API редактирования байт-кода в более удобную высокоуровневую оболочку. Библиотека в данный момент уже довольно популярна, в частности с недавних пор она используется в Mockito и Hibernate. Среди прочего Рафаэль рассказывал о возможности изменения с помощью Byte Buddy уже загруженных классов.

Думаем «А это мысль!», и начинаем работу.

Проектирование


Первое, из доклада Рафаэля вспомниаем, что модификация уже загруженных классов возможна только с помощью интерфейса java.lang.instrument.Instrumentation, который доступен при запуске java агента. Он может быть установлен либо при запуске VM с помощью командной строки, либо с помощью Attach API, который является платформозависимым и поставляется вместе с JDK.

Здесь есть важная деталь — удалить агент нельзя — его классы остаются загруженными до конца работы VM.

Что касается JIRA в плане поддержки attach API, то тут мы не можем гарантировать что она будет запущена на JDK и уж тем более не можем гарантировать OS, на которой она будет запущена.
Второе, вспоминаем, что основной единицей расширения функционала JIRA является add-on — Bundle на стероидах. Значит всю нашу логику, какой бы она ни была, придется оформить в виде add-on’ов. Отсюда вытекает требование, что если мы и будем вносить какие-то изменения в систему — они должны быть идемпотентными и отключаемыми.

С учетом этих ограничений видим глобально 2 задачи:

  • Установка агента: должна происходить при инсталляции аддона, обеспечивать защиту от двойной инсталляции, поддерживать инсталляцию агента на Linux и Windows, на JDK и JRE.
    Т.к. агент нельзя удалить, его обновление потребует рестарта приложения — это не очень вписывается в концепцию OSGi. Поэтому надо минимизировать ответственность агента, чтобы потребность его обновления возникала как можно реже.
  • Реализация инструментации: должна происходить при инсталляции аддона, должна обеспечивать идемпотентность трансформации классов, должна обеспечивать расширяемость логики валидации.

При распределении ответственности между компонентами у меня получилась вот такая схема:
a050e66e2a9642ac816f4c6fd7aef2a8.png

Реализация


Агент


Первым делом создаем агент:
public class InstrumentationSupplierAgent {
    public static volatile Instrumentation instrumentation;
    public static void agentmain(String args, Instrumentation inst) throws Exception {
        System.out.println("==**agent started**==");
        InstrumentationSupplierAgent.instrumentation = inst;
        System.out.println("==**agent execution complete**==");
    }
}

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

Провайдер


Теперь создадим add-on, который будет этот агент аттачить в целевую VM. Начнем с логики установки агента. Полный код установщика под спойлером:
AgentInstaller.java
@Component
public class AgentInstaller {

    private static final Logger log = LoggerFactory.getLogger(AgentInstaller.class);
    private final JiraHome jiraHome;
    private final JiraProperties jiraProperties;

    @Autowired
    public AgentInstaller(
        @ComponentImport JiraHome jiraHome,
        @ComponentImport JiraProperties jiraProperties
    ) {
        this.jiraHome = jiraHome;
        this.jiraProperties = jiraProperties;
    }

    private static File getInstrumentationDirectory(JiraHome jiraHome) throws IOException {
        final File dataDirectory = jiraHome.getDataDirectory();
        final File instrFolder = new File(dataDirectory, "instrumentation");
        if (!instrFolder.exists()) {
            Files.createDirectory(instrFolder.toPath());
        }
        return instrFolder;
    }

    private static File loadFileFromCurrentJar(File destination, String fileName) throws IOException {
        try (InputStream resourceAsStream = AgentInstaller.class.getResourceAsStream("/lib/" + fileName)) {
            final File existingFile = new File(destination, fileName);
            if (!existingFile.exists() || !isCheckSumEqual(new FileInputStream(existingFile), resourceAsStream)) {
                Files.deleteIfExists(existingFile.toPath());
                existingFile.createNewFile();
                try (OutputStream os = new FileOutputStream(existingFile)) {
                    IOUtils.copy(resourceAsStream, os);
                }
            }
            return existingFile;
        }
    }

    private static boolean isCheckSumEqual(InputStream existingFileStream, InputStream newFileStream) {
        try (InputStream oldIs = existingFileStream; InputStream newIs = newFileStream) {
            return Arrays.equals(getMDFiveDigest(oldIs), getMDFiveDigest(newIs));
        } catch (NoSuchAlgorithmException | IOException e) {
            log.error("Error to compare checksum for streams {},{}", existingFileStream, newFileStream);
            return false;
        }
    }

    private static byte[] getMDFiveDigest(InputStream is) throws IOException, NoSuchAlgorithmException {
        final MessageDigest md = MessageDigest.getInstance("MD5");
        md.digest(IOUtils.toByteArray(is));
        return md.digest();
    }

    public void install() throws PluginException {
        try {
            log.trace("Trying to install tools and agent");
            if (!isProperAgentLoaded()) {
                log.info("Instrumentation agent is not installed yet or has wrong version");
                final String pid = getPid();
                log.debug("Current VM PID={}", pid);
                final URLClassLoader systemClassLoader = (URLClassLoader) ClassLoader.getSystemClassLoader();
                log.debug("System classLoader={}", systemClassLoader);
                final Class virtualMachine = getVirtualMachineClass(
                    systemClassLoader,
                    "com.sun.tools.attach.VirtualMachine",
                    true
                );
                log.debug("VM class={}", virtualMachine);
                Method attach = virtualMachine.getMethod("attach", String.class);
                Method loadAgent = virtualMachine.getMethod("loadAgent", String.class);
                Method detach = virtualMachine.getMethod("detach");
                Object vm = null;
                try {
                    log.trace("Attaching to VM with PID={}", pid);
                    vm = attach.invoke(null, pid);
                    final File agentFile = getAgentFile();
                    log.debug("Agent file: {}", agentFile);
                    loadAgent.invoke(vm, agentFile.getAbsolutePath());
                } finally {
                    tryToDetach(vm, detach);
                }
            } else {
                log.info("Instrumentation agent is already installed");
            }
        } catch (Exception e) {
            throw new IllegalPluginStateException("Failed to load: agent and tools are not installed properly", e);
        }
    }

    private boolean isProperAgentLoaded() {
        try {
ClassLoader.getSystemClassLoader().loadClass(InstrumentationProvider.INSTRUMENTATION_CLASS_NAME);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    private void tryToDetach(Object vm, Method detach) {
        try {
            if (vm != null) {
                log.trace("Detaching from VM: {}", vm);
                detach.invoke(vm);
            } else {
                log.warn("Failed to detach, vm is null");
            }
        } catch (Exception e) {
            log.warn("Failed to detach", e);
        }
    }

    private String getPid() {
        String nameOfRunningVM = ManagementFactory.getRuntimeMXBean().getName();
        return nameOfRunningVM.split("@", 2)[0];
    }

    private Class getVirtualMachineClass(URLClassLoader systemClassLoader, String className, boolean tryLoadTools) throws Exception {
        log.trace("Trying to get VM class, loadingTools={}", tryLoadTools);
        try {
            return systemClassLoader.loadClass(className);
        } catch (ClassNotFoundException e) {
            if (tryLoadTools) {
                final OS os = getRunningOs();
                os.tryToLoadTools(systemClassLoader, jiraHome);
                return getVirtualMachineClass(systemClassLoader, className, false);
            } else {
                throw new ReflectiveOperationException("Failed to load VM class", e);
            }
        }
    }

    private OS getRunningOs() {
        final String osName = jiraProperties.getSanitisedProperties().get("os.name");
        log.debug("OS name: {}", osName);
        if (Pattern.compile(".*[Ll]inux.*").matcher(osName).matches()) {
            return OS.LINUX;
        } else if (Pattern.compile(".*[Ww]indows.*").matcher(osName).matches()) {
            return OS.WINDOWS;
        } else {
            throw new IllegalStateException("Unknown OS running");
        }
    }

    private File getAgentFile() throws IOException {
        final File agent = loadFileFromCurrentJar(getInstrumentationDirectory(jiraHome), "instrumentation-agent.jar");
        agent.deleteOnExit();
        return agent;
    }

    private enum OS {
        WINDOWS {

            @Override
            protected String getToolsFilename() {
                return "tools-windows.jar";
            }

            @Override
            protected String getAttachLibFilename() {
                return "attach.dll";
            }
        },
        LINUX {

            @Override
            protected String getToolsFilename() {
                return "tools-linux.jar";
            }

            @Override
            protected String getAttachLibFilename() {
                return "libattach.so";
            }
        };

        public void tryToLoadTools(URLClassLoader systemClassLoader, JiraHome jiraHome) throws Exception {
            log.trace("Trying to load tools");
            final File instrumentationDirectory = getInstrumentationDirectory(jiraHome);
            appendLibPath(instrumentationDirectory.getAbsolutePath());
            loadFileFromCurrentJar(instrumentationDirectory, getAttachLibFilename());
            resetCache();
            final File tools = loadFileFromCurrentJar(instrumentationDirectory, getToolsFilename());
            final Method method = URLClassLoader.class.getDeclaredMethod("addURL", URL.class);
            method.setAccessible(true);
            method.invoke(systemClassLoader, tools.toURI().toURL());
        }

        private void resetCache() throws NoSuchFieldException, IllegalAccessException {
            Field fieldSysPath = ClassLoader.class.getDeclaredField("sys_paths");
            fieldSysPath.setAccessible(true);
            fieldSysPath.set(null, null);
        }

        private void appendLibPath(String instrumentationDirectory) {
            if (System.getProperty("java.library.path") != null) {
                System.setProperty("java.library.path",
                    System.getProperty("java.library.path") + System.getProperty("path.separator")
                        + instrumentationDirectory);
            } else {
                System.setProperty("java.library.path", instrumentationDirectory);
            }
        }
        protected abstract String getToolsFilename();
        protected abstract String getAttachLibFilename();
    }
}


Разберем код по частям.

Самый простой сценарий — если агент уже загружен. Может, его включили через параметры командной строки при загрузке, а может, add-on устанавливается не первый раз.

Проверить — легко, достаточно загрузить класс агента системным класслоадером

private boolean isProperAgentLoaded() {
    try {
        ClassLoader.getSystemClassLoader().loadClass(InstrumentationProvider.INSTRUMENTATION_CLASS_NAME);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

Если он доступен, то больше устанавливать ничего не нужно. Но допустим у нас первая установка, и агент еще не загружен — сделаем это сами с помощью attach API. Аналогично предыдущему случаю сперва проверим — не работаем ли мы под JDK, т.е. доступен нам нужный API без дополнительных манипуляций или нет. Если нет, то попробуем «доставить» API.
private Class getVirtualMachineClass(URLClassLoader systemClassLoader, String className, boolean tryLoadTools) throws Exception {
        log.trace("Trying to get VM class, loadingTools={}", tryLoadTools);
        try {
            return systemClassLoader.loadClass(className);
        } catch (ClassNotFoundException e) {
            if (tryLoadTools) {
                final OS os = getRunningOs();
                os.tryToLoadTools(systemClassLoader, jiraHome);
                return getVirtualMachineClass(systemClassLoader, className, false);
            } else {
                throw new ReflectiveOperationException("Failed to load VM class", e);
            }
        }
}

Теперь рассмотрим процедуру установки attach API. Задача «превращения» JRE в JDK начинается с определения контейнерной ОС. В JIRA код определения ОС уже реализован:
private OS getRunningOs() {
        final String osName = jiraProperties.getSanitisedProperties().get("os.name");
        log.debug("OS name: {}", osName);
        if (Pattern.compile(".*[Ll]inux.*").matcher(osName).matches()) {
            return OS.LINUX;
        } else if (Pattern.compile(".*[Ww]indows.*").matcher(osName).matches()) {
            return OS.WINDOWS;
        } else {
            throw new IllegalStateException("Unknown OS running");
        }
}

Теперь, зная под какой мы ОС, рассмотрим, как можно загрузить attach API. Первым делом взглянем из чего собственно состоит attach API. Как я и говорил он — платформозависимый.

Замечание: tools.jar указан как платформонезависимый, но это не совсем так. В META-INF/services/ его скрывается конфигурационный файл com.sun.tools.attach.spi.AttachProvider, в котором перечислены доступные для окружения провайдеры:

#[solaris]sun.tools.attach.SolarisAttachProvider
#[windows]sun.tools.attach.WindowsAttachProvider
#[linux]sun.tools.attach.LinuxAttachProvider
#[macosx]sun.tools.attach.BsdAttachProvider
#[aix]sun.tools.attach.AixAttachProvider

Они, в свою, очередь как раз очень даже платформозависимы.

Чтобы подключить нужные файлы в сборку на текущий момент я решил просто вытащить файлы библиотек и копии tools.jar из соответствующих дистрибутивов JDK и сложить их в репозиторий.
Что важно отметить, так это то, что после загрузки файлы attach API нельзя удалить или изменить, поэтому если мы хотим, чтобы наш add-on по-прежнему можно было удалять и обновлять, то загружать библиотеки непосредственно из jar не надо — лучше при загрузке скопировать их из нашего jar в доступное нам из JIRA тихое, спокойное расположение.

public void tryToLoadTools(URLClassLoader systemClassLoader, JiraHome jiraHome) throws Exception {
            log.trace("Trying to load tools");
            final File instrumentationDirectory = getInstrumentationDirectory(jiraHome);//{JIRA_HOME}/data/instrumentation
            loadFileFromCurrentJar(instrumentationDirectory, getAttachLibFilename());//загружаем файл нативной библиотеки
            final File tools = loadFileFromCurrentJar(instrumentationDirectory, getToolsFilename());//загружаем tools.jar
            ...
}

Для копирования файлов будем использовать вот такой метод:
private static File loadFileFromCurrentJar(File destination, String fileName) throws IOException {
        try (InputStream resourceAsStream = AgentInstaller.class.getResourceAsStream("/lib/" + fileName)) {
            final File existingFile = new File(destination, fileName);
            if (!existingFile.exists() || !isCheckSumEqual(new FileInputStream(existingFile), resourceAsStream)) {
                Files.deleteIfExists(existingFile.toPath());//если файл уже загружен - будет исключение
                existingFile.createNewFile();
                try (OutputStream os = new FileOutputStream(existingFile)) {
                    IOUtils.copy(resourceAsStream, os);
                }
            }
            return existingFile;
        }
}

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

Итак, файлы есть, давайте разберемся как загружать. Начнем с самого сложного — загрузки нативной библиотеки. Если мы заглянем в недра attach API, то увидим, что непосредственно при выполнении задач происходит выгрузка библиотеки с помощью вот такого кода:

static {
        System.loadLibrary("attach");
}

Это говорит о том, что нам необходимо добавить расположение нашей библиотеки в «java.library.path»
private void appendLibPath(String instrumentationDirectory) {
            if (System.getProperty("java.library.path") != null) {
                System.setProperty("java.library.path",
                    System.getProperty("java.library.path") + System.getProperty("path.separator")
                        + instrumentationDirectory);
            } else {
                System.setProperty("java.library.path", instrumentationDirectory);
            }
}

После этого остается сложить нужный файл нативной библиотеки в правильный каталог ии… забить первый костыль в наше решение. «java.library.path» кэшируется в классе ClassLoader, в private static String sys_paths[]. Ну что нам private — идем сбрасывать кэш…
private void resetCache() throws NoSuchFieldException, IllegalAccessException {
            Field fieldSysPath = ClassLoader.class.getDeclaredField("sys_paths");
            fieldSysPath.setAccessible(true);
            fieldSysPath.set(null, null);
}

Вот, нативную часть мы загрузили — переходим к части API на Java. tools.jar в JDK загружается системным загрузчиком. Нам нужно добиться того же.

Немного подебажив, обнаруживаем, что системный загрузчик реализует java.net.URLClassLoader.
Если коротко, то этот загрузчик хранит расположения классов как список URL. Все, что нам нужно для загрузки — добавить URL нашего tools-[OS].jar в этот список. Изучив API URLClassLoader’а огорчаемся еще раз, т.к. обнаруживаем, что метод addURL, который делает именно то, что нужно, оказывается protected. Эх… еще одна подпорка к стройному прототипу:

final Method method = URLClassLoader.class.getDeclaredMethod("addURL", URL.class);
method.setAccessible(true);
method.invoke(systemClassLoader, tools.toURI().toURL());

Ну вот наконец-то все готово к загрузке класса виртуальной машины.

Загружать его обязательно нужно не текущим OSGi-класслоадером, а системным, который остается в системе всегда, т.к. в процессе выполнения attach этот класслоадер будет загружать нативную библиотеку, а сделать это можно только один раз. OSGi же класслоадеры создаются при установке бандла — каждый раз новый. Так что рискуем получить вот такую штуку:

… 19 more
Caused by: com.sun.tools.attach.AttachNotSupportedException: no providers installed
at com.sun.tools.attach.VirtualMachine.attach (VirtualMachine.java:203)

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

Когда мы загрузили класс, можем зарузить нужные методы и наконец-то приаттачить наш агент:

Method attach = virtualMachine.getMethod("attach", String.class);
                Method loadAgent = virtualMachine.getMethod("loadAgent", String.class);
                Method detach = virtualMachine.getMethod("detach");
                Object vm = null;
                try {
                    final String pid = getPid();
                    log.debug("Current VM PID={}", pid);
                    log.trace("Attaching to VM with PID={}", pid);
                    vm = attach.invoke(null, pid);
                    final File agentFile = getAgentFile();
                    log.debug("Agent file: {}", agentFile);
                    loadAgent.invoke(vm, agentFile.getAbsolutePath());
                } finally {
                    tryToDetach(vm, detach);
}

Единственной тонкостью тут является код получения pid виртуальной машины:
    private String getPid() {
        String nameOfRunningVM = ManagementFactory.getRuntimeMXBean().getName();
        return nameOfRunningVM.split("@", 2)[0];
    }

Способ нестандартизованный, но вполне рабочий, а в Java 9 Process API вообще позволит делать это без лиших проблем.

Add-on


Теперь встроим эту логику в add-on. Нас интересует возможность вызвать код во время установки аддона — это делается с помощью стандартного спрингового InitializingBean.
    @Override
    public void afterPropertiesSet() throws Exception {
        this.agentInstaller.install();
        this.serviceTracker.open();
    }

Сначала вызываем логику установки агента (рассмотренную выше), а затем открываем ServiceTracker — один из основных механизмов реализации whiteboard паттерна в OSGi. Если коротко, то эта штука позволяет нам выполнить логику при добалении/изменении сервисов определенного типа в контейнере.
private ServiceTracker initTracker(final BundleContext bundleContext, final InstrumentationProvider instrumentationProvider) {
        return new ServiceTracker<>(bundleContext, InstrumentationConsumer.class, new ServiceTrackerCustomizer() {
            @Override
            public Void addingService(ServiceReference serviceReference) {//выполняем код при появлении нового сервиса типа InstrumentationConsumer
                try {
                    log.trace("addingService called");
                    final InstrumentationConsumer consumer = bundleContext.getService(serviceReference);
                    log.debug("Consumer: {}", consumer);
                    if (consumer != null) {
                        final Instrumentation instrumentation;
                        try {
                            instrumentation = instrumentationProvider.getInstrumentation();
                            consumer.applyInstrumentation(instrumentation);
                        } catch (InstrumentationAgentException e) {
                            log.error("Error on getting insrumentation", e);
                        }
                    }
                } catch (Throwable t) {
                    log.error("Error on 'addingService'", t);
                }
                return null;
            }

            @Override
            public void modifiedService(ServiceReference serviceReference, Void aVoid) {

            }

            @Override
            public void removedService(ServiceReference serviceReference, Void aVoid) {

            }
});

Теперь, каждый раз, когда в контейнер будет регистрироваться сервис, реализующий класс InstrumentationConsumer, мы будем вызывать его метод applyInstrumentation с объектом java.lang.instrument.Instrumentation, полученным нами вот таким образом:
@Component
public class InstrumentationProviderImpl implements InstrumentationProvider {
    private static final Logger log = LoggerFactory.getLogger(InstrumentationProviderImpl.class);
    @Override
    public Instrumentation getInstrumentation() throws InstrumentationAgentException {
        try {
            final Class agentClass = ClassLoader.getSystemClassLoader().loadClass(INSTRUMENTATION_CLASS_NAME);//пытаемся загрузить класс агента системным загрузчиком, который грузит javaagents
            log.debug("Agent class loaded from system classloader", agentClass);
            final Field instrumentation = agentClass.getDeclaredField(INSTRUMENTATION_FIELD_NAME);//достаем значение через reflection
            log.debug("Instrumentation field: {}", instrumentation);
            final Object instrumentationValue = instrumentation.get(null);
            if (instrumentationValue == null) {
                throw new NullPointerException("instrumentation data is null. Seems agent is not installed");
            }
            return (Instrumentation) instrumentationValue;
        } catch (Throwable e) {
            String msg = "Error getting instrumentation";
            log.error(msg, e);
            throw new InstrumentationAgentException("Error getting instrumentation", e);
        }
    }
}

Переходим к написанию движка валидации.

Движок валидации


Находим точку, в которую наиболее эффективно внести изменения — класс DefaultIssueService (на самом деле далеко не все вызовы создания/изменения идут через эту точку, но это отдельная тема), и его методы:

validateCreate:

IssueService.CreateValidationResult validateCreate(@Nullable ApplicationUser var1, IssueInputParameters var2);

и validateUpdate:
IssueService.UpdateValidationResult validateUpdate(@Nullable ApplicationUser var1, Long var2, IssueInputParameters var3);

и прикидываем какой логики нам не хватает.

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

ByteBuddy предлагает нам 2 варианта реализации нашей задумки: с помощью прерывания и с помощью механизма Advice. Разницу подходов хорошо видно на слайде презентации Рафаэля.

1ea558ac8ba64041b1fbfcadf5e6c68a.PNG

Interceptor API хорошо документирован, в его качестве может выступать любой публичный класс, подробнее тут. В оригинальный байткод вызов Interceptor’а встраивается ВМЕСТО оригинального метода.

При попытке использовать этот способ я выявил 2 существенных недостатка:

  • В общем случае, у нас есть возможность получить оригинальный метод, и даже объект вызова метода. Однако, из-за ограничений на изменение сигнатуры загруженных классов, в случае когда мы инструментируем уже загруженный класс, оригинальный метод мы теряем (т.к. он не может быть сохранен как приватный метод того же класса). Так что если мы хотим переиспользовать оригинальную логику нам придется написать ее заново самим самим.
  • Т.к. мы фактически вызываем методы другого класса, нам необходимо обеспечить видимость между классами в цепочке класслоадеров. В случае, когда инструментируется класс внутри OSGi-контейнера, проблем с видимостью не будет. Но в нашем случае большинство классов из API JIRA загружается WebappClassLoader’ом, который находится вне OSGi, а значит при попытке вызова метода нашего Interceptor’а мы получим заслуженный ClassNotFoundException.

В ходе работы над проектом у меня родился вариант решения этой проблемы, но т.к. оно вмешивается в логику загрузки классов всего приложения я не рекомендую использовать его без тщательного тестирования и выложу под спойлером.
Решение проблемы загрузчиков
Основная идея заключается в том, чтобы прервать цепочку родителей WebappClassLoader’а и вставить туда некий прокси ClassLoader, который будет пытаться загружать классы с помощью BundleClassLoader, прежде чем делегировать загрузку настоящему родителю WebappClassLoader’а

Вот так:

57c5b2b2b84e4997bf98c55ef4b4d3bc.png

Реализация подхода вылядит так:
private void tryToFixClassloader(ClassLoader originalClassLoader, BundleWiringImpl.BundleClassLoader bundleClassLoader) {
        try {
            final ClassLoader originalParent = originalClassLoader.getParent();
            if (originalParent != null) {
                if (!(originalParent instanceof BundleProxyClassLoader)) {
                    final BundleProxyClassLoader proxyClassLoader = new BundleProxyClassLoader<>(originalParent, bundleClassLoader);
                    FieldUtils.writeDeclaredField(originalClassLoader, "parent", proxyClassLoader, true);
                }
            }
        } catch (IllegalAccessException e) {
            log.warn("Error on try to fix originalClassLoader {}", originalClassLoader, e);
        }
}

Применять его следует в блоке применения инструментации:
...
.transform((builder, typeDescription, classloader) -> {
builder.method(named("validateCreate").and(ElementMatchers.isPublic())).intercept(MethodDelegation.to(Interceptor.class));
                    if (!ClassUtils.isVisible(InstrumentationConsumer.class, classloader)) {
                    tryToFixClassloader(classloader, (BundleWiringImpl.BundleClassLoader) Interceptor.class.getClassLoader());
                }
             })
            .installOn(instrumentation);

В этом случае мы сможем загружать OSGi классы через WebappClassLoader. Единственное, о чем надо позаботиться — о том, чтобы не пытаться загружать с помощью OSGi классы, загрузка которых будет делегироваться во вне OSGi, т.к. это, очевидно, приведет к зацикливанию и исключениям.
Код BundleProxyClassLoader:
class BundleProxyClassLoader extends ClassLoader {

        private static final Logger log = LoggerFactory.getLogger(BundleProxyClassLoader.class);

        private final Set proxies;
        private final Method loadClass;
        private final Method shouldDelegate;

        public BundleProxyClassLoader(ClassLoader parent, T proxy) {
            super(parent);
            this.loadClass = getLoadClassMethod();
            this.shouldDelegate = getShouldDelegateMethod();
            this.proxies = new HashSet<>();
            proxies.add(proxy);
        }

        private Method getLoadClassMethod() throws IllegalStateException {
            try {
                Method loadClass = ClassLoader.class.getDeclaredMethod("loadClass", String.class, boolean.class);
                loadClass.setAccessible(true);
                return loadClass;
            } catch (NoSuchMethodException e) {
                throw new IllegalStateException("Failed to get loadClass method", e);
            }
        }

        private Method getShouldDelegateMethod() throws IllegalStateException {
            try {
                Method shouldDelegate = BundleWiringImpl.class.getDeclaredMethod("shouldBootDelegate", String.class);
                shouldDelegate.setAccessible(true);
                return shouldDelegate;
            } catch (NoSuchMethodException e) {
                throw new IllegalStateException("Failed to get shouldDelegate method", e);
            }
        }

        @Override
        public Class loadClass(String name, boolean resolve) throws ClassNotFoundException {
            synchronized (getClassLoadingLock(name)) {
                log.trace("Trying to find already loaded class {}", name);
                Class c = findLoadedClass(name);
                if (c == null) {
                    log.trace("This is new class. Trying to load {} with OSGi", name);
                    c = tryToLoadWithProxies(name, resolve);
                    if (c == null) {
                        log.trace("Failed to load with OSGi. Trying to load {} with parent CL", name);
                        c = super.loadClass(name, resolve);
                    }
                }
                if (c == null) {
                    throw new ClassNotFoundException(name);
                }
                return c;
            }
        }

        private Class tryToLoadWithProxies(String name, boolean resolve) {
            for (T proxy : proxies) {
                try {
                    final String pkgName = Util.getClassPackage(name);
                    //avoid cycle
                    if(!isShouldDelegatePackageLoad(proxy, pkgName)) {
                        log.trace("The load of class {} should not be delegated to OSGI parent, so let's try to load with bundles", name);
                        return (Class) this.loadClass.invoke(proxy, name, resolve);
                    }
                } catch (ReflectiveOperationException e) {
                    log.trace("Class {} is not found with {}", name, proxy);
                }
            }
            return null;
        }

        private boolean isShouldDelegatePackageLoad(T proxy, String pkgName) throws IllegalAccessException, InvocationTargetException {
            return (boolean)this.shouldDelegate.invoke(
                    FieldUtils.readDeclaredField(proxy, "m_wiring", true),
                    pkgName
            );
        }
    }

Я сохранил его на случай, если кто-то захочет развить эту идею.

Второй вариант реализации инструментации — это использование Advice. Этот метод значительно хуже документирован — фактически примеры можно найти только в тикетах на Github и ответах на StackOverflow.
Но не все так плохо
Тут надо отдать Рафаэлю должное — все вопросы и тикеты которые я видел снабжены подробнейшими пояснениями и примерами, так что разобраться будет совсем не трудно — надеюсь эти труды принесут плоды и мы будем видеть Byte Buddy в еще большем количестве проектов.

От первого он отличается тем, что по умолчанию наши advice-методы встраиваются в код класса. Для нас это означает:
  • отсутствие необходимости плясок с ClassLoader’ами
  • сохранение оригинальной логики — мы только можем выполнить некие действия до оригинального кода или после

Звучит идеально, нам предоставлен шикарный API позволяющий получать оригинальные аргументы, результаты работы оригинального кода (включая исключения) и даже получать результаты работы Advice’а который отработал до оригинального кода. Но всегда есть «но», и встраивание накладывает некоторые ограничения на код, который может быть встроен:
  • весь встраиваемый код должен быть оформлен одним методом
  • метод не должен содержать вызовов методов классов, недоступных классу, в который мы встраиваемся, в т.ч. и анонимных (прощайте лямбды!)
  • не поддерживается всплывание исключений — исключения нужно бросать явно в теле метода

Описания этих ограничений в документации Byte Buddy я не нашел
Ну что ж, попробуем написать нашу логику в стиле Advice. Как мы помним, нам надо постараться минимизировать необходимые инструментации. Это значит, что хотелось бы абстрагироваться от конкретных проверок валидации — сделать так, чтобы при появлении новой проверки она автоматически добавлялась в список проверок, которые будут выполнены при вызове validateCreate/validateUpdate, а сам код класса DefaultIssueService менять бы не приходилось.
В OSGi сделать это легко, но DefaultIssueService находится за рамками фреймворка и использовать OSGi приемы тут не получится.

Неожиданно нам на помощь приходит API JIRA. Каждый add-on представлен в JIRA как объект класса Plugin (обертка над Bundle с рядом специальных функций) с определенным ключом, по которому можно этот plugin искать.

Ключ задается нами в конфигурации аддона, plugin API загружается тем же класслоадером, что и наш DefaultIssueService — так что нам ничего не мешает в нашем advice’е вызвать именно наш plugin и с его помощью загрузить уже любой класс, который этим plugin’ом поставляется. Например, это может быть наш агрегатор проверок.

После этого мы можем получить экземпляр этого класса через опять-таки стандартный com.atlassian.jira.component.ComponentAccessor#getOSGiComponentInstanceOfType.
И никакой магии:

public class DefaultIssueServiceValidateCreateAdvice {
    @Advice.OnMethodExit(onThrowable = IllegalArgumentException.class)
    public static void intercept(
        @Advice.Return(readOnly = false) CreateValidationResult originalResult,//заменить возвращаемое значение своим можно присвоив эту переменную - поэтому ставим (readOnly = false)
        @Advice.Thrown Throwable throwable,//если оригинальный код кинет исключение - мы его получим
        @Advice.Argument(0) ApplicationUser user,
        @Advice.Argument(1) IssueInputParameters issueInputParameters
    ) {
        try {
            if (throwable == null) {
                //current plugin key
                final Plugin plugin = ComponentAccessor.getPluginAccessor().getEnabledPlugin("org.jrx.jira.instrumentation.issue-validation");
                //related aggregator class
                final Class issueValidatorClass = plugin != null ? plugin.getClassLoader().loadClass("org.jrx.jira.instrumentation.validation.spi.issueservice.IssueServiceValidateCreateValidatorAggregator") : null;
                final Object issueValidator = issueValidatorClass != null ? ComponentAccessor.getOSGiComponentInstanceOfType(issueValidatorClass) : null;//вот здесь нам на помощь приходит API JIRA
                if (issueValidator != null) {
                    final Method validate = issueValidator.getClass().getMethod("validate", CreateValidationResult.class, ApplicationUser.class, IssueInputParameters.class);
                    if (validate != null) {
                        final CreateValidationResult validationResult = (CreateValidationResult) validate
                            .invoke(issueValidator, originalResult, user, issueInputParameters);
                        if (validationResult != null) {
                            originalResult = validationResult;
                        }
                    } else {
                        System.err.println("==**Warn: method validate is not found on aggregator " + "**==");
                    }
                }
            }
        //Nothing should break service
        } catch (Throwable e) {
            System.err.println("==**Warn: Exception on additional logic of validateCreate " + e + "**==");
        }
    }
}

DefaultIssueServiceValidateUpdateAdvice выглядит аналогично с точностью до имен классов и методов. Пришла пора написать InstrumentationConsumer, который будет применять наш advice к нужному методу.
@Component
@ExportAsService
public class DefaultIssueServiceTransformer implements InstrumentationConsumer {

    private static final Logger log = LoggerFactory.getLogger(DefaultIssueServiceTransformer.class);
    private static final AgentBuilder.Listener listener = new LogTransformListener(log);
    private final String DEFAULT_ISSUE_SERVICE_CLASS_NAME = "com.atlassian.jira.bc.issue.DefaultIssueService";

    @Override
    public void applyInstrumentation(Instrumentation instrumentation) {
        new AgentBuilder.Default().disableClassFormatChanges()
            .with(new AgentBuilder.Listener.Filtering(
                new StringMatcher(DEFAULT_ISSUE_SERVICE_CLASS_NAME, EQUALS_FULLY),
                listener
            ))
            .with(AgentBuilder.TypeStrategy.Default.REDEFINE)
            .with(AgentBuilder.RedefinitionStrategy.REDEFINITION)
            .with(AgentBuilder.InitializationStrategy.NoOp.INSTANCE)
            .type(named(DEFAULT_ISSUE_SERVICE_CLASS_NAME))
            .transform((builder, typeDescription, classloader) ->
                    builder
                //transformation is idempotent!!! You can call it many times with same effect
                //no way to add advice on advice if it applies to original class
                //https://github.com/raphw/byte-buddy/issues/206
                .visit(Advice.to(DefaultIssueServiceValidateCreateAdvice.class).on(named("validateCreate").and(ElementMatchers.isPublic())))
                .visit(Advice.to(DefaultIssueServiceValidateUpdateAdvice.class).on(named("validateUpdate").and(ElementMatchers.isPublic()))))
            .installOn(instrumentation);
    }
}

Тут надо сказать об одном приятном бонусе. Применение advice’а — идемпотентно! Не нужно заботиться о том, чтобы не применить трансформацию дважды при переустановке аддона — за нас это сделает VM.
Дополнительные возможности
Как я уже говорил, из-за ограничений хранить метаинформацию в классе нельзя, но с подачи Рафаэля я провел эксперимент по добавлению к классу аннотаций. Если использовать аннотации, поставляющиеся вместе с JRE (например, JAXB и т.д.), то с их помощью вполне можно хранить в трансформированном классе минимально необходимую информацию о трансформации — версию, дату и т.д.
В финальный код это не попало, т.к. оказалось не нужно.

Ну что ж, дело за малым — напишем агрегатор. Первым делом определяем API валидации:
public interface IssueServiceValidateCreateValidator {
    @Nonnull CreateValidationResult validate(
        final @Nonnull CreateValidationResult originalResult,
        final ApplicationUser user,
        final IssueInputParameters issueInputParameters
    );
}

Дальше стандартными средствами OSGi в момент вызова получаем все доступные валидации и выполняем их:
@Component
@ExportAsService(IssueServiceValidateCreateValidatorAggregator.class) //не забываем оформить компонент как OSGi-сервис
public class IssueServiceValidateCreateValidatorAggregator implements IssueServiceValidateCreateValidator {
    private static final Logger log = LoggerFactory.getLogger(IssueServiceValidateCreateValidatorAggregator.class);
    private final BundleContext bundleContext;

    @Autowired
    public IssueServiceValidateCreateValidatorAggregator(BundleContext bundleContext) {
        this.bundleContext = bundleContext;
    }

    @Nonnull
    @Override
    public IssueService.CreateValidationResult validate(@Nonnull final IssueService.CreateValidationResult originalResult, final ApplicationUser user, final IssueInputParameters issueInputParameters) {
        try {
            log.trace("Executing validate of IssueServiceValidateCreateValidatorAggregator");
            final Collection> serviceReferences = bundleContext.getServiceReferences(IssueServiceValidateCreateValidator.class, null);//получаем все сервисы, реализующие IssueServiceValidateCreateValidator - тут уже обычными средствами OSGi
            log.debug("Found services: {}", serviceReferences);
            IssueService.CreateValidationResult result = originalResult;
            for (ServiceReference serviceReference : serviceReferences) {
                final IssueServiceValidateCreateValidator service = bundleContext.getService(serviceReference);
                if (service != null) {
                    result = service.validate(result, user, issueInputParameters);//передаем результат валидации всем по цепочке
                } else {
                    log.debug("Failed to get service from {}", serviceReference);
                }
            }
            return result;
        } catch (InvalidSyntaxException e) {
            log.warn("Exception on getting IssueServiceValidateCreateValidator", e);
            return originalResult;
        }
    }
}

Все готово — собираем, уставнавливаем

Тестовая валидация


Для проверки подхода реализуем простейшую проверку:
@Component
@ExportAsService
public class TestIssueServiceCreateValidator implements IssueServiceValidateCreateValidator {
    @Nonnull
    @Override
    public IssueService.CreateValidationResult validate(@Nonnull IssueService.CreateValidationResult originalResult, ApplicationUser user, IssueInputParameters issueInputParameters) {
        originalResult.getErrorCollection().addError(IssueFieldConstants.ASSIGNEE, "This validation works", ErrorCollection.Reason.VALIDATION_FAILED);
        return originalResult;
    }
}

Пытаемся создать новую задачу и вуаля!
e45390934d8d4124a0e2cab8c49260f5.PNG

Теперь можем удалять и ставить заново любой аддон из разработанных — поведение JIRA меняется корректно.

Заключение


Таким образом, мы получили средство динамического расширения API приложения, в данном случае JIRA. Безусловно, прежде чем использовать такой подход в production требуется тщательное тестирование, но на мой взгляд решение не окончательно закостылено, и при должной проработке такой подход может быть использован для решения «безнадежных задач» — исправление долгоживущих thirdparty дефектов, для расширения API и т.д.

Полный код самого проекта можно посмотреть на Github — пользуйтесь на здоровье!

з.ы. Чтобы не усложнять статью я не стал описывать детали сборки проекта и особенности разработки add-on’ов для JIRA — ознакомиться с этим можно здесь.

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

© Habrahabr.ru