1. Введение
В этом уроке мы поговорим о Java Instrumentation API. Он предоставляет возможность добавлять байт-код к существующим скомпилированным классам Java.
Мы также поговорим об агентах Java и о том, как мы используем их для инструментирования нашего кода.
2. Настройка
На протяжении всей статьи мы будем создавать приложение, используя инструменты.
Наше приложение будет состоять из двух модулей:
- Приложение банкомата, которое позволяет нам снимать деньги
- И агент Java, который позволит нам измерять производительность нашего банкомата, измеряя время, потраченное на трату денег.
Агент Java изменит байт-код банкомата, что позволит нам измерить время снятия средств без необходимости изменять приложение банкомата.
Наш проект будет иметь следующую структуру:
<groupId>com.foreach.instrumentation</groupId>
<artifactId>base</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<modules>
<module>agent</module>
<module>application</module>
</modules>
Прежде чем углубляться в детали инструментирования, давайте посмотрим, что такое java-агент.
3. Что такое Java-агент
В общем, java-агент — это просто специально созданный файл jar. Он использует инструментальный API , предоставляемый JVM, для изменения существующего байт-кода, загруженного в JVM.
Чтобы агент работал, нам нужно определить два метода:
premain
— статически загружать агент с помощью параметра -javaagent при запуске JVM.agentmain
— будет динамически загружать агент в JVM с помощью Java Attach API .
Следует иметь в виду интересную концепцию, заключающуюся в том, что реализация JVM, такая как Oracle, OpenJDK и другие, может предоставить механизм для динамического запуска агентов, но это не является обязательным требованием.
Во-первых, давайте посмотрим, как мы будем использовать существующий агент Java.
После этого мы рассмотрим, как мы можем создать его с нуля, чтобы добавить необходимые нам функции в наш байт-код.
4. Загрузка агента Java
Чтобы иметь возможность использовать агент Java, мы должны сначала загрузить его.
У нас есть два типа нагрузки:
- static — использует
premain
для загрузки агента с помощью опции -javaagent - динамический — использует
agentmain
для загрузки агента в JVM с помощью Java Attach API
Далее мы рассмотрим каждый тип нагрузки и объясним, как он работает.
4.1. Статическая нагрузка
Загрузка агента Java при запуске приложения называется статической загрузкой. Статическая загрузка изменяет байт-код во время запуска, прежде чем какой-либо код будет выполнен.
Имейте в виду, что статическая загрузка использует метод premain
, который запускается до запуска любого кода приложения, чтобы запустить его, мы можем выполнить:
java -javaagent:agent.jar -jar application.jar
Важно отметить, что мы всегда должны помещать параметр –javaagent
перед параметром –jar
.
Ниже приведены логи для нашей команды:
22:24:39.296 [main] INFO - [Agent] In premain method
22:24:39.300 [main] INFO - [Agent] Transforming class MyAtm
22:24:39.407 [main] INFO - [Application] Starting ATM application
22:24:41.409 [main] INFO - [Application] Successful Withdrawal of [7] units!
22:24:41.410 [main] INFO - [Application] Withdrawal operation completed in:2 seconds!
22:24:53.411 [main] INFO - [Application] Successful Withdrawal of [8] units!
22:24:53.411 [main] INFO - [Application] Withdrawal operation completed in:2 seconds!
Мы можем видеть, когда выполнялся метод premain и когда класс
MyAtm
был преобразован. Мы также видим два журнала транзакций снятия средств в банкомате, в которых указано время, которое потребовалось для завершения каждой операции.
Помните, что в нашем исходном приложении у нас не было этого времени завершения транзакции, оно было добавлено нашим Java-агентом.
4.2. Динамическая нагрузка
Процедура загрузки агента Java в уже работающую JVM называется динамической загрузкой. Агент подключается с помощью Java Attach API .
Более сложный сценарий — когда наше приложение банкомата уже работает в производственной среде, и мы хотим динамически добавить общее время транзакций без простоя нашего приложения.
Давайте напишем для этого небольшой фрагмент кода и назовем этот класс AgentLoader.
Для простоты мы поместим этот класс в jar-файл приложения. Таким образом, наш jar-файл приложения может как запускать наше приложение, так и прикреплять нашего агента к приложению банкомата:
VirtualMachine jvm = VirtualMachine.attach(jvmPid);
jvm.loadAgent(agentFile.getAbsolutePath());
jvm.detach();
Теперь, когда у нас есть AgentLoader
, мы запускаем наше приложение, убедившись, что в десятисекундной паузе между транзакциями мы динамически подключаем наш Java-агент с помощью AgentLoader
.
Давайте также добавим клей, который позволит нам либо запустить приложение, либо загрузить агент.
Мы назовем этот класс Launcher
, и это будет наш основной класс файла jar:
public class Launcher {
public static void main(String[] args) throws Exception {
if(args[0].equals("StartMyAtmApplication")) {
new MyAtmApplication().run(args);
} else if(args[0].equals("LoadAgent")) {
new AgentLoader().run(args);
}
}
}
Запуск приложения
java -jar application.jar StartMyAtmApplication
22:44:21.154 [main] INFO - [Application] Starting ATM application
22:44:23.157 [main] INFO - [Application] Successful Withdrawal of [7] units!
Присоединение агента Java
После первой операции мы присоединяем java-агент к нашей JVM:
java -jar application.jar LoadAgent
22:44:27.022 [main] INFO - Attaching to target JVM with PID: 6575
22:44:27.306 [main] INFO - Attached to target JVM and loaded Java agent successfully
Проверьте журналы приложений
Теперь, когда мы подключили нашего агента к JVM, мы увидим, что у нас есть общее время завершения второй операции снятия средств через банкомат.
Это означает, что мы добавили наш функционал на лету, пока наше приложение работало:
22:44:27.229 [Attach Listener] INFO - [Agent] In agentmain method
22:44:27.230 [Attach Listener] INFO - [Agent] Transforming class MyAtm
22:44:33.157 [main] INFO - [Application] Successful Withdrawal of [8] units!
22:44:33.157 [main] INFO - [Application] Withdrawal operation completed in:2 seconds!
5. Создание агента Java
Узнав, как использовать агент, давайте посмотрим, как мы можем его создать. Мы рассмотрим , как использовать Javassist для изменения байт-кода, и объединим это с некоторыми инструментальными методами API.
Поскольку java-агент использует Java Instrumentation API , прежде чем слишком углубляться в создание нашего агента, давайте рассмотрим некоторые из наиболее часто используемых методов в этом API и краткое описание того, что они делают:
addTransformer
— добавляет трансформатор в инструментальный движокgetAllLoadedClasses
— возвращает массив всех классов, загруженных в данный момент JVM.retransformClasses
— облегчает инструментирование уже загруженных классов, добавляя байт-кодremoveTransformer
— отменяет регистрацию предоставленного трансформатораredefineClasses
— переопределить предоставленный набор классов, используя предоставленные файлы классов, что означает, что класс будет полностью заменен, а не изменен, как в случае сretransformClasses
5.1. Создайте методы Premain
и Agentmain
Мы знаем, что каждому агенту Java нужен хотя бы один из методов premain
или agentmain
. Последний используется для динамической загрузки, а первый используется для статической загрузки агента Java в JVM.
Давайте определим их обоих в нашем агенте, чтобы мы могли загружать этот агент как статически, так и динамически:
public static void premain(
String agentArgs, Instrumentation inst) {
LOGGER.info("[Agent] In premain method");
String className = "com.foreach.instrumentation.application.MyAtm";
transformClass(className,inst);
}
public static void agentmain(
String agentArgs, Instrumentation inst) {
LOGGER.info("[Agent] In agentmain method");
String className = "com.foreach.instrumentation.application.MyAtm";
transformClass(className,inst);
}
В каждом методе мы объявляем класс, который мы хотим изменить, а затем углубляемся, чтобы преобразовать этот класс с помощью метода transformClass
.
Ниже приведен код метода transformClass
, который мы определили, чтобы помочь нам преобразовать класс MyAtm
.
В этом методе мы находим класс, который хотим преобразовать, и используем метод преобразования
. Также мы добавляем преобразователь в инструментальный движок:
private static void transformClass(
String className, Instrumentation instrumentation) {
Class<?> targetCls = null;
ClassLoader targetClassLoader = null;
// see if we can get the class using forName
try {
targetCls = Class.forName(className);
targetClassLoader = targetCls.getClassLoader();
transform(targetCls, targetClassLoader, instrumentation);
return;
} catch (Exception ex) {
LOGGER.error("Class [{}] not found with Class.forName");
}
// otherwise iterate all loaded classes and find what we want
for(Class<?> clazz: instrumentation.getAllLoadedClasses()) {
if(clazz.getName().equals(className)) {
targetCls = clazz;
targetClassLoader = targetCls.getClassLoader();
transform(targetCls, targetClassLoader, instrumentation);
return;
}
}
throw new RuntimeException(
"Failed to find class [" + className + "]");
}
private static void transform(
Class<?> clazz,
ClassLoader classLoader,
Instrumentation instrumentation) {
AtmTransformer dt = new AtmTransformer(
clazz.getName(), classLoader);
instrumentation.addTransformer(dt, true);
try {
instrumentation.retransformClasses(clazz);
} catch (Exception ex) {
throw new RuntimeException(
"Transform failed for: [" + clazz.getName() + "]", ex);
}
}
С этим покончено, давайте определим преобразователь для класса MyAtm
.
5.2. Определение нашего трансформатора
Преобразователь класса должен реализовать ClassFileTransformer
и реализовать метод преобразования.
Мы будем использовать Javassist для добавления байт-кода в класс MyAtm
и добавления журнала с общим временем транзакции вывода средств ATW:
public class AtmTransformer implements ClassFileTransformer {
@Override
public byte[] transform(
ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
byte[] byteCode = classfileBuffer;
String finalTargetClassName = this.targetClassName
.replaceAll("\\.", "/");
if (!className.equals(finalTargetClassName)) {
return byteCode;
}
if (className.equals(finalTargetClassName)
&& loader.equals(targetClassLoader)) {
LOGGER.info("[Agent] Transforming class MyAtm");
try {
ClassPool cp = ClassPool.getDefault();
CtClass cc = cp.get(targetClassName);
CtMethod m = cc.getDeclaredMethod(
WITHDRAW_MONEY_METHOD);
m.addLocalVariable(
"startTime", CtClass.longType);
m.insertBefore(
"startTime = System.currentTimeMillis();");
StringBuilder endBlock = new StringBuilder();
m.addLocalVariable("endTime", CtClass.longType);
m.addLocalVariable("opTime", CtClass.longType);
endBlock.append(
"endTime = System.currentTimeMillis();");
endBlock.append(
"opTime = (endTime-startTime)/1000;");
endBlock.append(
"LOGGER.info(\"[Application] Withdrawal operation completed in:" +
"\" + opTime + \" seconds!\");");
m.insertAfter(endBlock.toString());
byteCode = cc.toBytecode();
cc.detach();
} catch (NotFoundException | CannotCompileException | IOException e) {
LOGGER.error("Exception", e);
}
}
return byteCode;
}
}
5.3. Создание файла манифеста агента
Наконец, чтобы получить работающий агент Java, нам понадобится файл манифеста с парой атрибутов.
Следовательно, мы можем найти полный список атрибутов манифеста в официальной документации пакета инструментов .
В окончательный файл jar агента Java мы добавим следующие строки в файл манифеста:
Agent-Class: com.foreach.instrumentation.agent.MyInstrumentationAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Premain-Class: com.foreach.instrumentation.agent.MyInstrumentationAgent
Теперь наш агент инструментирования Java завершен. Чтобы запустить его, обратитесь к разделу Загрузка агента Java этой статьи.
6. Заключение
В этой статье мы говорили об API инструментов Java. Мы рассмотрели, как загрузить агент Java в JVM как статически, так и динамически.
Мы также рассмотрели, как создать собственный Java-агент с нуля.
Как всегда, полную реализацию примера можно найти на Github .