Sous le capot de Play! Framework

Play! Framework est un petit « nouveau » dans l’écosystème fourni des framework web Java et il a fait beaucoup parler de lui ces derniers temps. Pourquoi tant de bruit (bon ou mauvais d’ailleurs) me direz-vous ? Je n’entrerais pas dans ce débat et cet article n’est pas là pour faire l’éloge ou non de Play!. D’un point de vue personnel, je dirais juste que Play! m’a redonné envie de faire du développement web en java. Après c’est une question de goût.

Deux des fonctionnalités de Play! que j’apprécie particulièrement sont : le rechargement à chaud du code Java et le bytecode enhancement. Je vous propose de voir comment, sous le capot, Play! met en œuvre ces 2 fonctionnalités.

Introduction

Je vais passer très vite sur la présentation de Play!. Nombreux sont ceux qui l’ont déjà fait et mieux que je ne pourrais le faire. Si c’est la première fois que vous entendez le mot Play! (dans un article en français traitant de Java), je vous invite à vous rendre sur les liens suivants :

Le rechargement à chaud

Avec Play!, en mode développement, on démarre notre serveur, on fait joujou avec l’application, on s’aperçoit qu’il y a un bug, on ouvre son IDE favori, on corrige le bug, on recompile, on redéploie et …. et NON NON NON ! On ne recompile pas et on ne redéploie pas ! On revient juste sur son navigateur, on rafraichit la page et hop magie, le bug a disparu (enfin peut-être) ! C’est juste simplement magique mais en informatique (comme dans la vraie vie en fait), la magie ça n’existe pas. Alors comment Play! s’y prend-t-il ? Sous le capot, Play! utilise le JDT Compiler d’Eclipse, celui là même utilisé par l’IDE du même nom.

Déjà, au lancement de l’application, Play! va :

  • scanner le répertoire de l’application à la recherche des fichiers sources .java
  • compiler ces fichiers à l’aide du JDT Compiler d’Eclipse
  • charger les classes en mémoire

Ensuite, à chaque requête (et en mode développement), Play! essaye de détecter tout changement du code source.

En ce qui concerne des modifications apportées à des classes existantes, Play! conserve dans une HashMap l’ensemble des classes qu’il a compilées ainsi qu’un timestamp de la dernière compilation pour chacune des classes. Il suffit alors de comparer ce timestamp à la date de dernière modification du fichier correspondant pour savoir si Play! doit recompiler la classe et la recharger. Un petit coup de java.lang.Instrumentation.redefineClasses(classesDefinitions) est alors nécessaire pour redéfinir les classes modifiées. Cette redéfinition de classe possède quelques limites :

  • les instances existantes des classes redéfinies ne sont pas affectées
  • la redéfinition ne doit pas ajouter, supprimer ou renommer des attributs ou des méthodes, changer la signature des méthodes ou modifier l’héritage. Si c’est le cas, alors Play! va stopper et recharger l’application dans son intégralité.

Maintenant, que fait Play! si l’on a ajouté ou supprimé des classes ? A la compilation des fichiers sources, Play! calcule un hash de la concaténation de toutes les classes compilées. Ainsi, il suffit de recalculer ce hash, de le comparer au précédent afin de savoir si un différentiel de classes existe. Si c’est le cas, alors encore une fois, Play! redémarre l’application dans son intégralité.

Le bytecode enhancement

Quoi ? Play! fait du bytecode enhancement ? Mais où ? Pourquoi ?… au fait c’est quoi le bytecode enhancement ? Tentons de répondre à ces questions.

Pour commencer, où est-ce que Play! fait du bytecode enhancement ? Dans les classes de modèles par exemple. Dans Play!, on peut utiliser Hibernate (à travers JPA) pour persister nos objets Java dans une base de données. Il suffit d’annoter notre classe avec @javax.persistence.Entity. Ensuite Play! fournit la classe Model qu’on peut étendre pour avoir un support out of the box de JPA :

@Entity public class Person extends Model { public String name; public Integer age; }

L’arbre d’héritage de Person est : Person > Model > GenericModel > JPABase. Grâce à cet héritage on a accès à des méthodes utilitaires du type find(), findAll(), findById(), count() :

Person.findAll() //va retourner toutes les entités de type Person

Mais si on regarde bien l’implémentation de ces méthodes qui se trouvent dans la classe GenericModel, voici ce qu’on observe pour la méthode findAll() :

public static <T extends JPABase> List<T> findAll() { throw new UnsupportedOperationException("Please annotate your JPA model with @javax.persistence.Entity annotation."); }

Pourtant lorsqu’on utilise la méthode statique findAll(), j’ai bien le résultat attendu et non pas une exception qui m’est remontée. C’est là qu’intervient l’enhancement de bytecode. Dans Play!, ce sont les rôles des classes de type play.classloading.enhancers.Enhancer que d’enhancer le bytecode.

Du coup répondons à la 2ème question, qu’est ce que l’enhancement de bytecode ? C’est tout simplement le fait de manipuler le bytecode, d’avoir la possibilité de définir une nouvelle classe au runtime et de modifier une classe lorsque la JVM la charge en mémoire. Plusieurs frameworks existent pour nous faciliter cette tache : cglib, ASM ou encore Javaassist. Et c’est ce dernier qu’utilise Play!.
Revenons à notre exemple et prenons la classe play.db.jpa.JPAEnhancer qui va créer les méthodes des entités JPA de notre modèle.

public class JPAEnhancer extends Enhancer { public void enhanceThisClass(ApplicationClass applicationClass) throws Exception { CtClass ctClass = makeClass(applicationClass) if (!ctClass.subtypeOf(classPool.get("play.db.jpa.JPABase"))) { return; } // Enhance only JPA entities if (!hasAnnotation(ctClass, "javax.persistence.Entity")) { return; } [...] // findAll CtMethod findAll = CtMethod.make("public static java.util.List findAll() { return getJPAConfig("+entityName+".class).jpql.findAll("" + entityName + ""); }", ctClass); ctClass.addMethod(findAll); [...] } }

  • Ligne 1 à 2 :les classes qui étendent Enhancer doivent implémenter la méthode enhanceThisClass(). ApplicationClass est une classe de Play! et représente tout simplement une classe. Ses attributs notables sont par exemple javaFile (une référence au fichier source java), javaByteCode (le bytecode compilé), enhancedBytecode (le bytecode manipulé).

  • Ligne 3 : on instancie une javaassist.CtClass. CtClass est une classe du framework Javaassist et représente une classe.

  • Ligne 4 à 6 : on vérifie que cette classe hérite de Play.db.jpa.JPABase. Sinon on ne fait rien

  • Ligne 9 à 11 : on vérifie que cette classe est annotée avec @javax.persistence.Entity. Sinon on ne fait rien

  • Linge 16 à 17 : on crée la méthode findAll() grâce à javaassist.CtMethod.make(). Notez que cette méthode prend en paramètre une String représentant du code Java (pas besoin de connaître le bytecode)

La classe JPAEnhancer va donc boucler sur toutes les entités et rajouter toutes ces méthodes au runtime. D’autres Enhancer existent dans Play! tels que PropertiesEnhancer et ControllersEnhancer. N’hésitez pas à y jeter un coup d’oeil, le code se trouve sur Github : https://github.com/Playframework/Play

Conclusion

Nous avons ouvert le capot de Play! pour voir ce qu’il s’y cachait. L’utilisation du JDT Compiler d’Eclipse permet de gérer la compilation à la volée et le rechargement à chaud de l’application. Néanmoins cette fonctionnalité a ses limites : dès qu’on ajoute/supprime des classes ou qu’on modifie « trop » (changement de signature, ajout de champ, d’annotation, …) une classe, toute l’application est rechargée ce qui peut être pénalisant si l’application prend du temps à démarrer.
L’enhancement de bytecode, qui permet d’enrichir nos classes au runtime, fait appel à Javassist et donne encore plus à Play! ce côté « magique ». Par contre jouer avec le bytecode, c’est sympa mais après à débugger c’est tout de suite plus compliqué. Pour cela, vous pouvez utiliser JD-GUI pour désassembler les .class précompilés par Play!.
Plein d’autres choses se cachent sous le capot de Play!, n’hésitez pas à l’ouvrir et à laisser un commentaire si vous découvrez quelque chose d’intéressant.