Краще в райнтайме, ніж ніколи: розширюємо API JIRA «на льоту»

Що робити, якщо наявного у додатку API для вирішення задачі недостатньо, а можливості оперативно провести зміни в код немає?



Останньою надією в цій ситуації може бути застосування засобів пакету java.lang.instrument. Всім, кому цікаво, що і як в Java можна зробити з кодом вже запущеної VM, ласкаво просимо під кат.

На Хабре вже є статті про роботу з байткодом:

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

У цій статті я покажу, як можна виконати инструментацию програми 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-onBundle на стероїдах. Значить всю нашу логіку, якою б вона не була, доведеться оформити у вигляді add-on'ів. Звідси випливає вимога, що якщо ми і будемо вносити якісь зміни в систему — вони повинні бути идемпотентными і відключаються.

З урахуванням цих обмежень бачимо глобально 2 завдання:

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

  • Реалізація інструментації: повинна відбуватися при інсталяції аддона, повинна забезпечувати идемпотентность трансформації класів, повинна забезпечувати розширюваність логіки валідації.
При розподілі відповідальності між компонентами у мене вийшла ось така схема:


Реалізація

Агент

Насамперед створюємо агент:

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(destination File, 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 yet not installed 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 tools File = 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 tools File = loadFileFromCurrentJar(instrumentationDirectory, getToolsFilename());//завантажуємо tools.jar
...
}

Для копіювання файлів будемо використовувати ось такий метод:

private static File loadFileFromCurrentJar(destination File, 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<InstrumentationConsumer, Void> initTracker(final BundleContext bundleContext, final InstrumentationProvider instrumentationProvider) {
return new ServiceTracker<>(bundleContext, InstrumentationConsumer.class, new ServiceTrackerCustomizer<InstrumentationConsumer, Void>(){
@Override
public Void addingService(ServiceReference<InstrumentationConsumer> 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<InstrumentationConsumer> serviceReference, Void aVoid) {

}

@Override
public void removedService(ServiceReference<InstrumentationConsumer> 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. Різницю підходів добре видно на слайді презентации Рафаеля.


Interceptor API добре документований, в його якості може виступати будь-який публічний клас, детальніше тут. В оригінальний байткод виклик Interceptor'а вбудовується ЗАМІСТЬ оригінального методу.

При спробі використовувати цей спосіб я виявив 2 суттєвих недоліки:

  • У загальному випадку, у нас є можливість отримати оригінальний метод, і навіть об'єкт виклику методу. Однак, за обмежень на зміну сигнатури завантажених класів, у випадку коли ми инструментируем вже завантажений клас, оригінальний метод ми втрачаємо(оскільки він не може бути збережений як приватний метод того ж класу). Так що якщо ми хочемо переиспользовать оригінальну логіку нам доведеться написати її заново самим самим.

  • оскільки ми фактично викликаємо методи іншого класу, нам необхідно забезпечити видимість між класами в ланцюжку класслоадеров. У разі, коли инструментируется клас всередині OSGi-контейнера, проблем з видимістю не буде. Але в нашому випадку більшість класів з API JIRA завантажується WebappClassLoader'ом, який знаходиться поза OSGi, а значить при спробі виклику методу нашого Interceptor'а ми отримаємо заслужений ClassNotFoundException.
В ході роботи над проектом у мене народився варіант вирішення цієї проблеми, але оскільки воно втручається в логіку завантаження класів всього додатка я не рекомендую використовувати його без детального тестування та викладу під спойлером.

Рішення проблеми завантажувачівОсновна ідея полягає в тому, щоб перервати ланцюжок батьків WebappClassLoader'а і вставити туди якийсь проксі ClassLoader, який буде намагатися завантажувати класи з допомогою BundleClassLoader, перш ніж делегувати завантаження справжньому батькові WebappClassLoader'а

Ось так:


Реалізація підходу вылядит так:

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<T extends BundleWiringImpl.BundleClassLoader> extends ClassLoader {

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

private final Set<T> 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 ідемпотентний!!! 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<ServiceReference<IssueServiceValidateCreateValidator>> serviceReferences = bundleContext.getServiceReferences(IssueServiceValidateCreateValidator.class, null);//отримуємо всі сервіси, що реалізують IssueServiceValidateCreateValidator - тут вже звичайними засобами OSGi
log.debug("Found services: {}", serviceReferences);
IssueService.CreateValidationResult result = originalResult;
for (ServiceReference<IssueServiceValidateCreateValidator> 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;
}
}

Намагаємося створити нову задачу і вуаля!


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

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

Повний код самого проекту можна подивитися на Github — користуйтеся на здоров'я!

з.и. Щоб не ускладнювати статтю я не став описувати деталі складання проекту і особливості розробки add-on'ів для JIRA — ознайомитися з цим можна тут.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.