Un guide sur la manipulation de bytecode Java avec ASM

1. Introduction

Dans cet article, nous verrons comment utiliser la bibliothèque http://asm.ow2.org/ pour manipuler une classe Java existante en ajoutant des champs, des méthodes et en modifiant le comportement des méthodes existantes.

2. Dépendances

Nous devons ajouter les dépendances ASM à notre 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>

Nous pouvons obtenir les dernières versions de asm et asm-util de Maven Central.

3. Notions de base sur l’API ASM

L’API ASM fournit deux styles d’interaction avec les classes Java pour la transformation et la génération: basé sur des événements et basé sur des arbres.

3.1. API basée sur des événements

Cette API est largement basée sur le modèle Visitor et a une apparence similaire au modèle d’analyse ** SAX du traitement de documents XML. Il est composé essentiellement des composants suivants:

  • ClassReader - aide à lire les fichiers de classe et marque le début de

transformer une classe ** ClassVisitor - fournit les méthodes utilisées pour transformer la classe

après avoir lu les fichiers de classe bruts ** ClassWriter - est utilisé pour générer le produit final de la classe

transformation

C’est dans ClassVisitor que nous avons toutes les méthodes de visiteur que nous utiliserons pour toucher les différents composants (champs, méthodes, etc.) d’une classe Java donnée. Nous faisons cela en fournissant une sous-classe de ClassVisitor pour implémenter les modifications éventuelles dans une classe donnée.

En raison de la nécessité de préserver l’intégrité de la classe de sortie en ce qui concerne les conventions Java et le bytecode résultant, cette classe nécessite un ordre strict dans lequel ses méthodes doivent être appelées pour générer une sortie correcte.

Les méthodes ClassVisitor de l’API basée sur les événements sont appelées dans l’ordre suivant:

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

3.2. API basée sur des arbres

Cette API est une API plus orientée objet et est analogue au modèle JAXB de traitement de documents XML.

Il repose toujours sur l’API basée sur les événements, mais introduit la classe racine ClassNode . Cette classe sert de point d’entrée dans la structure de classe.

4. Utilisation de l’API ASM basée sur les événements

Nous allons modifier la classe java.lang.Integer avec ASM. Et nous devons saisir un concept fondamental à ce stade: la classe ClassVisitor contient toutes les méthodes de visiteur nécessaires pour créer ou modifier toutes les parties d’une classe .

Nous avons seulement besoin de remplacer la méthode de visiteur nécessaire pour implémenter nos changements. Commençons par configurer les composants prérequis:

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

Nous l’utilisons comme base pour ajouter l’interface Cloneable à la classe stock Integer , et nous ajoutons également un champ et une méthode.

4.1. Travailler avec des champs

Créons notre ClassVisitor que nous utiliserons pour ajouter un champ à la 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;
    }
}

Ensuite, remplaçons la méthode visitField , où nous vérifions d’abord si le champ que nous prévoyons d’ajouter existe déjà et définissons un indicateur pour indiquer le statut ** .

Nous devons toujours transmettre l’appel de méthode à la classe parente - ceci doit se produire car la méthode visitField est appelée pour chaque champ de la classe. Le fait de ne pas transférer l’appel signifie qu’aucun champ ne sera écrit dans la classe.

Cette méthode nous permet également de modifier la visibilité ou le type de champs existants :

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

Nous vérifions d’abord le drapeau défini dans la méthode visitField précédente et appelons à nouveau la méthode visitField , en fournissant le nom, le modificateur d’accès et la description. Cette méthode retourne une instance de FieldVisitor.

  • La méthode visitEnd est la dernière méthode appelée dans l’ordre des méthodes du visiteur. C’est la position recommandée pour exécuter la logique d’insertion de champ ** .

Ensuite, nous devons appeler la méthode visitEnd sur cet objet pour signaler que nous avons fini de visiter ce champ:

@Override
public void visitEnd() {
    if (!isFieldPresent) {
        FieldVisitor fv = cv.visitField(
          access, fieldName, fieldType, null, null);
        if (fv != null) {
            fv.visitEnd();
        }
    }
    cv.visitEnd();
}
  • Il est important de s’assurer que tous les composants ASM utilisés proviennent du package org.objectweb.asm ** . De nombreuses bibliothèques utilisent la bibliothèque ASM en interne et les IDE peuvent insérer automatiquement les bibliothèques ASM fournies.

Nous utilisons maintenant notre adaptateur dans la méthode addField , en obtenant une version transformée de java.lang.Integer avec notre champ ajouté:

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

Nous avons remplacé les méthodes visitField et visitEnd .

Tout ce qui doit être fait concernant les champs se passe avec la méthode visitField . Cela signifie que nous pouvons également modifier les champs existants (par exemple, transformer un champ privé en public) en modifiant les valeurs souhaitées transmises à la méthode visitField .

4.2. Travailler avec des méthodes

La génération de méthodes entières dans l’API ASM est plus complexe que les autres opérations de la classe. Cela implique une quantité importante de manipulation de code octet de bas niveau et, par conséquent, sort du cadre de cet article.

Pour la plupart des utilisations pratiques, cependant, nous pouvons soit modifier une méthode existante pour la rendre plus accessible (peut-être la rendre publique afin qu’elle puisse être surchargée ou surchargée) ou modifier une classe pour la rendre extensible .

Rendons publique la méthode 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);
   }
}

Comme nous l’avons fait pour la modification du champ, nous interceptons simplement la méthode de visite et modifions les paramètres souhaités .

Dans ce cas, nous utilisons les modificateurs d’accès du package org.objectweb.asm.Opcodes pour modifier la visibilité de la méthode . Nous connectons ensuite notre ClassVisitor :

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

4.3. Travailler avec des classes

Dans le même esprit que les méthodes de modification, nous modifions les classes en interceptant la méthode de visiteur appropriée . Dans ce cas, nous interceptons visit , qui est la toute première méthode de la hiérarchie des visiteurs:

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

Nous substituons la méthode visit pour ajouter l’interface Cloneable au tableau d’interfaces à prendre en charge par la classe Integer . Nous le connectons comme toutes les autres utilisations de nos adaptateurs.

5. Utiliser la classe modifiée

Nous avons donc modifié la classe Integer . Nous devons maintenant pouvoir charger et utiliser la version modifiée de la classe.

En plus d’écrire simplement la sortie de writer.toByteArray sur le disque en tant que fichier de classe, il existe d’autres moyens d’interagir avec notre classe Integer personnalisée.

5.1. Utilisation de TraceClassVisitor

La bibliothèque ASM fournit la classe d’utilitaire TraceClassVisitor que nous utiliserons pour effectuer une introspection de la classe modifiée . Ainsi, nous pouvons confirmer que nos changements ont eu lieu .

Parce que TraceClassVisitor est un ClassVisitor , nous pouvons l’utiliser comme remplacement instantané pour un ClassVisitor standard:

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

Ce que nous avons fait ici est d’adapter le ClassVisitor que nous avons transmis à notre précédent PublicizeMethodAdapter avec le TraceClassVisitor .

Toutes les visites seront maintenant effectuées avec notre traceur, qui pourra ensuite imprimer le contenu de la classe transformée, en montrant les modifications que nous y avons apportées.

Bien que la documentation ASM indique que TraceClassVisitor peut imprimer sur le PrintWriter fourni au constructeur, cela ne semble pas fonctionner correctement dans la version la plus récente d’ASM.

Heureusement, nous avons accès à l’imprimante sous-jacente de la classe et avons pu imprimer manuellement le contenu textuel du traçage dans notre méthode visitEnd remplacée.

5.2. Utilisation de Java Instrumentation

Il s’agit d’une solution plus élégante qui nous permet de travailler avec la machine virtuelle à un niveau plus rapproché via Instrumentation. .

Pour instrumenter la classe java.lang.Integer , nous écrivons un agent qui sera configuré en tant que paramètre de ligne de commande avec la machine virtuelle Java . L’agent nécessite deux composants:

  • Une classe qui implémente une méthode nommée premain

  • Une implémentation de

ClassFileTransformer dans lequel nous fournirons de manière conditionnelle la version modifiée de notre classe.

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

Nous définissons maintenant notre classe d’implémentation premain dans un fichier manifeste JAR à l’aide du plug-in Maven jar:

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

Jusqu’à présent, la construction et l’emballage de notre code produisent le pot que nous pouvons charger en tant qu’agent. Pour utiliser notre classe Integer personnalisée dans une hypothétique " YourClass.class ":

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

6. Conclusion

Bien que nous ayons implémenté nos transformations ici individuellement, ASM nous permet d’enchaîner plusieurs adaptateurs pour réaliser des transformations complexes de classes.

En plus des transformations de base que nous avons examinées ici, ASM prend également en charge les interactions avec les annotations, les génériques et les classes internes.

Nous avons constaté une partie de la puissance de la bibliothèque ASM: elle supprime beaucoup de limitations que nous pourrions rencontrer avec les bibliothèques tierces et même les classes JDK standard.

ASM est largement utilisé sous le capot de certaines des bibliothèques les plus populaires (Spring, AspectJ, JDK, etc.) pour effectuer beaucoup de «magie» à la volée.

Vous trouverez le code source de cet article dans le projet GitHub