Il y a 4 ans -
Temps de lecture 15 minutes
Les frameworks et librairies Java under the hood
En tant que développeurs Java, nous utilisons de nombreux frameworks et librairies. Parmi les plus populaires, nous retrouvons Spring, Lombok, ainsi que beaucoup d’outils de test tels que JUnit ou Mockito.
Leur utilisation est simplifiée par le biais d’annotations et de fluent API. Cela les rend moins intrusifs dans le code et surtout nous fait économiser quelques lignes de développement. Mais ce qui est le plus marquant, ce sont les comportements qu’ils ajoutent à nos objets.
Afin de mieux comprendre leur fonctionnement, je vous propose d’en étudier les mécanismes sous-jacents en répondant aux questions suivantes :
- Quels sont les mécanismes proposés par Java ?
- Comment générer du code au runtime ?
- Comment générer du code en précompilation ?
L’introspection
Un des principes de la programmation orientée objet est l’encapsulation. Cela signifie que les attributs d’un objet font partie de sa représentation interne et sont donc cachés. Les frameworks ont parfois besoin d’explorer la structure interne de nos classes et donc de casser ce principe d’encapsulation. Java permet de faire de l’introspection, c’est-à-dire de s’introduire à l’intérieur de nos classes pour y consulter, appeler ou initialiser les méthodes et les attributs.
Parmi les évolutions majeures de la version 1.5 de Java, on retrouve les annotations. Elles permettent d’ajouter des méta-données aux classes, méthodes, attributs, paramètres et variables locales. La consultation des annotations se fait aussi par introspection.
Prenons l’exemple d’une classe ordinaire, avec un attribut annoté et non initialisé, et d’une méthode affichant cet attribut sur la console lorsqu’elle est appelée :
[java]public class MyClass {
@SetValue("myValue")
private String myField;
public void displayMyField() {
System.out.printf(myField);
}
} [/java]
Dans le code suivant, l’instance de la classe est créée par introspection. Il en est de même pour initialiser l’attribut myField
avec son annotation et pour l’exécution de la méthode displayMyField
.
[java]import java.lang.reflect.Field;
import java.lang.reflect.Method;
// Créer une instance de classe
Class<MyClass> clazz = MyClass.class;
MyClass myClass = clazz.newInstance();
// Récupère l’attribut myField et l’instancie avec la valeur fournie dans l’annotation @SetValue
Field myField = clazz.getDeclaredField("myField");
SetValue setValue = myField.getDeclaredAnnotation(SetValue.class);
myField.setAccessible(true);
myField.set(myClass, setValue.value());
myField.setAccessible(false);
// Récupère la méthode displayMyField et l’exécute
Method myMethod = clazz.getDeclaredMethod("displayMyField");
myMethod.invoke(myClass);[/java]
Le résultat obtenu suite à l’exécution de ce code est :
[bash]myValue[/bash]
C’est la classe java.lang.Class
de Java qui permet de faire de l’introspection. C’est une classe générique ayant pour paramètre de type la classe qui est introspectée. Pour la récupérer il existe 3 méthodes :
- Via une instance de la classe : Tous les objets Java héritent de la classe
Object
et donc de ses méthodes. La méthodegetClass()
permet de récupérer une instance deClass
sous la formeClass<? extends T>.
- Via le nom de la classe :
Class.forName()
permet de récupérer une classe à partir de son nom. En revanche, cette méthode retournera une instance deClass
avec un type anonyme :Class<?>
. Si la classe demandée n’existe pas, uneClassNotFoundException
est lancée. - Via la classe elle même : Il est possible de prendre n’importe quelle classe et de lui ajouter l’extension
.class
. Ceci aura pour but de créer une instance deClass
, ayant pour type la classe à introspecter.
Une fois l’instance de Class
récupérée, il est possible d’accéder par introspection à toutes les données de la classe, comme les méthodes, les attributs, les constructeurs et les annotations.
L’introspection est très utilisée dans les frameworks et les librairies. Lorsque nous utilisons un framework tel que Spring pour faire de l’IoC (Inversion of Control), l’injection de dépendances se fait via cette méthode. La lecture des annotations conservées au runtime l’utilise également. Autre exemple, JUnit utilise également l’introspection pour lancer les méthodes de test.
L’introspection permet de contourner la notion d’encapsulation des objets. En revanche, cette souplesse à un coût. En effet, accéder aux éléments d’un objet ralentit fortement l’exécution du code.
Le Proxy dynamique
Pour un framework ou une librairie, changer les valeurs des attributs, collecter les annotations, invoquer des méthodes ou instancier des classes par introspection est une première étape, mais ce n’est pas suffisant. Ils ont également besoin d’ajouter du comportement de manière dynamique, c’est-à-dire pendant l’exécution du code.
Le pattern proxy est une manière de répondre à cette problématique. Son but est de représenter les fonctionnalités d’une autre classe, en se positionnant entre la classe appelante et la classe appelée. Pour mettre en œuvre ce mécanisme, la classe proxy doit implémenter la même interface que la classe appelée, et garder une dépendance vers celle-ci. Ainsi, lorsque la classe appelante exécute une méthode de la classe appelée, c’est la classe proxy qui a le contrôle et choisit ou non d’acheminer l’appel vers la classe appelée.
Java possède une classe nommée Proxy
, qui permet de créer des proxys dynamiques. Pour en créer une instance, il suffit de fournir un ClassLoader
qui chargera le bytecode, une ou plusieurs interfaces qui définiront sa signature, et une classe de type InvocationHandler
qui interceptera tous les appels de méthodes.
Pour commencer, prenons une interface simple avec une méthode :
[java]public interface MyInterface {
String doSomething(int i, String s);
}[/java]
Le code suivant permet de créer une instance de l’interface ci-dessus, sans pour autant avoir codé une classe qui l’implémente. Toutes les interactions avec cette instance sont capturées par un InvocationHandler
, créé sous forme de lambda.
[java]import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.function.Function;
import static java.util.Arrays.stream;
import static java.util.stream.Collectors.joining;
//Récupère le class loader (les interfaces et la classe proxy doivent
//appartenir au même class loader)
ClassLoader classLoader = MyClass.class.getClassLoader();
//La liste des interfaces qui seront implémentées
//Ici seule l’interface MyInterface sera utilisée
Class[] interfaces = {MyInterface.class};
//Variable Fonction pour le parsing des paramètres
//Voir handler ci-dessous
Function<Object[], String> format_parameters = (parameters)
-> stream(parameters)
.map(o -> "(" + o.getClass().getName() + " = " + o + ")" )
.collect(joining(", "));
//Invoke Handler : Intercepte tous les appels de méthodes
//Retournera une chaine de caractères avec le nom de la méthode
//et la liste des paramètres avec leur type et leur valeur
InvocationHandler handler = (proxyObject, method, parameters)
-> "{ method name : " + method.getName()
+ ", parameter [" + parameters.length + "] : "
+ " { " + format_parameters.apply(parameters) + "}";
//Création de l’instance qui implémente MyInterface
MyInterface myInterfaceProxyfied = (MyInterface) Proxy.newProxyInstance(classLoader, interfaces, handler);
//Appel de la méthode doSomething
System.out.println("Result : " + myInterfaceProxyfied.doSomething(20, "hello"));[/java]
Le résultat obtenu suite à l’exécution de ce code est :
[bash]Result : { method name : doSomething, parameter [2] : { (java.lang.Integer = 20), (java.lang.String = hello)}[/bash]
Penchons-nous un peu plus sur l’implémentation du InvocationHandler
:
[java]import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
public class MyInterfaceHandler implements InvocationHandler {
private MyInterface myInterface;
public MyInterfaceHandler(MyInterface myInterface) {
this.myInterface = myInterface;
}
@Override
public Object invoke(Object o, Method method, Object[] objects) throws Exception {
System.out.println("Proxy class Before");
Object toReturn = method.invoke(myInterface, objects);
System.out.println("Proxy class after");
return toReturn;
}
}[/java]
InvocationHandler
possède juste une méthode : invoke
. Cette dernière possède 3 arguments :
Object
: qui fournit l’instance de l’objet proxy.Method
: La méthode qui a été invoquée.Object[]
: La liste des arguments qui a été passée à la méthode appelée.
Toutes les méthodes appelées sur une instance proxy passeront par cette méthode. Dans cet exemple, nous avons proxyfié une autre instance de MyInterface
. Nous pouvons voir qu’il est assez facile de rajouter du comportement autour de cette classe, appeler la méthode d’origine de celle-ci ou non. Le proxy dynamique a notamment été utilisé pour mettre au point les EJB. Mais cette méthode a depuis été remplacée par des techniques plus modernes, car l’obligation d’utiliser une interface reste très contraignante.
CGLIB
Le problème du Proxy dynamique présenté ci-dessus est que nous avons besoin de définir une interface. Les frameworks ont donc besoin d’une autre méthode pour pouvoir ajouter du comportement aux objets : l’héritage dynamique.
Le principe de substitution de Liskov nous met en garde sur l’héritage, en définissant la propriété suivante : si B
est un sous-type de A
, alors tout objet de A
peut être remplacé par un objet de type B
, sans altérer les propriétés du système. Pour plus d’informations, voir l’article sur les principes SOLID.
L’héritage dynamique consiste en la création d’une sous-classe au runtime. Il a pour but de surcharger une ou plusieurs méthodes, afin d’en modifier le comportement, tout en respectant le principe de Liskov décrit ci-dessus.
CGLIB est une librairie qui permet de créer de manière dynamique du bytecode en faisant de l’héritage dynamique. Ainsi, il substitue les classes par des sous-classes, ce qui donne l’impression que le comportement a changé.
Prenons par exemple une classe avec deux méthodes qui contiennent chacune un paramètre :
[java]public class MyClass {
public String sayHello(String name) {
return "Hello" + name;
}
public int incrementNumber(int i) {
return i++;
}
}[/java]
Le code suivant va permettre de créer une classe dynamiquement et d’en changer le comportement.
[java]import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
//Classe qui permet de créer des sous-classes d’une autre classe
Enhancer enhancer = new Enhancer();
//Classe à étendre
enhancer.setSuperclass(MyClass.class);
//L’objet MethodInterceptor permet d’intercepter tous les appels de méthodes
//de l’objet créé.
//Attention même les méthodes de la classe object seront interceptées
MethodInterceptor methodInterceptor = (obj, method, args, proxy) -> {
if ("sayHello".equals(method.getName())) {
return "Good bye " + args[0];
}
if ("incrementNumber".equals(method.getName())) {
return ((Integer) args[0]) + 2;
}
return proxy.invokeSuper(obj, args);
};
//Création de l’instance, avec le comportement voulu
enhancer.setCallback(methodInterceptor);
MyClass myClass = (MyClass) enhancer.create();
System.out.println(myClass.sayHello("John"));
System.out.println(myClass.incrementNumber(5));[/java]
Le résultat obtenu suite à l’exécution de ce code est :
[bash]Good byeJohn
7[/bash]
La classe principale de CGLIB est la classe Enhancer
. Nous lui fournissons, via la méthode setSuperClass()
, la classe dont nous souhaitons hériter. Ensuite, via la méthode setCallback()
, nous lui fournissons une instance de la classe MethodInterceptor
. Comme son nom l’indique, elle interceptera tous les appels de méthodes, même les méthodes étant héritées de la classe Object
. Enfin, la méthode create()
permet de créer la classe avec les nouveaux comportements.
Spring utilise CGLIB pour instancier ses beans. Cela permet de faire par exemple de l’AOP (Aspect-Oriented Programming). Mais il existe également d’autres alternatives à CGLIB.
Bytebuddy
Bytebuddy est une alternative à CGLIB. Il se présente sous la forme d’une fluent API. Mockito est basé dessus depuis la version 2.1. L’un des gros avantages de cette librairie est qu’il n’est plus nécessaire de passer par une classe qui implémente InvokeHandler
.
Réécrivons l’exemple de CGLIB, cette fois-ci avec Bytebuddy :
[java]import net.bytebuddy.ByteBuddy;
import static net.bytebuddy.implementation.FixedValue.value;
import static net.bytebuddy.implementation.InvocationHandlerAdapter.of;
import static net.bytebuddy.matcher.ElementMatchers.named;
MyClass myClass = new ByteBuddy()
.subclass(MyClass.class)
.method(named("sayHello"))
.intercept(value("Good bye John"))
.method(named("incrementNumber"))
.intercept(of((o, m, p) -> (int)p[0] + 2))
.make()
.load(getClass()
.getClassLoader()).getLoaded()
.newInstance();
System.out.println(myClass.sayHello("John"));
System.out.println(myClass.incrementNumber(2));[/java]
Il existe encore d’autres alternatives qui ne seront pas présentées dans cet article. Nous pouvons par exemple nommer javassist, BCEL ou encore ASM. Ces implémentations sont plus bas-niveau et permettent de manipuler directement le bytecode.
APT
Les Annotation Processing Tools ont été apportés avec Java 6. Cette fonctionnalité permet, via des annotations, d’exécuter du code à la compilation. Le but est de générer de nouveaux fichiers, qu’il s’agisse de code, de documentation, de configuration ou de tout autre type de fichier.
Cette méthode a un avantage par rapport aux méthodes présentées précédemment : rien n’est exécuté au runtime. En revanche, elle est plus compliquée à implémenter et ne permet pas de modifier des fichiers existants.
Prenons une classe simple, avec un attribut et une méthode. Cette classe contient une annotation que nous avons créée :
[java]@MyAptAnnotation
public class AptClassTest {
@MyAptAnnotation
private String var;
@MyAptAnnotation
public void printVar() {}
}[/java]
La classe suivante sera exécutée lors de la compilation de la classe AptClassTest
et va l’introspecter. Il aurait été tout à fait possible de créer d’autres classes à partir de cette introspection, qui ensuite auraient été compilées par Javac.
[java]import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.TypeElement;
import java.util.Set;
import java.util.stream.Collectors;
@SupportedAnnotationTypes({"org.myapt.MyAptAnnotation"})
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class MyAptImpl extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> typeElements,
RoundEnvironment roundEnvironment) {
typeElements.stream()
.flatMap(e -> roundEnvironment.getElementsAnnotatedWith(e).stream())
.forEach(
e -> {
processingEnv.getMessager().printMessage(NOTE, "Element found :");
processingEnv.getMessager().printMessage(
NOTE, " – name : " + e.getSimpleName()
);
processingEnv.getMessager().printMessage(
NOTE, " – type : " + e.getKind()
);
processingEnv.getMessager().printMessage(
NOTE, " – enclosed elements : " +
e.getEnclosedElements().stream()
.map(e2 -> e2.getSimpleName())
.collect(Collectors.joining(", "))
);
}
);
return true;
}
}[/java]
Nous les compilons manuellement de la manière suivante :
[bash]$ javac org/myapt/MyAptAnnotation.java
$ javac org/myapt/MyAptImpl.java
$ javac -processor org/myapt/MyAptImpl org/myapt/AptClassTest.java[/bash]
Nous obtenons la sortie suivante :
[bash]Note: Element found :
Note: – name : AptClassTest
Note: – type : CLASS
Note: – enclosed elements : <init>, var, printVar
Note: Element found :
Note: – name : var
Note: – type : FIELD
Note: – enclosed elements :
Note: Element found :
Note: – name : printVar
Note: – type : METHOD
Note: – enclosed elements :[/bash]
Pour que les APT soient pris en compte dans un projet, il faut une classe qui implémente l’interface Processor
(ou étend la classe AbstractProcessor
) et qu’elle soit déjà compilée. C’est la raison pour laquelle, la plupart du temps, il y a 2 projets : le premier qui contient les classes implémentant l’interface Processor
et le second sur lequel les APT sont appliqués.
Ensuite, il faut indiquer au compilateur que l’on souhaite utiliser les APT. Il y a 2 manières de le faire :
- Avec le compilateur Javac, via le paramètre
-processor
suivi de la liste des classes implémentant les APT, séparés par des virgules. - En créant un fichier
META-INF/services/javax.annotation.processing
dans le JAR de la librairie, en indiquant sur chaque ligne les classes implémentant les APT.
Exemple de fichier META-INF/services/javax.annotation.processing
, déclarant deux classes :
[java]org.xebia.maclasse1
org.xebia.maclasse2[/java]
L’avantage de la seconde solution est que la librairie contient les informations. Il n’y a donc plus besoin d’appliquer une configuration spécifique lors de la compilation.
L’une des librairies qui utilise les APT est Lombok. Mais contrairement à ce qui est dit plus haut, Lombok modifie le code. En réalité, il y arrive en modifiant les AST (Abstract Syntax Tree) générés par les compilateurs.
En effet, lorsque des sources sont compilées avec les APT, il y a 3 phases de compilation :
- Parsing : Cette étape permet de transformer le code Java des fichiers en AST.
- Annotation Processing : Permet de récupérer toutes les annotations qui viennent d’être « parsées » dans l’étape 1, puis d’exécuter les APT correspondants. Si celles-ci ont créé de nouveaux fichiers, l’étape 1 est exécutée à nouveau.
- Bytecode generation : C’est la phase de compilation finale qui va générer le code binaire pour la JVM.
En théorie, les APT ne sont pas censés pouvoir modifier l’arbre syntaxique. Mais pour ajouter sa fonctionnalité, Lombok le fait en utilisant des API cachées de Javac. Le revers de la médaille est que ce hack rend Lombok très sensible aux évolutions des JDK.
Conclusion
Le but de cet article a été de démystifier ce que font des frameworks et librairies que nous utilisons bien souvent comme des boîtes noires. Nous avons étudié les différentes méthodes qu’ils utilisent pour simplifier nos développements logiciels. Ce sont bien ces méthodes qui les rendent flexibles et moins intrusives, ce qui nous évitent de forcer nos classes à faire de l’héritage par exemple.
Néanmoins, il est important de noter que lorsque l’on rentre dans ces configurations là, nous sortons du paradigme Objet de Java. Et mal utilisé, vous pourriez tomber dans des anti-patterns et dégrader les performances de votre application.
Commentaire