Часть первая

Всем большой привет! Перед началом стоит сказать, что библиотека Javassist довольно мощный инструмент, так как стирает почти все границы у того безграничного языка JAVA, позволяя разработчику осуществлять манипуляции связанные с байткодом.

Конечно, получив доступ к байткоду, а ровно и к возможности воздействовать на этот самый байткод вам совсем не обязательно вклиниваться в него. Javassist можно использовать и в “мирных” целях!

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

Итак, если после всех предостережений вы все же решили использовать эту библиотеку, то давайте начинать!

В этой статье мы рассмотрим Javassist, как инструмент, с помощью которого мы будем вклиниваться в существующий байткод и трансформировать его.

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

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

Ответ очевиден – приложение не может само себя изменять. Т.е. приложение не может само изменять свой же байткод. Это должен делать кто-то другой. Этот кто-то другой – такое же Java приложение, но заточенное на работу с байткодом.

Итак, теперь мы знаем, что использование второго приложения, в котором и будет крутиться вся логика, связанная с использованием Javassist просто неизбежно. Дело в том, что это самое приложение загружается в JVM первым, разворачивается там и начинает пропускать через себя все классы, которые необходимы для работы уже самого целевого приложения.

Что же происходит под капотом JVM? Каким образом первое приложение с Javassist может пропускать через себя байткод? Как это вообще работает?

Все мы привыкли видеть в Maven такой тег как <manifestFile>. В этом теге указывается путь до файла MANIFEST.MF. В свою очередь в файле манифеста прописывается точка входа в приложение. Всегда это выглядит так: Main-Class: путь до класса.Класс в котором расположен одноименный метод main. Но Javassist в силу своей специфики априори не может запускаться как обычное Java-приложение. Должно быть что-то, что отличает такое “чудо-приложение” от обычного. И, конечно, такая особенность есть. Дело в том, что в приложение Javassist нет привычного для нас всех метода main. Вместо этого метода используется метод, который именуется как premain. В принципе, название говорит само за себя. Этот метод главнее чем метод main. Собственно, потому он и называется premain. Логично, что и содержимое файла манифеста MANIFEST.MF теперь будет другим. Вместо “Main-Class” теперь будет использоваться “Premain-Class:”.

Содержимое MANIFEST.MF с Premain-Class:

Manifest-Version: 1.0
Premain-Class: app.Agent
Built-By: Vasilyev Pavel
Created-By: Apache Maven 3.6.1
Build-Jdk: 1.8.0_201

Листинг 1.

На вкус и цвет…, но все же не несущие особого value параметры я удалю:

Premain-Class: app.Agent

Листинг 2.

Так будет выглядеть MANIFEST.MF, если мы все это будем прописывать руками. Этот манифест в дальнейшем будет зашит в JAR файл и лежать вместе с package, в которых находятся скомпилированные Java-классы. Чтобы избежать лишнего конфигурирования мы воспользуемся плагином, который сам все упакует и подложит куда нужно.

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-shade-plugin</artifactId>
    <version>3.2.4</version>
    <executions>
        <execution>
            <phase>package</phase>
            <goals>
                <goal>shade</goal>
            </goals>
            <configuration>
                <finalName>agent</finalName>
                <transformers>
                    <transformer
                            implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                        <manifestEntries>
                            <Premain-Class>app.Agent</Premain-Class>
                        </manifestEntries>
                    </transformer>
                </transformers>
            </configuration>
        </execution>
    </executions>
</plugin>

Листинг 3.

И конечно же мы не можем забыть, собственно, о самой библиотеке Javassist.

<dependency>
    <groupId>org.javassist</groupId>
    <artifactId>javassist</artifactId>
    <version>3.20.0-GA</version>
</dependency>

Листинг 4.

В итоге мы имеем Maven-проект с подключенной библиотекой в зависимостях pom.xml.

Весь код pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.agent</groupId>
    <artifactId>javaagent</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven-compiler-plugin.source.version>1.8</maven-compiler-plugin.source.version>
        <maven-compiler-plugin.target.version>1.8</maven-compiler-plugin.target.version>
        <maven-compiler-plugin.inherited>true</maven-compiler-plugin.inherited>
        <maven-compiler-plugin.encoding.version>UTF-8</maven-compiler-plugin.encoding.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.javassist</groupId>
            <artifactId>javassist</artifactId>
            <version>3.20.0-GA</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <scope>compile</scope>
            <version>3.11</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>3.2.4</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                        <configuration>
                            <finalName>agent</finalName>
                            <transformers>
                                <transformer
                                        implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                    <manifestEntries>
                                        <Premain-Class>app.Agent</Premain-Class>
                                    </manifestEntries>
                                </transformer>
                            </transformers>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

Листинг 5.

Теперь, когда все готово для упаковки в нужную структуру, можно написать и сам код, который будет исполняться и пропускать через себя байткод скомпилированных классов из другого JAR файла. Но так как мы еще не написали Java-код для целевого Java-проекта, то оставим эту заготовку в виде Maven проекта с подключенной библиотекой, а потом дополним эту заготовку логикой по трансформации исполняемого байткода.

Пишем целевое Java-приложение

Это будет самый обычный java-проект, в котором существует класс Main:

public class Main {

    static int myInt1 = 77;

    public static void main(String[] args) {
        System.out.println("Hello World and myInt1 = " + myInt);
    }
}

Листинг 6.

Наша цель:

Внедрить в байткод класса Main свою реализацию метода main и запустить код на исполнение. Итак, наше целевое приложение для опытов написано! Теперь соберем его в Jar архив и приступим к наполнению логикой нашей заготовки, которую мы бережно написали в первой части.

Работаем с Javassist

Ранее мы подготовили структуру проекта и подключили необходимые зависимости.

Напишем саму логику внедрения в байткод. Для этого создадим класс “Agent” и добавим в него единственный метод “premain”. Данный метод абсолютно такой же как метод “main” в простом классическом приложении типа “Hello World” с той лишь разницей, что метод “main” теперь – “premain” и этот метод принимает на вход два параметра. Хоть разница и не значительная но после применения приставки “pre”, наше приложение перестает быть классическим и его уже не получится так просто собрать в два клика через Intellij Idea.

public static void premain(String agentArgs, Instrumentation instrumentation) {
}

Листинг 7.

Собрать такой проект можно либо руками, компилируя каждый класс, создавая манифест файл, а потом запаковывать все в Jar файл, либо создать Maven проект и автоматизировать всю сборку при помощи определенных “plugin”. Конечно же мы выберем второй вариант, через Maven, так как нам нужно подключить еще и саму библиотеку Javassist.

 Здесь-то нам и пригодится наш подготовленный конфиг файла pom.xml (см.выше).

 Главный метод “premain” создан и теперь нужно написать соответствующую логику по трансформации байткода целевого приложения. Для этого воспользуемся ”Instrumentation”, ссылку на который мы получили в параметрах. Только перед тем, как использовать ”Instrumentation” мы должны добавить класс, в котором будет описана логика по трансформации загружаемого байткода из классов.

Вот сам класс, в котором происходит трансформация байткода (ниже будет пояснение):

public class ClassTransformer implements ClassFileTransformer {

@Override
public byte[] transform(final ClassLoader loader,
                        final String className,
                        final Class<?> classBeingRedefined,
                        final ProtectionDomain protectionDomain,
                        final byte[] classfileBuffer) {

        byte[] byteCode = classfileBuffer;

        if ("com.company.Main".equals(className.replaceAll("/", "."))) {

            try {
                ClassPool pool = ClassPool.getDefault();
                CtClass ctClass = pool.get("com.company.Main");
                CtMethod myMain = ctClass.getDeclaredMethod("main");
                ctClass.removeMethod(myMain);

                CtField toBeDeleted = ctClass.getField("myInt1");
                ctClass.removeField(toBeDeleted);
                CtField ctField = new CtField(CtClass.intType, "myInt1", ctClass);
                ctField.setModifiers(Modifier.STATIC | Modifier.FINAL | Modifier.PUBLIC);
                ctClass.addField(ctField, "123");

                CtField name = CtField.make("static int myInt2 = 45;", ctClass);
                ctClass.addField(name);

                ctClass.addMethod(CtNewMethod.make("public static void main(String[] args) { int localInt = 67; System.out.println(\"Our numbers : \" + myInt1 + \" : \" + myInt2 + \" : \" + localInt);}", ctClass));
                ctClass.addMethod(CtNewMethod.make("public void onEvent(){System.out.println(\"Hello World\");}", ctClass));

                CtMethod[] methods = ctClass.getDeclaredMethods();

                for (CtMethod method : methods) {
                    System.out.println("!!!!!!! + " + method.getName());
                    if (method.getName().equals("main")) {
                        try {
                            method.insertAfter("System.out.println(\"Logging using Agent\");");
                        } catch (CannotCompileException e) {
                            e.printStackTrace();
                        }
                    }
                }
                try {
                    byteCode = ctClass.toBytecode();
                    ctClass.detach();
                    return byteCode;
                } catch (IOException e) {
                    e.printStackTrace();
                }
                ctClass.detach();
                return byteCode;
            } catch (NotFoundException e) {
                System.out.println(e.getMessage());
            } catch (CannotCompileException e) {
                e.printStackTrace();
            }

        }
        return byteCode;
    }
}

Листинг 8.

А теперь добавим это класс в качестве аргумента для метода addTransformer.

instrumentation.addTransformer(new ClassTransformer());

Листинг 9.

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

 Перед проверкой мы конечно же разберем что происходит в переопределенном методе “transform” (Листинг 8).

Самое первое на что мы обращаем внимание, так это на то, что данный метод возвращает массив байтов. Дело в том, что на вход данного метода одним из параметров подается массив байт от класса, который загружается. Таким образом в метод “transform” попадают абсолютно все классы, которые загружаются в JVM. Точнее не сами классы, а байткод классов. Именно в тот момент, когда в наш метод “transform” попал байткод определенного класса мы можем трансформировать его на свой лад и вернуть байткод, но в уже в “редактированном виде”. Такое “редактирование” называется “трансформация”.

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

С ее помощью, мы будем оперировать байткодом точно так же, как мы оперируем на низком уровне кодом, когда что-то пишем на языке более высокого уровня. Это весьма удобно.

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

Начинается самое интересное. Помните, в листинге 6, мы написали нашу стандартную программу из разряда “Hello World”? В нашем варианте мы усовершенствовали “Hello World” и к одноименной фразе добавили еще вывод числа “myInt1”. Теперь наша программа выводит в консоль фразу "Hello World and myInt1 = 77".

Попробуем “трансформировать” байткод класса Main, а именно изменить вывод данной строки, да не просто изменить, а еще попытаемся определить новые переменные, присвоить им значения и вывести все это в консоль!

В листинге 8, видно, что общаться с кодом, через библиотеку Javassist довольно просто. Первое что мы должны сделать – остановиться на том классе, в байткод которого требуется вмешательство. Поэтому обращаемся к переменной “classname” и проверяем на нужном ли классе мы находимся. Если на нужном, то объект, который будет подвержен трансформации найден и можно его начать изменять.

Думаю, что дальше коментарии будут лишними, так как в коде и так все понятно.

Итак, осталось сделать последнее действие – запустить оба наших jar-архива! Это можно сделать командой:

java -javaagent:agent.jar -jar demo.jar

Следует отметить, что запускаемый javaagent – обычная java-программа, а следовательно при запуске можно прописывать все ключи, которые могут быть вам необходимы.

После трансформации наш вывод будет иметь следующий вид:

Our numbers : 123 : 45 : 67

Summary:

Наверное на этом стоит остановиться, потому что чем хотел поделиться я поделился:

- мы научились создавать java-проект, который загружается первым и трансформирует код другого загружаемого проекта, путем пропускания его байткода через себя;

- мы научились писать код для трансформации загружаемого байткода;

- мы научились запускать последовательность созданных jar-файлов javaagent и целевого приложения.

 Надеюсь эта статья помогла тем, кто хочет начать изучение библиотеки Javassist.

Если есть какие-то дополнения и комментарии, то пожалуйста пишите. Буду рад любой обратной связи. Так же прошу поделиться своими знаниями по тонкостям использования данной библиотеки.

Всем большое спасибо и больших успехов!

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


  1. AndanteMQ
    07.05.2022 20:51

    Подписался на продолжение!


  1. sshikov
    07.05.2022 21:21
    +4

    Ответ очевиден – приложение не может само себя изменять.

    По мне так не очевиден. Приложение очевидно может сгенерировать байткода, загрузить его класслоадером, и вызвать полученные классы. И чем это не изменение самого себя? А тот же javaagent — это отдельное приложение, или часть того же самого приложения? А если он в том же самом jar, что и основная часть (или вы считаете, что так нельзя)? Не то чтобы это была претензия по сути, скорее — нужно четче определить что вы тут называете приложением. По мне, так та часть, что изменяет байткод, должна (ну как правило) знать, что за код она изменяет. И их вполне можно считать частью одного приложения. Как скажем, ядро и plugins.


    1. upagge
      08.05.2022 12:03

      Я вот тоже не понял этот момент, читал на ходу. Ломбок вот вроде отлично справляется с изменением байткода моего приложения.


      1. PROgrammer_JARvis
        08.05.2022 14:42

        Ломбок редактирует не байткод.

        Он работает на уровне сурсов (если точнее, то AST), используя внутренние API javac/ecj (публичное API не даёт редактировать дерево существующих сурсов, а только позволяет создавать новые).


  1. Stiver
    08.05.2022 19:18

    Ответ очевиден – приложение не может само себя изменять. Т.е. приложение не может само изменять свой же байткод.
    Во времена былинные можно было писать и self-modifying байткод. Сейчас да, только через подгрузку классов.


  1. antonarhipov
    09.05.2022 14:09

    К сожалению, в статье нет хорошего конкретного обоснования, зачем это нужно.

    Предположим, вы нашли баг в библиотеке

    Если в какой-то библиотеке баг, то надо не преобразовывать байткод, а исправлять эту ошибку в библиотеке.

    А как можно использовать Javassist, в интернетах информации пруд пруди.


    1. sshikov
      09.05.2022 18:42

      >Если в какой-то библиотеке баг, то надо не преобразовывать байткод, а исправлять эту ошибку в библиотеке.
      Ну, иногда проще так. По сути, модификация байткода или исходников (с пересборкой) — результат-то один, а вот затраты труда могут быть очень разными.


  1. aleksandy
    10.05.2022 19:40

    if ("com.company.Main".equals(className.replaceAll("/", "."))) {
      ...
    }
    return byteCode;

    Автор, читайте внимательнее документацию, чтобы не нагружать приложение ненужной работой.

    Returns:

    a well-formed class file buffer (the result of the transform), or nullif no transform is performed.