Publié par

Il y a 8 ans -

Temps de lecture 5 minutes

Legacy code – gestion des exceptions avec Java Instrumentation

Dans un récent billet, je vous ai présenté JPDA afin de résoudre le problème d’envoi de mail à l’interception des exceptions levées dans une application legacy. Dans cette deuxième partie de la série, je vous propose de résoudre le même problème avec l’API Java Instrumentation.

Java Instrumentation

L’API instrumentation a vu le jour avec Java 5. Elle décrit le mécanisme de manipulation du byte-code utilisé par les outils d’analyse, afin de collecter des données relatives à l’exécution des programmes. Avant la version 5 de Java, les programmes utilisaient d’autres techniques pour analyser les programmes pendant leur exécution, notamment JPDA (présentée dans le précédent article). L’instrumentation est réalisée avec les « agents » Java. Un agent est un programme Java qui se déploie sous forme d’un jar. Il est lancé au démarrage de la JVM en ajoutant l’option « -javaagent:cheminAgent=options » à la ligne de commande (on remarquera au passage la similarité avec le lancement de JPDA). « cheminAgent » est le chemin pointant vers le jar de l’agent et « options » la liste des arguments sous forme d’une seule chaine de caractères.
Pour qu’il soit valide, l’agent doit contenir:

  • Une classe qui implémente la méthode premain dont la signature doit être:
    public static void premain(String agentArgs, Instrumentation inst);
    

    ou

    public static void premain(String agentArgs);
    

    Cette méthode ressemble à la méthode main d’un programme Java. Elle sera appelée par la JVM au lancement de l’agent en lui fournissant une chaîne de caractères, à analyser par le programme, représentant les options de l’agent et un objet instrumentation. Typiquement, la méthode premain décompose les options et ajoute un ou plusieurs ClassFileTransformer à l’objet instrumentation.

  • Une classe qui implémente ClassFileTransformer : La méthode transform() de la classe sera appelée pour chaque chargement de classe. S’il y a plusieurs transformateurs, ils seront invoqués dans l’ordre dans lequel ils ont été ajoutés. La méthode reçoit parmi ses arguments le byte-code original de la classe, et elle retourne le byte-code après modification s’il y en a eu.
  • Un fichier META-INF/MANIFEST.MF : ce fichier doit contenir un attribut Premain-Class désignant le nom complet de la classe qui implémente la méthode premain, et optionnellement un attribut Boot-Class-Path qui référencera les librairies externes. Pour les autres options, le lecteur pourra se référer à la spécification.

ExceptionHandlerAgent

Pour résoudre le problème des exceptions Java levées par l’application, nous allons créer notre propre agent ExceptionHandlerAgent. Celui-ci modifiera le byte-code des classes afin de rajouter le traitement des exceptions. Il implémente donc l’interface ClassFileTransformer. La manipulation du byte-code Java n’est pas chose facile. Elle nécessite une connaissance approfondie du format du byte-code. Heureusement, il existe beaucoup de frameworks (ASM, BCEL, Javassist…) qui rendent cette tâche plus facile. Dans notre exemple, nous allons utiliser la librairie Javassist. Elle nous offre un ensemble de méthodes haut-niveau pour la manipulation du byte-code ainsi qu’un micro-compilateur intégré pour la compilation des petits bouts de code Java. Nous présenterons cette librairie en détail dans un prochain article.

La méthode premain se contente d’ajouter une nouvelle instance d’ExceptionHandlerAgent

public class ExceptionHandlerAgent implements ClassFileTransformer {
    public static void premain(String agentArgument, Instrumentation instrumentation) {
        instrumentation.addTransformer(new ExceptionHandlerAgent());
    }
...
}

La manipulation du byte-code est effectuée dans la méthode transform(), qui est appelée à chaque chargement de classe. Instrumenter les classes de la JVM ne nous intéresse pas et peut même être dangereux. Il en est de même pour les classes des librairies utilisées par l’application. Nous nous intéressons donc uniquement au classes du package fr.xebia.

public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
            ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        if (className.startsWith("fr/xebia")) {
            ClassPool pool = ClassPool.getDefault();
            CtClass cl = null;
            try {
                cl = pool.makeClass(new java.io.ByteArrayInputStream(classfileBuffer));
                if (!cl.isInterface()) {
                    CtBehavior[] methods = cl.getDeclaredBehaviors();
                    for (int i = 0; i < methods.length; i++) {
                        if (!methods[i].isEmpty()) {
                            enrichMethod(cl, methods[i]);
                        }
                    }
                    classfileBuffer = cl.toBytecode();
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                if (cl != null) {
                    cl.detach();
                }
            }
        }
        return classfileBuffer;
    }

Notre but est de traiter les exceptions « catchées » et non « catchées ». Pour les exceptions non catchées, nous pouvons utiliser Thread.setDefaultUncaughtExceptionHandler(eh), qu’il suffit d’insérer au début de la méthode main. Javaassist ne permet pas de compiler les classes internes et anonymes, nous allons faire en sorte que la classe contenant la méthode main implémente l’interface UncaughtExceptionHandler. Il suffit alors de passer une instance de cette classe à la méthode statique setDefaultUncaughtExceptionHandler(). Pour les autres exceptions, nous pouvons modifier les clauses catch afin d’insérer le traitement d’exception au début de la clause. C’est ce qui est fait par la méthode enrichMethod.

private void enrichMethod(CtClass cl, CtBehavior ctBehavior) throws CannotCompileException, NotFoundException {
        if (ctBehavior.getName().equals("main")) {
            CtClass eh = ClassPool.getDefault().makeClass("java.lang.Thread$UncaughtExceptionHandler");
            cl.addInterface(eh);
            CtMethod m = CtNewMethod.make("public void uncaughtException(Thread t, Throwable e){System.out.println(\"An Uncaughed exception is thrown\");e.printStackTrace();}",cl);
            cl.addMethod(m);
            ctBehavior.insertBefore("Thread.setDefaultUncaughtExceptionHandler( new " + cl.getName() + "());");
        }
        ctBehavior.instrument(new ExprEditor() {
            @Override
            public void edit(Handler h) throws CannotCompileException {
                h.insertBefore("System.out.println(\"Caugh an Exception\");");
            }
        });
    }

Conclusion

Contrairement à la solution avec JPDA, cette solution n’ajoute pas d’overhead à l’exécution de l’application. Cependant, le pré-requis de bien comprendre un minimum le format byte-code avant de pouvoir en profiter reste un handicap pour la démocratisation de cette API, qui reste réservée aux développeurs de frameworks. La simplicité de l’API et la complexité des librairies de manipulation de code font de cette solution une arme à double tranchant.

Publié par

Commentaire

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Xebian, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.