Intégrer des annotations dans son projet

Les annotations sont apparues depuis Java 5 et sont de plus en plus présentes dans les différents frameworks que nous croisons tous les jours (Spring, Hibernate, etc.). Cependant il s’agit d’une fonctionnalité rarement implémentée dans les frameworks internes ou même dans les différents projets.

Nous allons donc voir plusieurs cas d’utilisation et leurs mises en pratique, afin de vous montrer que d’ajouter des annotations à un projet, c’est assez simple, fun et que ça donne un coup de jeune à vos projets en plus de simplifier la vie des développeurs.

Cet article n’est pas un tutoriel sur les annotations, pour cela je vous renvoie à cet article d’Axel. Je reprendrai cependant certains cas d’utilisations évoqués dans le tutoriel. De plus je ne m’intéresserai dans cet article qu’aux annotations runtime qui sont les plus simples à mettre en place.

Le fil rouge de l’article sera la création d’un système très simple de Listener qui permet de s’abonner de manière dynamique à des évènements.

Cas d’utilisation

Flagguer une classe, une méthode ou un attribut

Le cas le plus basique des annotations est de pouvoir “flagguer” une partie de son code. Avant les annotations, une interface de marquage était souvent utilisée comme pour Serializable ou Clonable par exemple.

Remplacer les interfaces de marquage par une annotation à plusieurs intérêts :

  • Une interface n’est pas faite pour ça, il s’agissait d’un détournement des mécanismes objet pour pallier à un manque du langage. Maintenant que ce manque est comblé, il est logique d’utiliser la véritable solution.
  • Le code est plus logique, un développeur qui arrive sur un projet et voit qu’une classe implémente une interface a souvent comme premier réflexe d’aller voir ce qu’il y a dans l’interface en l’occurrence pour constater qu’il n’y a rien et donc de s’interroger sur son intérêt.
  • Le code est plus lisible, les annotations sont en général positionnées avant la définition et non pas après. On sait donc dès le début que notre classe est “spéciale” et cela permet souvent de mieux comprendre son fonctionnement.
  • Il est possible de flagguer autre chose que des classes, cela offre donc beaucoup plus de liberté aux développeurs. Le meilleurs exemple est @Deprecated ou @Override qui permettent de savoir immédiatement qu’une méthode est obsolète ou qu’elle redéfinit une méthode de la classe mère.

Les cas d’utilisations sont assez nombreux notamment dans les “gros” frameworks. En JPA, nos classes persistées sont flagguées par un @Entity. En Spring, les différentes couches sont flagguées par @Repository, @Component, etc.

Le principal intérêt de flagguer une partie de son code est de savoir s’il doit subir des traitements particuliers dans certains cas ou, pour les classes, de récupérer toutes les classes ayant une certaine annotation. Nous verrons comment faire cela un peu plus loin.

Si l’on considère notre système d’abonnement, nous allons vouloir spécifier qu’une classe peut s’abonner à notre système d’évènement et quelle(s) méthode(s) sera(ont) appelée(s) lorsqu’un évènement est déclenché.

Nous déclarons deux annotations toutes simples :

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface EventSubscriber {
}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Trigger {
}

Puis nous les appliquons sur notre classe qui s’abonnera à nos évènements :

@EventSubscriber
public class AnnotationEventSubscriber{
...
@Trigger
public void myTrigger() {...}

@Trigger
public void anotherTrigger() {...}
...
}

Déjà un premier constat, le code est plus concis et lisible qu’avec un système d’interface. On sait dès le début que notre classe peut s’abonner à des évènements, et si nous ne regardons pas la signature de la classe et accédons directement à une méthode, nous savons immédiatement qu’il s’agit d’un trigger en faisant abstraction du nom.

Ensuite, le code est beaucoup plus libre que si nous avions déclaré une interface avec une méthode trigger habituelle. Techniquement nous pouvons déclarer autant de trigger que nous voulons, il suffit que le code qui les appelle le prenne en charge. Par la suite nous irons encore plus loin en rendant la signature de la méthode libre à l’utilisateur.

Configurer son code sans fichiers externes

Une autre grande amélioration apportée par les annotations est de pouvoir configurer son code très simplement. Ici, il s’agit bien entendu de configuration purement programmatique et non pas applicative.

Les applications modernes sont de plus en plus modulaires et flexibles, il faut donc un moyen pour faire travailler tout ce monde ensemble en évitant au maximum les couplages forts entre les différents modules. La solution classique est un fichier de configuration qui permet de définir un mapping ou les interactions entre les différents composants. Dans de nombreux cas, ces configurations sont directement liées au code et ne peuvent pas être modifiées sans impacter le code directement. Elles n’ont donc pas vocation à être changées à chaud.

Les développeurs se retrouvent donc à gérer deux fichiers (leur classe et un fichier XML). Les annotations permettent de ramener la logique de configuration dans le code puisqu’elle y est directement liée et de se passer de fichiers XML.

Un autre avantage non négligeable est que souvent les annotations sont beaucoup moins verbeuses qu’un fichier XML.

Un très bon exemple de ceci est le passage d’un fichier de mapping à un système d’annotations dans Hibernate. Pour que le mapping change, il faut que le code avec lequel il est couplé change également. De plus le fichier XML était très fastidieux à maintenir et très verbeux.

Note : Il existe de nombreux débats sur les avantages et inconvénients du passage du XML vers les annotations et au final pas de réel consensus, il s’agit principalement d’une préférence personnelle car chaque méthode a des avantages et des inconvénients. Je vous encourage à rechercher sur internet les fils de discussions sur les annotations hibernate pour vous faire votre propre idée.

Si nous continuons notre exemple, nous allons vouloir définir à quel type d’évènements notre abonnement fait référence. Au choix nous pouvons choisir de définir la configuration au niveau de la classe afin qu’elle s’applique à toutes les méthodes @Trigger ou directement au niveau de la méthode. Pour plus de simplicité et de flexibilité nous allons le faire au niveau de la méthode.

Nous allons simplement rajouter un attribut permettant de saisir un type d’évènement sous forme de String :

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Trigger {

String value();
}

Maintenant nous pouvons définir pour chacun de nos triggers quel évènement le déclenche :

@EventSubscriber
public class AnnotationEventSubscriber{

@Trigger("event1")
public void myTrigger() {}

@Trigger("event2")
public void anotherTrigger() {}
}

Si vous avions du faire le même mécanisme via un fichier XML cela aurait ressemblé à ça :

<events>
<event name="event1">
<trigger class="fr.soat.classical.ClassicalEventSubscriber" method="myTrigger"/>
</event>
<event name="event2">
<trigger class="fr.soat.classical.ClassicalEventSubscriber" method="anotherTrigger"/>
</event>
</events>

Auquel il aurait fallu associer un parser XML pour extraire les informations.

En cas de modification (ajout d’un attribut par exemple) il aurait donc fallu modifier le XML et le parseur au lieu de simplement rajouter un attribut à notre annotation directement.

Injecter des données

Un autre cas d’utilisation intéressant des annotations est l’injection d’informations, soit dans les paramètres d’une méthode, soit directement dans un attribut. C’est ce que font Spring ou CDI pour nous, via des annotations justement.

Mais il est possible d’aller plus loin que le simple pattern IoC en extrayant des paramètres d’une Map dynamiquement par exemple. Ce système est utilisé par Spring MVC pour directement injecter des paramètres HTTP de la requête à une méthode.

Si l’on considère notre exemple avec un objet évènement contenant en plus de son type, une série de paramètres génériques sous forme de Map :

public class Event {
private String type;
private Map params = new HashMap();

public Event(String type, Map params) {
this.type = type;
this.params = params;
}

public Event(String event) {
this.type = event;
}

public String getType() {
return type;
}

public Object getParam(String name) {
return params.get(name);
}
}

Nous pouvons faire en sorte que nos triggers acceptent différentes signatures : Aucun paramètre, un object Event, éventuellement une String correspondant au type et une Map de paramètres. Jusque-là rien de spectaculaire et pas besoin d’annotations, il suffit de vérifier la signature par réflexion et le tour est joué.

Maintenant imaginons que l’on veuille une méthode qui prenne comme paramètre une valeur précise de notre Map :

@Trigger("event2")
public void anotherTrigger(String param1, Integer param2) {}

Au lieu d’un classique :

public void triggeredWhenEventOccurs(Event event) {}

Cela a plusieurs avantages :

  • Notre méthode est testable unitairement sans avoir besoin de construire un objet Event qui ne nous sert pas.
  • Elle a un sens métier, son nom est libre et correspond donc à ce qu’elle fait vraiment, ses paramètres sont limités à ceux qui la concerne, et par conséquent est plus facilement compréhensible.
  • Elle est réutilisable pour d’autres appels si l’action peut être déclenchée autrement (action utilisateur, appel à un webservice, etc.).

Cependant pour que cela fonctionne il faut définir quel paramètre injecter via une annotation (vous pouvez également le faire par configuration XML mais les 3/4 de vos développeurs se pendront en voyant la syntaxe et le 1/4 restant utilisera systématiquement des objets Event car ça sera plus rapide pour eux).

Nous créons donc une annotation chargée du mapping entre notre Map et nos paramètres de méthode :

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface EventParam {
String value();
}

Dans cet exemple notre annotation est réduite au strict minimum, c’est à dire recevoir le nom du paramètre en valeur. Il serait éventuellement souhaitable de définir si le paramètre est obligatoire ou non et de définir une valeur par défaut s’il ne l’est pas.

Nous pouvons donc éditer notre trigger afin que la magie s’opère :

@Trigger("event2")
public void anotherTrigger(@EventParam("param1")String param1, @EventParam("param2") Integer param2) {}

Auto-détection et recherches d’annotations

Nous avons vu plusieurs cas d’utilisation intéressants mais jusqu’à présent il manque une partie importante : la magie qui fait fonctionner tout ça.

En effet nous avons beau mettre des annotations partout s’il n’y a rien pour les interpréter elles ne servent à rien et rien ne se passera. Il nous faut donc un moyen de vérifier que les annotations sont présentes (via l’API Reflection) mais surtout un moyen de scanner le classpath pour trouver les classes et les instances possédant certaines annotations.

Le moyen le plus simple pour gérer tout ça est le système de scan de Spring. Mais comme on ne veut pas forcément être dépendant de Spring pour un projet ou un framework donné, je présenterai également rapidement d’autres moyens d’arriver à nos fins.

Retrouver nos annotations

Comme souvent avec Spring, tout devient simple. Il suffit de connaitre la bonne ligne à mettre dans le fichier de configuration!

En l’occurrence la ligne magique est :


<context:component-scan base-package="fr.soat.annotation" />

Cette ligne permet de dire à Spring quels packages il doit scanner et vous l’avez probablement déjà si vous utilisez les annotations Spring dans votre projet.

Une fois que vous vous êtes assuré que Spring scannait bien vos classes, vous pouvez retrouver vos instances grâce à la classe ClassPathScanningCandidateComponentProvider qui va vous permettre de scanner toutes les classes indexées par Spring.

Note : pour que ça fonctionne il faut que vos classes soient instanciées par Spring bien évidemment.

La classe ClassPathScanningCandidateComponentProvider se contentant de scanner toutes les classes, il faut restreindre son champ d’action via un filtre. Il existe plusieurs types de filtres en fonction de vos besoins (annotation, nom de la classe, héritage, etc.), nous allons bien évidemment utiliser celui qui permet de retourner la liste des classes possédant une annotation précise :

AnnotationTypeFilter filter = new AnnotationTypeFilter(EventSubscriber.class);
scanner.addIncludeFilter(filter);
Set<BeanDefinition> beans = scanner.findCandidateComponents("fr.soat.annotation");

Extraire des métadonnées

Nous voici avec une collection de BeanDefinition, objet qui contient tout un tas d’informations utiles à Spring, et une seule qui nous intéresse : le nom de la classe.

A partir de là, il suffit d’itérer sur la liste, récupérer l’objet Class correspondant et de chercher les méthodes annotées :

for(BeanDefinition beanDefinition : beans) {
Class beanClass;
try {
beanClass = Class.forName(beanDefinition.getBeanClassName());
for (Method curMethod : curClass.getMethods()) {
Trigger annotation = curMethod.getAnnotation(Trigger.class);
if (annotation != null) {
String eventName = annotation.value();
Object bean = applicationContext.getBean(beanClass);
registerListener(eventName,curMethod,bean);
}
}

} catch (ClassNotFoundException e) {

// spring a indexé une classe qui n'existe pas...
throw new RuntimeException("Configuration fatal error!!!", e);
}
}

Enregistrer nos listeners

Maintenant que nous avons nos classes et nos méthodes, il ne nous reste plus qu’à récupérer l’instance du bean via l’applicationContext Spring et d’enregistrer nos listeners. Nous allons donc créer un objet pour encapsuler la méthode et le bean puis ajouter tout ça dans une Map associant une liste de couple bean-méthode à un type d’évènement via la méthode registerListener présente dans le code d’avant.

public class TriggerToCall {
private Method method;
private Object bean;

public TriggerToCall(Method method, Object bean) {
this.method = method;
this.bean = bean;
}

public Method getMethod() {
return method;
}

public Object getBean() {
return bean;
}
}
public void registerListener(String eventName, Method method,Object bean) {
List triggersToCall = triggers.get(eventName);
if (triggersToCall == null) {
triggersToCall = new ArrayList();
}
triggersToCall.add(new TriggerToCall(method,bean));
triggers.put(eventName, triggersToCall);
}

Déclencher les évènements

Maintenant que notre système d’abonnement automatique est prêt il ne nous reste plus qu’à coder le déclenchement de nos évènements. Cette partie est relativement simple, Il suffit de récupérer la liste des abonnés pour notre évènement et d’appeler chaque méthode abonnée via l’API Reflection.

public void dispatchEvent(Event event) {
Collection eventTriggers = triggers.get(event.getType());

if(eventTriggers != null) {
for(TriggerToCall trigger : eventTriggers) {
try {
trigger.getMethod().invoke(trigger.getBean(), extractMethodParameters(event));
} catch (IllegalAccessException e) {
e.printStackTrace(); // change with your logging system
} catch (InvocationTargetException e) {
e.printStackTrace(); // change with your logging system
}
}
}
}

Il ne nous reste plus qu’à coder la méthode extractMethodParameters qui va se charger d’extraire les bons paramètres de notre évènement en fonction de la signature de la méthode abonnée.

Gérer les signatures multiples pour nos méthodes trigger

Cette partie se contente de vérifier les paramètres de la méthode et de les injecter si elle les trouve.

Note : Pour ne pas complexifier l’exemple, en cas de problème à n’importe quel niveau on jette une RuntimeException. Dans une implémentation réelle il faudrait bien évidement prévoir un mécanisme de récupération ou jeter une autre exception.

private Object[] extractMethodParameters(TriggerToCall trigger, Event event) {
// extraction des types des paramètres
Class[] types = trigger.getMethod().getParameterTypes();

// pas de paramètres!
if (types.length == 0) {
return new Object[0];
}

// le paramètre est l'évènement
else if (types.length == 1  &amp;amp;amp;amp;amp;&amp;amp;amp;amp;amp; types[0] == Event.class) {
return new Object[]{event};
}

// introspection pour récupérer les annotations
List paramList = new ArrayList();
Annotation[][] annotationTypes = trigger.getMethod().getParameterAnnotations();
for (Annotation[] annotations : annotationTypes) {
for (Annotation annotation : annotations) {
if (annotation instanceof EventParam) {
paramList.add((EventParam) annotation);
}
}
}

// check que tous les paramètres sont bien annotés
if (paramList.size() != types.length) {
throw new RuntimeException("You need to annotate all the parameters of your trigger");
}

List<Object> params = new ArrayList<Object>();

// Création de la liste de paramètres
int curParam = 0;
for (EventParam param : paramList) {
Object value = event.getParam(param.value());
// le nom et le type matches
if (value != null  &amp;amp;amp;amp;amp;&amp;amp;amp;amp;amp; value.getClass().isAssignableFrom(types[curParam])) {
params.add(value);
}
// le type ne match pas
else if (value != null) {
throw new RuntimeException("Invalid type  : '" + value.getClass().getName() + "' expected '" + types[curParam].getName());
}
// paramètre inconnu
else {
throw new RuntimeException("Parameter not found : '" + param.value() + "'");
}
curParam++;
}

return params.toArray();
}

Se passer de Spring

Dans cet article, Spring est utilisé pour deux tâches distinctes :

  • L’instanciation des classes
  • La détection des annotations

Pour l’instanciation, vous pouvez utiliser un autre framework (picoContainer, CDI, Guice, etc.) ou les gérer vous-même. Vous aurez alors à changer la partie utilisant l’applicationContext Spring par tout autre code permettant de trouver l’instance d’une classe.

Pour la détection des annotations, la situation est un peu plus compliquée. Il est possible de parcourir toutes les classes d’un classpath de différentes manières pour vérifier une par une si elles possèdent nos annotations mais cela peut être relativement complexe et fastidieux à faire soi-même.

Cependant il existe des frameworks plus léger que Spring qui peuvent se charger de cela à votre place.  Le plus avancé et le plus puissant semble être reflections qui permet de faire des recherches très poussées dans un classpath sans avoir d’autres dépendances à tirer.

Conclusion

Cet article nous a montré qu’il était possible de grandement simplifier la tâche des développeurs en intégrant des annotations dans nos projets. Le coût initial n’est cependant pas nul, surtout si vous n’êtes pas spécialement familier avec les annotations, mais peut être vite amorti grâce au gain de productivité qu’il engendre en évitant les fichiers de configuration redondants ou en facilitant les tests par exemple.

L’ensemble du code du projet afin de mieux comprendre comment tout cela s’architecture ensemble est disponible ici.

Nombre de vue : 281

COMMENTAIRES 1 commentaire

  1. Mathieu PARISOT dit :

    Le code source est maintenant également disponible sur GitHub : https://github.com/soatexpert/Annotations

AJOUTER UN COMMENTAIRE