Модификация байт-кода с помощью Instrumentation

В этой статье мы рассмотрим пакет 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();
}
Поскольку аннотация в агенте,  то чтобы ее использовать в основном приложении, придется наш агент включить в Maven-зависимость основного приложения.

Важно также:

Аннотация должна иметь уровень @Retention(RetentionPolicy.CLASS), что означает, что она будет присутствовать в скомпилированных .class-файлах. Этот уровень присущ аннотациям по умолчанию, так что можно ничего не писать.

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, если ничего не поменялось. Вызывается он для каждого класса приложения.

В методе мы:

  1. Проверяем, что класс находится в нашем пакете.
  2. Перебираем в цикле методы класса.
  3. Если нашли аннотированный метод, то изменяем его, вставляя в самое начало сообщение из аннотации.

Использование аннотации в основном приложении

Как уже было сказано, чтобы использовать аннотацию, надо включить агент как 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:

Конфигурация Run
Конфигурация Run

В любом случае надо задать в параметрах VM путь до агента.

Итоги

Код доступен на GitHub.

 

 

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *