В этой статье мы рассмотрим пакет java.lang.instrument.Instrumentation, с помощью которого можно поменять байт-код класса на этапе его загрузки.
Эта статья из цикла обработки аннотаций, обработчик аннотаций с RetentionPolicy.SOURCE мы уже написали. Теперь обработаем аннотации с RetentionPolicy.CLASS, и это уже совсем другая история.
Этапы компиляции исходников, загрузки классов
Как известно, сначала исходные Java-файлы компилируются в файлы с расширением .class. В предыдущей статье мы рассматривали, как сгенерировать дополнительный класс с исходным кодом на этапе компиляции (опираясь на существующий исходный код с аннотациями).
Далее, после запуска приложения .class файлы загружаются в память с помощью Classloader-ов, и для каждого класса создается объект типа java.lang.Class. Этот объект содержит описание класса: его методы, поля, аннотации и т.д. Так вот еще до загрузки класса и создания объектов Class тоже можно вмешаться и поменять байт-код.
Агент
Эту возможность дает библиотека инструментирования. С ее помощью можно написать служебное приложение, или так называемый агент.
Это инструмент, который может мониторить и изменять основное приложение. Например, измерять память, занимаемую объектами в процессе работы приложения. Или вмешаться и до загрузки классов поменять байт-код.
В случае модификации байт-кода агент служит посредником-модификатором.
Чтобы агент вмешался в процесс, его обычно собирают как отдельное приложение и передают как параметр VM при запуске основного приложения.
Агент берет откомпилированные классы, меняет их и пропускает на этап загрузки измененными. Классы загружаются в память уже измененными, и уже на основе измененного байт-кода в основном приложении создаются объекты типа java.lang.Class.
Создание агента
Сначала перечислим формальные особенности приложения-агента. Во-первых, это его главный метод.
Метод premain()
Агент отличается специфическим методом premain() вместо main(). Название метода premain() говорящее — агент запускается до основного приложения.
Так же как main() — стартовая точка приложения, premain() — стартовая точка агента.
Обратите внимание, что вторым параметром premain() является Instrumentation из пакета инструментирования:
import java.lang.instrument.Instrumentation; public class AgentMain { public static void premain(String args, Instrumentation instrumentation) { System.out.println("Classes loaded: " + instrumentation.getAllLoadedClasses().length); instrumentation.addTransformer(new ClassTransformer()); } }
Здесь мы выводим все загруженные классы и добавляем класс, который будет трансформировать байт код. Его мы подробнее рассмотрим ниже.
Создание манифеста
Вторая особенность: так же как для обычного приложения в манифесте указывают класс с методом main(), для агента указывают класс с методом premain().
Итоговый jar-файл должен содержать файл манифеста META-INF/MANIFEST.MF, и в нем должна быть строка:
Premain-Class: ru.sysout.bytecode.AgentMain
Чтобы не создавать манифест вручную, можно создавать манифест с помощью Maven.
Например, с помощью плагина maven-assembly-plugin это делается так:
<build> <finalName>agent</finalName> <plugins> <plugin> <artifactId>maven-assembly-plugin</artifactId> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> <appendAssemblyId>false</appendAssemblyId> <archive> <index>true</index> <manifestEntries> <Premain-Class>ru.sysout.bytecode.AgentMain</Premain-Class> </manifestEntries> </archive> </configuration> <executions> <execution> <id>make-assembly</id> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> </plugin> </plugins> </build>
Теперь перейдем к задаче — что же мы будем трансформировать.
Задача
Сразу скажу, что основное приложение у нас простейшее — один Main класс с одним пустым методом m(). В main() он вызывается. Код приложения приведен чуть ниже в разделе об использовании аннотации — метод m() аннотирован.
Трансформировать байт-код мы будем так — изменим код помеченных аннотацией @Log методов, а именно: в начало метода добавим вывод в консоль сообщения. Так вызовы методов будут логироваться в консоль. В нашем случае будет логироваться вызов единственного метода m().
Трансформировать байт-код аннотированных методов будем с помощью ClassTransformer.
Создание аннотации
Для начала создадим аннотацию в приложении-агенте:
public @interface Log { String message(); }
Важно также:
ClassTransformer
Теперь главное — напишем класс, который меняет байт-код.
Для трансформации нам понадобится библиотека javaassist:
<dependency> <groupId>org.javassist</groupId> <artifactId>javassist</artifactId> <version>3.24.0-GA</version> </dependency>
Последнюю версию можно найти тут.
Наш класс должен реализовывать интерфейс ClassFileTransformer из библиотеки javaassist:
public class ClassTransformer implements ClassFileTransformer { private ClassPool pool= ClassPool.getDefault(); public byte[] transform(ClassLoader loader, String className, Class classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { try { pool.insertClassPath(new ByteArrayClassPath(className, classfileBuffer)); CtClass cclass = pool.get(className.replaceAll("/", ".")); if (!cclass.getName().startsWith("ru.sysout.")) { return null; } for (CtMethod currentMethod : cclass.getDeclaredMethods()) { Log annotation = (Log) currentMethod.getAnnotation(Log.class); if (annotation != null) { currentMethod.insertBefore("{System.out.println(\"" + annotation.message() + "\");}"); } } return cclass.toBytecode(); } catch (Exception e) { e.printStackTrace(); } return null; } }
Этот метод возвращает новый байт-код класса либо null, если ничего не поменялось. Вызывается он для каждого класса приложения.
В методе мы:
- Проверяем, что класс находится в нашем пакете.
- Перебираем в цикле методы класса.
- Если нашли аннотированный метод, то изменяем его, вставляя в самое начало сообщение из аннотации.
Использование аннотации в основном приложении
Как уже было сказано, чтобы использовать аннотацию, надо включить агент как Maven-зависимость основного приложения:
<dependencies> <dependency> <groupId>ru.sysout</groupId> <artifactId>bytecodetransform-agent</artifactId> <version>1.0-SNAPSHOT</version> </dependency> </dependencies>
Теперь можно создать и аннотировать метод method() в нашем основном приложении. Мы вызываем его один раз:
public class Main { public static void main(String... args) { new Main().method(); } @Log(message = "method called") private void method() { } }
Этот метод ничего не делает. Но после трансформации байт кода он будет выводить в консоль значение message аннотации @Log:
method called
Чтобы вывод сработал, надо запустить наше основное приложение с агентом.
Как запускать основное приложение с агентом
Для этого надо передать агент agent.jar в параметре -javaagent виртуальной машины. То есть если наше основное приложение собирается в main.jar, то запускать его надо так:
java -jar main.jar -javaagent:"path-to\agent.jar"
Кавычки (на Windows) двойные.
Как результат в консоли мы получим:
Classes loaded: 732 method called
Можно задать параметры VM и в сборке Maven, но это оставим на отдельный коммент, если кому-то понадобится.
Можно не собирать основной main.jar, а запустить Main класс прямо из IDE:
В любом случае надо задать в параметрах VM путь до агента.
Итоги
Код доступен на GitHub.