Una guida alla manipolazione del bytecode Java con ASM

1. Introduzione

In questo articolo vedremo come utilizzare la libreria ASM per manipolare una classe Java esistente aggiungendo campi, aggiungendo metodi e modificando il comportamento dei metodi esistenti.

2. Dipendenze

Dobbiamo aggiungere le dipendenze ASM al nostro pom.xml :

 org.ow2.asm asm 6.0   org.ow2.asm asm-util 6.0  

Possiamo ottenere le ultime versioni di asm e asm-util da Maven Central.

3. Nozioni di base sull'API ASM

L'API ASM fornisce due stili di interazione con le classi Java per la trasformazione e la generazione: basata su eventi e basata su albero.

3.1. API basata su eventi

Questa API è fortemente basata sul pattern Visitor ed è simile al modello di analisi SAX per l' elaborazione dei documenti XML. È composto, al suo interno, dai seguenti componenti:

  • ClassReader : aiuta a leggere i file di classe ed è l'inizio della trasformazione di una classe
  • ClassVisitor : fornisce i metodi utilizzati per trasformare la classe dopo aver letto i file di classe non elaborati
  • ClassWriter : viene utilizzato per produrre il prodotto finale della trasformazione della classe

È nel ClassVisitor che abbiamo tutti i metodi del visitatore che useremo per toccare i diversi componenti (campi, metodi, ecc.) Di una data classe Java. Lo facciamo fornendo una sottoclasse di ClassVisitor per implementare eventuali modifiche in una data classe.

A causa della necessità di preservare l'integrità della classe di output riguardo alle convenzioni Java e al bytecode risultante, questa classe richiede un ordine rigoroso in cui i suoi metodi dovrebbero essere chiamati per generare l'output corretto.

I metodi ClassVisitor nell'API basata su eventi vengono chiamati nel seguente ordine:

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

3.2. API basata su albero

Questa API è un'API più orientata agli oggetti ed è analoga al modello JAXB di elaborazione dei documenti XML.

È ancora basato sull'API basata su eventi, ma introduce la classe radice ClassNode . Questa classe funge da punto di ingresso nella struttura della classe.

4. Utilizzo dell'API ASM basata su eventi

Modificheremo la classe java.lang.Integer con ASM. E dobbiamo cogliere un concetto fondamentale a questo punto: la classe ClassVisitor contiene tutti i metodi visitatore necessari per creare o modificare tutte le parti di una classe .

Abbiamo solo bisogno di sovrascrivere il metodo visitatore necessario per implementare le nostre modifiche. Cominciamo impostando i componenti prerequisiti:

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); } }

Lo usiamo come base per aggiungere l' interfaccia Cloneable alla classe Integer stock e aggiungiamo anche un campo e un metodo.

4.1. Lavorare con i campi

Creiamo il nostro ClassVisitor che useremo per aggiungere un campo alla classe 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; } } 

Successivamente, sovrascriviamo il metodo visitField , dove prima controlliamo se il campo che intendiamo aggiungere esiste già e impostiamo un flag per indicare lo stato .

Dobbiamo ancora inoltrare la chiamata al metodo alla classe genitore - questo deve accadere poiché il metodo visitField viene chiamato per ogni campo della classe. L'impossibilità di inoltrare la chiamata significa che nessun campo verrà scritto nella classe.

Questo metodo ci permette anche di modificare la visibilità o il tipo di campi esistenti :

@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); } 

Per prima cosa controlliamo il flag impostato nel metodo visitField precedente e chiamiamo di nuovo il metodo visitField , questa volta fornendo il nome, il modificatore di accesso e la descrizione. Questo metodo restituisce un'istanza di FieldVisitor.

Il metodo visitEnd è l'ultimo metodo chiamato in base ai metodi dei visitatori. Questa è la posizione consigliata per eseguire la logica di inserimento campo .

Quindi, dobbiamo chiamare il metodo visitEnd su questo oggetto per segnalare che abbiamo finito di visitare questo campo:

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

È importante essere sicuri che tutti i componenti ASM utilizzati provengano dal pacchetto org.objectweb.asm : molte librerie utilizzano internamente la libreria ASM e gli IDE potrebbero inserire automaticamente le librerie ASM in bundle.

Ora usiamo il nostro adattatore nel metodo addField , ottenendo una versione trasformata di java.lang.Integer con il nostro campo aggiunto:

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(); } }

Abbiamo sovrascritto i metodi visitField e visitEnd .

Tutto quello che si deve fare riguardo ai campi avviene con il metodo visitField . Ciò significa che possiamo anche modificare i campi esistenti (ad esempio, trasformare un campo privato in pubblico) cambiando i valori desiderati passati al metodo visitField .

4.2. Lavorare con i metodi

La generazione di interi metodi nell'API ASM è più complessa di altre operazioni nella classe. Ciò comporta una quantità significativa di manipolazione del codice byte di basso livello e, di conseguenza, esula dall'ambito di questo articolo.

Per gli usi più pratici, tuttavia, possiamo modificare un metodo esistente per renderlo più accessibile (magari renderlo pubblico in modo che possa essere sovrascritto o sovraccaricato) o modificare una classe per renderla estensibile .

Rendiamo pubblico il metodo 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); } } 

Come abbiamo fatto per la modifica del campo, ci limitiamo a intercettare il metodo di visita e modificare i parametri desiderati .

In this case, we use the access modifiers in the org.objectweb.asm.Opcodes package to change the visibility of the method. We then plug in our ClassVisitor:

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

4.3. Working With Classes

Along the same lines as modifying methods, we modify classes by intercepting the appropriate visitor method. In this case, we intercept visit, which is the very first method in the visitor hierarchy:

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); } } 

We override the visit method to add the Cloneable interface to the array of interfaces to be supported by the Integer class. We plug this in just like all the other uses of our adapters.

5. Using the Modified Class

So we've modified the Integer class. Now we need to be able to load and use the modified version of the class.

In addition to simply writing the output of writer.toByteArray to disk as a class file, there are some other ways to interact with our customized Integer class.

5.1. Using the TraceClassVisitor

The ASM library provides the TraceClassVisitor utility class that we'll use to introspect the modified class. Thus we can confirm that our changes have happened.

Because the TraceClassVisitor is a ClassVisitor, we can use it as a drop-in replacement for a standard 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()); } 

What we have done here is to adapt the ClassVisitor that we passed to our earlier PublicizeMethodAdapter with the TraceClassVisitor.

All the visiting will now be done with our tracer, which then can print out the content of the transformed class, showing any modifications we've made to it.

While the ASM documentation states that the TraceClassVisitor can print out to the PrintWriter that's supplied to the constructor, this doesn't appear to work properly in the latest version of ASM.

Fortunately, we have access to the underlying printer in the class and were able to manually print out the tracer's text contents in our overridden visitEnd method.

5.2. Using Java Instrumentation

This is a more elegant solution that allows us to work with the JVM at a closer level via Instrumentation.

To instrument the java.lang.Integer class, we write an agent that will be configured as a command line parameter with the JVM. The agent requires two components:

  • A class that implements a method named premain
  • An implementation of ClassFileTransformer in which we'll conditionally supply the modified version of our class
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; } }); } }

We now define our premain implementation class in a JAR manifest file using the Maven jar plugin:

 org.apache.maven.plugins maven-jar-plugin 2.4     com.baeldung.examples.asm.instrumentation.Premain   true     

Building and packaging our code so far produces the jar that we can load as an agent. To use our customized Integer class in a hypothetical “YourClass.class“:

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

6. Conclusion

While we implemented our transformations here individually, ASM allows us to chain multiple adapters together to achieve complex transformations of classes.

In addition to the basic transformations we examined here, ASM also supports interactions with annotations, generics, and inner classes.

We've seen some of the power of the ASM library — it removes a lot of limitations we might encounter with third-party libraries and even standard JDK classes.

ASM is widely used under the hood of some of the most popular libraries (Spring, AspectJ, JDK, etc.) to perform a lot of “magic” on the fly.

Puoi trovare il codice sorgente di questo articolo nel progetto GitHub.