ASM in Java

Posted on: February 20th, 2012 by Spade No Comments »

Довольно часто бывает, что нам нужно изменить/расширить существующую функциональность, но прибегнуть к традиционным подходам (наследование, перегрузка) мы не можем – то ли классы объявлены финальными, то ли из соображений чистоты архитектуры приложения. В таких случаях мы можем прибегнуть к AOP, который справляется с этой проблемой посредством прокси-объектов. Они «заворачивают» в себя оригинальные объекты, и перехватывая вызовы методов меняют функционал в требуемую сторону. Изменять поведение программы таки способом можно на трех этапах:

  1. Compile-time – с помощью специальной настройки IDE (плагин и т.п.) или использования DSL (domain specific language) разработанного специально для этого мы можем на этапе сборки кода внедрять туда нужные нам инструкции – конечно в случае, когда у нас есть доступ к исходникам.
  2. Class load time – можно написать свой класс лоадер, который будет загружать измененные классы вместо запрашиваемых.
  3. Runtime  – во время создания объекта класса, вместо него создается прокси, которые меняет функционал – с этим подходом работают большинство AOP-framework-ов.


Для того чтоб менять объекты на лету используются bytecode manipulation frameworks. Основные из них:

  1. BCEL – решение от apache, тяжеловатое, по мнению многих
  2. ASM – популярный открытый фреймворк

Последний считается более легковесным и простым в понимании. Его используют много известных  java-решений: cglib, terracotta, clojure, groovy, jython, jruby etc. Рассмотрим как он работает.

ASM

Анализ байткода выполняется двумя путями – разбор на лету, и построение дерева. Это схоже с идеологие работы с XML – SAX vs. DOM. Основной паттерн работы – Visitor. Есть классы для разбора объектов разного уровня: ClassVisitor, MethodVisitor, FieldVisitor.

Add field

В примерах фреймворка поставляется адаптер для добавления нового поля к классу. Разберем этот пример:

import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.FieldVisitor;
 
import static org.objectweb.asm.Opcodes.ASM4;
 
public class AddFieldAdapter extends ClassVisitor {
 
    private int fAcc;
    private String fName;
    private String fDesc;
 
    public AddFieldAdapter(ClassVisitor cv, int fAcc, String fName,
                           String fDesc) {
        super(ASM4, cv);
        this.fAcc = fAcc;
        this.fName = fName;
        this.fDesc = fDesc;
    }
 
    @Override
    public void visitEnd() {
        FieldVisitor fv = cv.visitField(fAcc, fName, fDesc, null, null);
        if (fv != null) {
            fv.visitEnd();
        }
        cv.visitEnd();
    }
}

Мы создаем адаптер на основе ClassVisitor – для того чтобы проходя по классу что-то с этим самым классом сделать (добавить поле). Чтобы не нарушить структуру лучше всего добавлять поле в конец класса. Мы там очутимся когда будет вызван метод visitEnd() – это значит что мы достигли конца, и можно спокойно действовать не боясь влезть в какую-нибудь структуру. Тем более это правильно в случае, если порядок членов класса важен (мало ли что люди курят, когда пишут свой код). Там мы вызываем функцию visitField с параметрами нашего поля (имя, тип, уровень доступа). Значение мы можем задать сразу только если добавляем константу, в противном случае поле будет «пустым». Что нам теперь делать с этим адаптером?

public  Class execute(Class clazz) throws Exception {
 
        String className = clazz.getName();
        String classAsPath = className.replace('.', '/') + ".class";
 
        ClassReader cr = new ClassReader(clazz.getClassLoader().getResourceAsStream(classAsPath));
        ClassWriter cw = new ClassWriter(cr, 0);
 
        AddFieldAdapter cv = new AddFieldAdapter(cw, fieldNode.access, fieldNode.name, fieldNode.desc);
        cr.accept(cv, 0);
 
        return getClassFromBytes(clazz, cw.toByteArray());
    }

Класс ClassReader зачитает бинарное содержимое нашего класса (байткод). ClassWriter будет использоваться чтобы изменять это содержимое. А наш AddFieldAdapter укажет каким именно образом должны происходить изменения. Последняя строчка запускает процесс переработки. Однако тут мы сталкиваемся с небольшой неожиданностью – на выходе вместо объекта класса у нас тоже бинарное содержимое (массив байт результирующего класса) – как и на входе. Чтобы превратить это в тип, нам нужен особый класс лоадер. Авторы фреймворка предлагают такой выход:

public class ByteCodeClassLoader extends ClassLoader{
 
    public Class defineClass(String name, byte[] bytes) {
        return defineClass(name, bytes, 0, bytes.length);
    }
}

Мы расширяем класс-лоадер и используем его protected функцию чтобы создать класс прямо из массива байт.

 public  Class getClassFromBytes(Class clazz, byte[] bytes) {
        ByteCodeClassLoader byteCodeClassLoader = new ByteCodeClassLoader();
        return byteCodeClassLoader.defineClass(clazz.getName(), bytes);
    }

Объект у нас не совсем понятного типа и потому чтоб получить доступ к его новому полю нужен reflection подход.
Для вывода нового содержимого класса воспользуемся ToStringBuilder.reflectionToString от apache.

...
Object obj = aClass.newInstance();
obj.getClass().getField("ownerName").set(obj, "Bruce Willis");
System.out.println("objModified = " + ToStringBuilder.reflectionToString(obj, ToStringStyle.MULTI_LINE_STYLE));
...

Change Method

Более интересный пример – изменить код существующего метода. Допустим у нас есть некий класс, проводящий авторизацию (сильно упрощенный):

public class MyBean {
 
    public void authorize(boolean valid) {
 
        if (valid) {
            System.out.println("User is valid!");
        } else {
            System.out.println("User is NOT valid!");
        }
    }
}

Задача – сделать так чтобы авторизация всегда проходила. Очевидно, что хватит добавления следующей инструкции в начало метода:

valid = true;

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

public class AuthorizeMethodAdapter extends MethodVisitor {
 
    public AuthorizeMethodAdapter(MethodVisitor mv) {
        super(ASM4, mv);
    }
 
    @Override
    public void visitCode() {
        super.visitCode();
        mv.visitInsn(ICONST_1);
        mv.visitVarInsn(ISTORE, 1);
    }
}

Расширяя класс MethodVisitor мы изменили его функцию visitCode() – она будет вызвана тогда, когда парсер байткода дойдет до тела метода (но еще не перейдет к чтению инструкций). Там мы поместим константу и зачитаем её в стэк (откуда она потом будет считана для if-инструкции). Дальше определим адаптер типа ClassVisitor – который будет парсить класс, содержащий наш метод.

public class AuthorizeMethodClassAdapter extends ClassVisitor {
 
    private static final String NAME = "authorize";
 
    public AuthorizeMethodClassAdapter(ClassVisitor cv) {
        super(ASM4, cv);
    }
 
    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        MethodVisitor mv;
        mv = super.visitMethod(access, name, desc, signature, exceptions);
        if (mv != null && name.equals(NAME)) {
            mv = new AuthorizeMethodAdapter(mv);
        }
        return mv;
    }
}

Чтобы случайно не добавить инструкцию не туда – проверяем имя метода (в идеале нужно искать еще и по списку параметров, так как метод может быть перегружен). Если тот, что нужно – передаем метод на обработку нашему адаптеру, написаному ранее. Дальнейшие действия аналогичны:

        ...
        ClassReader cr = new ClassReader(clazz.getClassLoader().getResourceAsStream(classAsPath));
        ClassWriter cw = new ClassWriter(cr, 0);
 
        AuthorizeMethodClassAdapter cv = new AuthorizeMethodClassAdapter(cw);
        cr.accept(cv, 0);
        ...

Вызовем проверочный метод у полученного класса:

        ...
        Object obj = aClass.newInstance();
        obj.getClass().getMethod("authorize", boolean.class).invoke(obj, false);
        ...

На выводе даст: User is valid!. Полный код maven-проекта лежит на github. Для корректного запуска нужно обеспечить maven доступ к библиотекам asm последней версии (на момент написания – 4.0). Для этого можно, например, скачать их с официального сайта и проинсталировать в локальный репозиторий:

mvn install:install-file -DgroupId=asm  -DartifactId=asm-debug-all  -Dversion=4.0 -Dfile= -Dpackaging=jar

Удачи!

Leave a Reply