Знакомимся с Javassist
Часть первая
Всем большой привет! Перед началом стоит сказать, что библиотека Javassist довольно мощный инструмент, так как стирает почти все границы у того безграничного языка JAVA, позволяя разработчику осуществлять манипуляции связанные с байткодом.
Конечно, получив доступ к байткоду, а ровно и к возможности воздействовать на этот самый байткод вам совсем не обязательно вклиниваться в него. Javassist можно использовать и в «мирных» целях!
При многообразии возможностей не стоит забывать о том, что использовать библиотеку нужно только тогда, когда это действительно необходимо. Использование данного инструмента делает основной код менее понятным, так как после применения Javassist у вас появится второе полноценное приложение, в котором будет жить Java со своей логикой.
Итак, если после всех предостережений вы все же решили использовать эту библиотеку, то давайте начинать!
В этой статье мы рассмотрим Javassist, как инструмент, с помощью которого мы будем вклиниваться в существующий байткод и трансформировать его.
Потребоваться это может в разных случаях. Например, у вас под рукой есть некая библиотека, в которой все классы уже скомпилированы и все зависимости вместе с запускающим методом упакованы в JAR архив. И вот, случилось так, что по какой-то причине вам нужно изменить реализацию того или иного метода. Предположим, вы нашли баг в библиотеке, или возможно требуется, чтобы были выполнены какие-нибудь дополнительные действия, в виде подсчета чего-нибудь, отправки оповещения о том или ином событии при старте вашего приложения и при определенных условиях.
Как и говорилось выше, после использования Javassist у вас появится второе полноценное приложение, в котором будет жить Java со своей логикой. Почему это происходит? Почему нельзя запаковать все в одно приложение?
Ответ очевиден — приложение не может само себя изменять. Т.е. приложение не может само изменять свой же байткод. Это должен делать кто-то другой. Этот кто-то другой — такое же Java приложение, но заточенное на работу с байткодом.
Итак, теперь мы знаем, что использование второго приложения, в котором и будет крутиться вся логика, связанная с использованием Javassist просто неизбежно. Дело в том, что это самое приложение загружается в JVM первым, разворачивается там и начинает пропускать через себя все классы, которые необходимы для работы уже самого целевого приложения.
Что же происходит под капотом JVM? Каким образом первое приложение с Javassist может пропускать через себя байткод? Как это вообще работает?
Все мы привыкли видеть в Maven такой тег как
Содержимое 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-классы. Чтобы избежать лишнего конфигурирования мы воспользуемся плагином, который сам все упакует и подложит куда нужно.
org.apache.maven.plugins
maven-shade-plugin
3.2.4
package
shade
agent
app.Agent
Листинг 3.
И конечно же мы не можем забыть, собственно, о самой библиотеке Javassist.
org.javassist
javassist
3.20.0-GA
Листинг 4.
В итоге мы имеем Maven-проект с подключенной библиотекой в зависимостях pom.xml.
Весь код pom.xml:
4.0.0
com.agent
javaagent
1.0-SNAPSHOT
1.8
1.8
true
UTF-8
org.javassist
javassist
3.20.0-GA
org.apache.commons
commons-lang3
compile
3.11
org.apache.maven.plugins
maven-shade-plugin
3.2.4
package
shade
agent
app.Agent
Листинг 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.
Если есть какие-то дополнения и комментарии, то пожалуйста пишите. Буду рад любой обратной связи. Так же прошу поделиться своими знаниями по тонкостям использования данной библиотеки.
Всем большое спасибо и больших успехов!