Java agent & Javassist pour monitorer du code
Un agent java, qu’est ce que c’est ?
Un agent java est un élément permettant d’instrumenter du code java. Il est lancé au chargement de la JVM, grâce à l’option : -javaagent:/chemin/vers/un/jar.
Le jar d’un agent doit contenir au moins une classe déclarant une méthode dont la signature est :
public static void premain(String agentArgs, Instrumentation inst)
Il doit également contenir un Manifest indiquant l’emplacement de cette classe, grâce à l’instruction « Premain-Class ». Par exemple :Premain-Class: fr.mael.examples.agent.MyAgent
Au chargement de la JVM, c’est à dire avant que l’application ne s’exécute, la méthode premain de l’agent est appelée. L’objet Instrumentation passé en paramètre de la méthode premain présente plusieurs fonctions, dont une qui va nous intéresser ici : la méthode ayant la signature void addTransformer(ClassFileTransformer transformer)
. Cette méthode accepte en paramètre un objet implémentant l’interface ClassFileTransformer. Cette interface ne déclare qu’une méthode : byte[] transform(ClassLoader loader, String className, Class> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer)
. Cette méthode est très intéressante. Elle est appelée une fois pour chaque classe chargée par le (ou les) classloader(s) de la JVM. Elle permet de modifier à la volée le bytecode de n’importe quelle classe avant que celle-ci ne soit mise à disposition de l’application. Le bytecode original de la classe est passé en paramètre: classfileBuffer, et la fonction renvoie le bytecode modifié (ou pas).
Pourquoi Javassist ?
Grâce à des librairies manipulant du bytecode, on peut donc faire à peu près ce qu’on veut : ajouter des méthodes à des interfaces, à des classes, ajouter des paramètres à des méthodes existantes, modifier les corps de méthodes, etc. bref, on a quasiment autant de possibilité que si on avait accès au code source des classes.
Il existe plusieurs librairies manipulant du bytecode. Parmi les plus connues, on trouve par exemple ASM et Javassist. La première est plus « low level », et requiert visiblement de bonnes connaissances en bytecode (ce qui n’est pas mon cas), et la seconde est plus « high level », permettant de s’affranchir de manipulation directe de bytecode.
Comme mes connaissances en bytecode sont actuellement limitées (c’est dans ma « TO LEARN » liste), j’ai choisi d’utiliser Javassist.
Pour quoi faire ?
Avec un peu d’imagination, on peut penser à beaucoup d’utilisations des agents java : logging dans du code pour lequel on n’a pas les sources, gestion des exceptions, monitoring etc. Attention toutefois à ne pas faire n’importe quoi, la manipulation du bytecode n’est pas une chose anodine, et peut introduire des bugs difficiles à corriger, ou encore avoir un impact sur les performances. Il faut donc l’utiliser avec parcimonie.
Ici, l’exercice va consister à intercepter toutes les requêtes sur une base de données effectuées via un PreparedStatement, pour logger la requête, son temps d’exécution et la stacktrace pour savoir le code qui a mené à l’exécution de la requête. Je ne garantis pas du tout les performances, il s’agit avant tout de découvrir l’utilisation des agents
Agent de base
Avoir un projet de base pour développer un agent est très simple, surtout avec Maven. Tout ce dont on a besoin, c’est d’une classe déclarant la méthode premain :
package fr.mael.examples.javaagent; import java.lang.instrument.Instrumentation; public class MyAgent { public static void premain(String agentArgs, Instrumentation inst) { } }
Et d’une instruction dans le pom permettant de construire le fichier Manifest:
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <configuration> <archive> <manifestEntries> <Premain-Class>fr.mael.examples.javaagent.MyAgent</Premain-Class> </manifestEntries> </archive> </configuration> </plugin> </plugins> </build>
Voilà. On a tout ce qu’il faut : au build, maven construira un jar avec le Manifest qui va bien, permettant d’utiliser l’agent.
Conception du logger
Reprenons donc les spécifications que je me suis fixées :
- On doit logger le temps d’exécution de la requête d’un PreparedStatement. C’est assez simple à réaliser : il faudra qu’on injecte un morceau de bytecode au début et un autre à la fin de la méthode executeQuery. Le soucis, c’est que PreparedStatement est une interface, on ne peut pas injecter du bytecode dans le corps des méthodes d’une interface, puisqu’elles n’ont pas de corps. Ce qu’on devra faire, c’est donc instrumenter toutes les classes chargées qui implémentent PreparedStatement, et qui ne sont pas elles-mêmes des interfaces
- On doit logger la stack trace courante. On peut le faire à n’importe quel moment dans le code, en utilisant
new Throwable().getStackTrace()
. Donc pas de problème pour ce point (sauf peut-être au niveau des performances, mais comme je l’ai indiqué, ce n’est pas l’objet de ce billet) - On doit afficher la requête SQL exécutée. Là, ça se complique parce que, à ma connaissance, les spécifications de java ne définissent aucune méthode permettant d’avoir accès à la requête SQL depuis un PreparedStatement. On doit donc récupérer la requête en amont, par exemple au moment où la méthode prepareStatement est appelée, et ensuite passer la requête au statement pour pouvoir la récupérer ensuite. Mais comment faire ça ? Une manière de le faire est de renommer la méthode prepareStatement des classes implémentant Connection. On peut ensuite recréer une méthode prepareStatement qui appelle la méthode renommée, et fait le nécessaire pour passer la requête au PreparedStatement. C’est à dire qu’on transformerait ceci :
public PreparedStatement prepareStatement(String sql) { //code qu'on ne connait pas }
en cela :
/** * Méthode originale renommée **/ public PreparedStatement __prepareStatement(String sql) { //code qu'on ne connait pas } /** * Notre nouvelle méthode, qui respecte le contract imposé par java.sql.Connection **/ public PreparedStatement prepareStatement(String sql) { //tout d'abord, du code permettant de sauvegarder la requête SQL quelque part //ensuite, on appelle la méthode originale renommée : return __prepareStatement(sql); }
J’ai montré ici du code java. Sans utiliser Javassist, il faudrait transformer cette logique en bytecode. Javassist permet au contraire de manipuler le bytecode en manipulant des chaînes de caractères proches de ce que j’ai indiqué ci-dessus, ce qui le rend très intéressant, à mon avis.
Mise en place
On va d’abord créer un objet que j’ai nommé « SQLProbe », qui permet de stocker une chaîne de caractères correspondant à la requête SQL, et qui permet également de lancer et d’arrêter un timer, pour mesurer le temps d’exécution de le requête SQL. Voici cette classe :
package fr.mael.examples.javaagent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class SQLProbe { private static Logger LOG = LoggerFactory.getLogger(SQLProbe.class); private String sql; private Long startTime; public SQLProbe(String sql) { this.sql = sql; } public void start() { startTime = System.currentTimeMillis(); } public void stop() { LOG.info(String.format("\\"%s\\" query took %d ms to execute. Stacktrace:", sql, (System.currentTimeMillis() - startTime)), new Throwable()); } public String getSql() { return sql; } public void setSql(String sql) { this.sql = sql; } }
On crée ensuite notre classe héritant de ClassFileTransformer :
public class JDBCClassTransformer implements ClassFileTransformer { @Override public byte[] transform(ClassLoader loader, String className, Class classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { byte[] classfileBufferToReturn = classfileBuffer; //le ClassPool contient l'ensemble des classes du classpath ClassPool pool = ClassPool.getDefault(); //cet objet sera la classe passée en paramètre CtClass currentClass = null; //les classes de connexion et de statement de java.sql CtClass connection = null, statement = null; try { //on crée une classe javassist à partir des bytes passés en paramètre currentClass = pool.makeClass(new java.io.ByteArrayInputStream(classfileBuffer)); //on récupère les 2 interfaces qui nous intéresse connection = pool.get("java.sql.Connection"); statement = pool.get("java.sql.PreparedStatement"); //on teste si la classe actuelle hérite d'une des deux interfaces. Si c'est //le cas, on l'instrumente if (currentClass.subtypeOf(connection) && !currentClass.isInterface()) { probeConnection(currentClass); } else if (currentClass.subtypeOf(statement) && !currentClass.isInterface()) { probeStatement(currentClass); } //on récupère le bytecode modifié (ou pas) de la classe classfileBufferToReturn = currentClass.toBytecode(); } catch (Exception e) { e.printStackTrace(); } finally { if (currentClass != null) { currentClass.detach(); } } return classfileBufferToReturn; } private void probeStatement(CtClass currentClass) throws NotFoundException, CannotCompileException { CtMethod executeQuery = null; try { //ici on utilise getDeclaredMethod pour n'instrumentaliser que les classes qui déclarent effectivement //cette méthode (et pas les classes qui héritent d'une classe qui déclare cette méthode) //c'est un peu moche mais je n'ai pas trouvé mieux... executeQuery = currentClass.getDeclaredMethod("executeQuery"); } catch (NotFoundException e) { return; } //on crée un champ de type SQLProbe qu'on injecte comme propriété dans le Statement CtField field = CtField.make("private fr.mael.examples.javaagent.SQLProbe $$probe;", currentClass); currentClass.addField(field); //on crée un setter un setter pour ce champ CtMethod setter = CtNewMethod.make("public void set$$probe(fr.mael.examples.javaagent.SQLProbe probe){ this.$$probe = probe;}", currentClass); currentClass.addMethod(setter); //on démarre la sonde au début de l'exécution de la méthode executeQuery() executeQuery.insertBefore("this.$$probe.start();"); //on stopppe la sonde à la fin de l'exécution de la méthode executeQuery() executeQuery.insertAfter("this.$$probe.stop();"); } private void probeConnection(CtClass currentClass) throws NotFoundException, CannotCompileException { //on récupère la méthode prepareStatement de base CtMethod old = currentClass.getMethod("prepareStatement", "(Ljava/lang/String;)Ljava/sql/PreparedStatement;"); //on crée une nouvelle méthode prepareStatement à partir de l'ancienne CtMethod newC = CtNewMethod.copy(old, "prepareStatement", currentClass, null); //on attribute un nouveau nom à la méthode prepareStatement de base String newName = "__prepareStatement"; old.setName(newName); //on crée le corps de notre nouvelle méthode //cette méthode doit créer une probe, puis appeler la méthode prepareStatement de base, et //attribuer la probe au Statement récupéré à partir de cette appel. StringBuffer body = new StringBuffer(); body.append("{"); body.append(" fr.mael.examples.javaagent.SQLProbe probe = new fr.mael.examples.javaagent.SQLProbe($1);"); body.append(" java.sql.PreparedStatement statement = ").append(newName).append("($$);"); body.append(" fr.mael.examples.javaagent.Util.setProbe(statement, probe);"); body.append(" return statement;"); body.append("}"); //on attribue son corps à notre nouvelle méthode newC.setBody(body.toString()); //on ajoute notre nouvelle méthode currentClass.addMethod(newC); } }
Toutes les explications sont dans le code, je ne pense pas qu’il y ait besoin de plus d’explications. On peut maintenant modifier notre agent pour ajouter ce ClassFileTransformer :
package fr.mael.examples.javaagent; import java.lang.instrument.Instrumentation; public class MyAgent { public static void premain(String agentArgs, Instrumentation inst) { inst.addTransformer(new JDBCClassTransformer()); } }
Et c’est tout. Plus qu’à exécuter un mvn package
pour créer le jar de l’agent. Et ensuite, il suffit de créer un petit bout de code qui fait une requête en base. Un truc de ce genre :
public static void main(String[] args) { try { Class.forName("com.mysql.jdbc.Driver"); String url = "jdbc:mysql://localhost:3306/une_base"; String user = "root"; String passwd = "root"; Connection conn = DriverManager.getConnection(url, user, passwd); PreparedStatement preparedStatement = conn.prepareStatement("select * from ma_grosse_table"); preparedStatement.executeQuery(); } catch (Exception e) { e.printStackTrace(); } }
exécuter ce main avec l’option JVM :
-javaagent:/chemin/vers/le/jar/de/mon/agent.jar
Et on devrait obtenir une sortie de ce genre :
18:42:21.094 [main] INFO fr.mael.examples.javaagent.SQLProbe - "select * from ma_grosse_table" query took 20075 ms to execute. Stacktrace: java.lang.Throwable: null at fr.mael.examples.javaagent.SQLProbe.stop(SQLProbe.java:23) [java-agent-0.0.1-SNAPSHOT.jar:na] at com.mysql.jdbc.PreparedStatement.executeQuery(PreparedStatement.java:2335) [mysql-connector-java-5.1.29.jar:na] at fr.mael.examples.agentsql.App.main(App.java:22) [classes/:na]
On a toutes les infos : la requête exécutée, son temps d’exécution, et la stacktrace.
Conclusion
Comme je l’ai indiqué plus tôt dans l’article, le code que je donne risque sans doute d’avoir un impact non négligeable sur les performances. Il ne s’agit pas ici d’avoir un code prêt à être utilisé en production, mais un code permettant de découvrir l’instrumentation de bytecode grâce à un agent.
Ces techniques offrent des possibilités infinies d’instrumentation d’une application existante sans en modifier une seule ligne de code. Pour ceux qui connaissent, on peut penser à AppDynamics qui pousse ce concept assez loin, en proposant un monitoring d’applications java assez poussé (je n’ai aucun lien de quelque sorte que ce soit avec AppDynamics, c’est juste que je connais un peu leur application).
Fichiers
Le projet maven de l’agent : http://blog.le-guevel.com/wp-content/uploads/2014/02/java-agent.tar.gz