Перейти к основному содержимому

Руководство по манипулированию байт-кодом Java с помощью ASM

· 9 мин. чтения

1. Введение

В этой статье мы рассмотрим, как использовать библиотеку ASM для управления существующим классом Java путем добавления полей, добавления методов и изменения поведения существующих методов.

2. Зависимости

Нам нужно добавить зависимости ASM в наш pom.xml :

<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm</artifactId>
<version>6.0</version>
</dependency>
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm-util</artifactId>
<version>6.0</version>
</dependency>

Мы можем получить последние версии asm и asm-util от Maven Central.

3. Основы ASM API

ASM API предоставляет два стиля взаимодействия с классами Java для преобразования и генерации: на основе событий и на основе дерева.

3.1. API на основе событий

Этот API в значительной степени основан на шаблоне Visitor и похож на модель синтаксического анализа SAX для обработки XML-документов. В своей основе он состоит из следующих компонентов:

  • ClassReader — помогает читать файлы классов и является началом преобразования класса.
  • ClassVisitor — предоставляет методы, используемые для преобразования класса после чтения необработанных файлов классов.
  • ClassWriter — используется для вывода конечного продукта преобразования класса.

Именно в ClassVisitor у нас есть все методы посетителей, которые мы будем использовать для доступа к различным компонентам (полям, методам и т. д.) данного класса Java. Мы делаем это, предоставляя подкласс ClassVisitor для реализации любых изменений в данном классе.

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

Методы ClassVisitor в API на основе событий вызываются в следующем порядке:

visit
visitSource?
visitOuterClass?
( visitAnnotation | visitAttribute )*
( visitInnerClass | visitField | visitMethod )*
visitEnd

3.2. API на основе дерева

Этот API является более объектно-ориентированным API и аналогичен модели обработки XML-документов JAXB.

Он по-прежнему основан на API, основанном на событиях, но в нем представлен корневой класс ClassNode . Этот класс служит точкой входа в структуру класса.

4. Работа с API ASM на основе событий

Мы изменим класс java.lang.Integer с помощью ASM. И на этом этапе нам нужно понять фундаментальную концепцию: класс ClassVisitor содержит все необходимые методы посетителей для создания или изменения всех частей класса .

Нам нужно только переопределить необходимый метод посетителя, чтобы реализовать наши изменения. Начнем с настройки необходимых компонентов:

public class CustomClassWriter {

static String className = "java.lang.Integer";
static String cloneableInterface = "java/lang/Cloneable";
ClassReader reader;
ClassWriter writer;

public CustomClassWriter() {
reader = new ClassReader(className);
writer = new ClassWriter(reader, 0);
}
}

Мы используем это в качестве основы для добавления интерфейса Cloneable к стандартному классу Integer , а также добавляем поле и метод.

4.1. Работа с полями

Давайте создадим наш ClassVisitor , который мы будем использовать для добавления поля в класс Integer :

public class AddFieldAdapter extends ClassVisitor {
private String fieldName;
private String fieldDefault;
private int access = org.objectweb.asm.Opcodes.ACC_PUBLIC;
private boolean isFieldPresent;

public AddFieldAdapter(
String fieldName, int fieldAccess, ClassVisitor cv) {
super(ASM4, cv);
this.cv = cv;
this.fieldName = fieldName;
this.access = fieldAccess;
}
}

Далее давайте переопределим метод visitField , где мы сначала проверяем, существует ли уже поле, которое мы планируем добавить, и устанавливаем флаг для обозначения статуса .

Нам все еще нужно перенаправить вызов метода в родительский класс — это должно произойти, поскольку метод visitField вызывается для каждого поля в классе. Неспособность переадресовать вызов означает, что в класс не будут записаны поля.

Этот метод также позволяет нам изменять видимость или тип существующих полей :

@Override
public FieldVisitor visitField(
int access, String name, String desc, String signature, Object value) {
if (name.equals(fieldName)) {
isFieldPresent = true;
}
return cv.visitField(access, name, desc, signature, value);
}

Сначала мы проверяем флаг, установленный в предыдущем методе visitField , и снова вызываем метод visitField , на этот раз предоставляя имя, модификатор доступа и описание. Этот метод возвращает экземпляр FieldVisitor.

Метод visitEnd вызывается последним в порядке методов посетителя. Это рекомендуемая позиция для выполнения логики вставки поля .

Затем нам нужно вызвать метод visitEnd для этого объекта, чтобы сообщить, что мы закончили посещение этого поля:

@Override
public void visitEnd() {
if (!isFieldPresent) {
FieldVisitor fv = cv.visitField(
access, fieldName, fieldType, null, null);
if (fv != null) {
fv.visitEnd();
}
}
cv.visitEnd();
}

Важно убедиться, что все используемые компоненты ASM взяты из пакета org.objectweb.asm — многие библиотеки используют библиотеку ASM внутри, и IDE могут автоматически вставлять связанные библиотеки ASM.

Теперь мы используем наш адаптер в методе addField , получая преобразованную версию java.lang.Integer с нашим добавленным полем:

public class CustomClassWriter {
AddFieldAdapter addFieldAdapter;
//...
public byte[] addField() {
addFieldAdapter = new AddFieldAdapter(
"aNewBooleanField",
org.objectweb.asm.Opcodes.ACC_PUBLIC,
writer);
reader.accept(addFieldAdapter, 0);
return writer.toByteArray();
}
}

Мы переопределили методы visitField и visitEnd .

Все, что нужно сделать с полями, происходит с помощью метода visitField . Это означает, что мы также можем изменить существующие поля (скажем, преобразовать частное поле в общедоступное), изменив нужные значения, переданные методу visitField .

4.2. Работа с методами

Генерация целых методов в ASM API требует больше усилий, чем другие операции в классе. Это включает в себя значительное количество низкоуровневых манипуляций с байт-кодом и, как следствие, выходит за рамки этой статьи.

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

Сделаем метод toUnsignedString общедоступным:

public class PublicizeMethodAdapter extends ClassVisitor {
public PublicizeMethodAdapter(int api, ClassVisitor cv) {
super(ASM4, cv);
this.cv = cv;
}
public MethodVisitor visitMethod(
int access,
String name,
String desc,
String signature,
String[] exceptions) {
if (name.equals("toUnsignedString0")) {
return cv.visitMethod(
ACC_PUBLIC + ACC_STATIC,
name,
desc,
signature,
exceptions);
}
return cv.visitMethod(
access, name, desc, signature, exceptions);
}
}

Как и в случае с модификацией поля, мы просто перехватываем метод посещения и изменяем нужные параметры .

В этом случае мы используем модификаторы доступа в пакете org.objectweb.asm.Opcodes для изменения видимости метода . Затем мы подключаем наш ClassVisitor :

public byte[] publicizeMethod() {
pubMethAdapter = new PublicizeMethodAdapter(writer);
reader.accept(pubMethAdapter, 0);
return writer.toByteArray();
}

4.3. Работа с классами

Аналогично модификации методов мы модифицируем классы, перехватывая соответствующий метод посетителя . В данном случае мы перехватываем visit , который является самым первым методом в иерархии посетителей:

public class AddInterfaceAdapter extends ClassVisitor {

public AddInterfaceAdapter(ClassVisitor cv) {
super(ASM4, cv);
}

@Override
public void visit(
int version,
int access,
String name,
String signature,
String superName, String[] interfaces) {
String[] holding = new String[interfaces.length + 1];
holding[holding.length - 1] = cloneableInterface;
System.arraycopy(interfaces, 0, holding, 0, interfaces.length);
cv.visit(V1_8, access, name, signature, superName, holding);
}
}

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

5. Использование модифицированного класса

Итак, мы изменили класс Integer . Теперь нам нужно иметь возможность загружать и использовать модифицированную версию класса.

Помимо простой записи вывода write.toByteArray на диск в виде файла класса, есть и другие способы взаимодействия с нашим настроенным классом Integer .

5.1. Использование TraceClassVisitor

Библиотека ASM предоставляет служебный класс TraceClassVisitor , который мы будем использовать для самоанализа измененного класса . Таким образом, мы можем подтвердить, что наши изменения произошли .

Поскольку TraceClassVisitor является ClassVisitor , мы можем использовать его в качестве замены стандартного ClassVisitor :

PrintWriter pw = new PrintWriter(System.out);

public PublicizeMethodAdapter(ClassVisitor cv) {
super(ASM4, cv);
this.cv = cv;
tracer = new TraceClassVisitor(cv,pw);
}

public MethodVisitor visitMethod(
int access,
String name,
String desc,
String signature,
String[] exceptions) {
if (name.equals("toUnsignedString0")) {
System.out.println("Visiting unsigned method");
return tracer.visitMethod(
ACC_PUBLIC + ACC_STATIC, name, desc, signature, exceptions);
}
return tracer.visitMethod(
access, name, desc, signature, exceptions);
}

public void visitEnd(){
tracer.visitEnd();
System.out.println(tracer.p.getText());
}

Здесь мы адаптировали ClassVisitor , который мы передали нашему более раннему PublicizeMethodAdapter с помощью TraceClassVisitor .

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

Хотя в документации ASM указано, что TraceClassVisitor может распечатывать данные в PrintWriter , предоставленном конструктору, в последней версии ASM это не работает должным образом.

К счастью, у нас есть доступ к базовому принтеру в классе, и мы можем вручную распечатать текстовое содержимое трассировщика в нашем переопределенном методе visitEnd .

5.2. Использование инструментов Java

Это более элегантное решение, которое позволяет нам работать с JVM на более близком уровне через Instrumentation .

Чтобы инструментировать класс java.lang.Integer , мы пишем агент, который будет настроен как параметр командной строки с JVM . Агент требует двух компонентов:

  • Класс, который реализует метод с именем premain
  • Реализация ClassFileTransformer, в которой мы будем условно предоставлять модифицированную версию нашего класса.
public class Premain {
public static void premain(String agentArgs, Instrumentation inst) {
inst.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform(
ClassLoader l,
String name,
Class c,
ProtectionDomain d,
byte[] b)
throws IllegalClassFormatException {
if(name.equals("java/lang/Integer")) {
CustomClassWriter cr = new CustomClassWriter(b);
return cr.addField();
}
return b;
}
});
}
}

Теперь мы определяем наш предварительный класс реализации в файле манифеста JAR с помощью подключаемого модуля Maven jar:

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>2.4</version>
<configuration>
<archive>
<manifestEntries>
<Premain-Class>
com.foreach.examples.asm.instrumentation.Premain
</Premain-Class>
<Can-Retransform-Classes>
true
</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>

На данный момент сборка и упаковка нашего кода создает банку, которую мы можем загрузить в качестве агента. Чтобы использовать наш настроенный класс Integer в гипотетическом « YourClass.class »:

java YourClass -javaagent:"/path/to/theAgentJar.jar"

6. Заключение

В то время как мы реализовали наши преобразования здесь по отдельности, ASM позволяет нам объединять несколько адаптеров в цепочку для достижения сложных преобразований классов.

В дополнение к базовым преобразованиям, которые мы рассмотрели здесь, ASM также поддерживает взаимодействие с аннотациями, обобщениями и внутренними классами.

Мы увидели некоторые возможности библиотеки ASM — она снимает множество ограничений, с которыми мы можем столкнуться при использовании сторонних библиотек и даже стандартных классов JDK.

ASM широко используется под капотом некоторых самых популярных библиотек (Spring, AspectJ, JDK и т. д.) для выполнения большого количества «волшебства» на лету.

Исходный код этой статьи вы можете найти в проекте GitHub .