Introduction aux annotations
-
Permet la méta-programmation
- Association de propriétés pour des éléments de code-source
- Permet de faciliter la configuration directement dans le code-source sans utiliser de fichier de configuration externe
-
Annotations utilisables :
- Par le compilateur (e.g. ajout de contraintes sur des variables)
-
Par des outils de génération de documentation
- Javadoc
- Par d'autres outils d'analyse statique du code-source
- Dynamiquement à l'exécution par introspection
-
Liste (non exhaustive) de langages supportant les annotations
- Java (depuis la version 1.5 avec la JSR 175)
- Kotlin (compatibilité avec Java)
- Scala
- PHP
- Python (decorators qui ne sont pas vraiment des annotations mais en fait des transformateurs de méthodes, classes)
- ...
-
Exemples pratiques d'utilisation d'annotations :
- Permet de documenter le code pour générer une documentation plus consistante
-
Permet d'ajouter des contraintes sur certains champs ou paramètres qui peuvent être ensuite vérifiés (par exemple @NotNull et @Nullable pour indiquer si une référence nulle est autorisée ou pas)
- La vérification peut être réalisée statiquement à l'aide d'outil d'analyse statique de code (PMD, CheckStyle, FindBugs...)...
- ... ou dynamiquement en appelant certaines méthodes pour tester
- Permet d'utiliser des frameworks d'injection de dépendance (annotation @inject) tel que JavaEE CDI, Spring, Guice...
- Permet d'utiliser des frameworks de persistence d'objets en utilisant l'API JPA (implantations : Hibernate http://hibernate.org/[, [OpenJPA...)
- ...
Les annotations ne font rien en elles-mêmes. Elles ne sont que des indications fournies sur le code à destination d'outils externes travaillant sur ce code.
Création d'une nouvelle annotation
Les annotations sont des interfaces (prefixées par @) avec des méthodes sans argument indiquant leur propriétés.
On peut indiquer une clause default pour specifier une valeur par défaut si la propriété n'est pas définie lorsque l'on utilise l'annotation.
Déclarons une annotation :
package fr.upem.jacosa.annotations; @interface StringConstraint { boolean nullable(); String regex() default ""; int minLength() default 0; int maxLength() default Integer.MAX_VALUE; }
... et utilisons-là pour un champ d'une classe :
public class Contact { ... /** The phone number contains only digits */ @StringConstraint(nullable=false, regex="[0-9]*", minLength=10) private String phoneNumber; ... }
La machine virtuelle ne va pas tester automatiquer la contrainte. Nous pouvons ensuite écrire une classe utilitaire avec une méthode statique que nous allons appeler dans le setter pour valider la contrainte :
package fr.upem.jacosa.annotations; import java.lang.reflect.Field; import java.util.regex.Pattern; public class ConstraintChecker { public static void checkConstraints(Field field, Object value) { StringConstraint sc = field.getAnnotation(StringConstraint.class); if (sc != null) { if (value == null && ! sc.nullable()) throw new ConstraintViolation("nullability constraint not respected"); if (value != null) { String stringValue = value.toString(); if (stringValue.length() < sc.minLength()) throw new ConstraintViolation("minimum length not respected with " + value); if (stringValue.length() > sc.maxLength()) throw new ConstraintViolation("maximum length not respected with " + value); if (! Pattern.matches(sc.regex(), stringValue)) throw new ConstraintViolation(stringValue + " does not match the regexp " + sc.regex()); } } } /** Calling this method raise an exception if there is a constraint violation */ public static void checkConstraints(Class<?> klass, String fieldName, Object value) { try { checkConstraints(klass.getField(fieldName), value); } catch (NoSuchFieldException | SecurityException e) { throw new RuntimeException(e); // transform to an unchecked exception } } }
Nous pouvons ensuite implanter le setter dans Contact :
package fr.upem.jacosa.annotations; public class Contact { @StringConstraint(nullable=false, minLength=1) private final String name; public Contact(String name) { ConstraintChecker.checkConstraints(getClass(), "name", name); this.name = name; } @StringConstraint(nullable=false, regex="[0-9]*", minLength=10) private String phoneNumber; public void setPhoneNumber(String phoneNumber) { ConstraintChecker.checkConstraints(getClass(), "phoneNumber", phoneNumber); this.phoneNumber = phoneNumber; } }
- Nous sommes obligés d'appeler à la main la méthode checkConstraints.
- Nous pouvons aussi générer automatiquement l'appel à checkConstraints à l'aide d'un fremework qui examinera l'annotation et modifiera automatiquement le bytecode pour intégrer le code dans le setter.
Quelques frameworks de manipulation de bytecode :
Exemple d'utilisation du framework Javassist avec une classe permettant d'introduire un méta-objet capturant les appels de méthodes, de lecture et d'écriture de champ :
package fr.upem.jacosa.annotations; import javassist.tools.reflect.*; public class ConstraintMetaObject extends Metaobject { public ConstraintMetaObject(Object self, Object[] args) { super(self, args); System.out.println("** constructed: " + self.getClass().getName()); } public Object trapFieldRead(String name) { System.out.println("** field read: " + name); return super.trapFieldRead(name); } public void trapFieldWrite(String name, Object value) { ConstraintChecker.checkConstraints(getObject().getClass(), name, value); System.out.println("** field write: " + name); super.trapFieldWrite(name, value); } public Object trapMethodCall(int identifier, Object[] args) throws Throwable { System.out.println("** trap: " + getMethodName(identifier) + "() in " + getClassMetaobject().getName()); return super.trapMethodcall(identifier, args); } }
Une étape supplémentaire à la compilation est nécessaire pour modifier le bytecode et introduire la vérification de contrainte :
javac *.java # to compile the Java files as usual # do not forget to put the javassist.jar in the classpath priorly to this command java javassist.tools.reflect.Compiler Contact -m ConstraintMetaObject # to transform the Contact class and introduce the contraint checkings java ContactMain # to execute the Contact main as usual
package fr.upem.jacosa.annotations; public class ContactMain { public static void main(String[] args) { Contact contact = new Contact("foo"); contact.setPhoneNumber("1234"); // should fail since it does not respect the constraint } }
Les annotations peuvent ainsi être utilisées pour la programmation orientée aspect (AOP). Ce paradigme de programmation traite des fonctionnalités transversales d'un projet logiciel telles que :
- la journalisation (logging) d'informations (débuggage, profiling...)
- l'authentification
- la vérification d'arguments ou de résultat calculé par une fonction
- ...
Exemple d'utilisation d'annotation pour l'injection de dépendance
L'injection de dépendances est un mécanisme qui permet de séparer le code construisant les instances dans un fichier de configuration (ici une classe Java de configuration mais cela peut aussi être un fichier XML).
Nous créeons une classe Customer avec des annotations pour injecter automatiquement tous les champs (nous laissons un constructeur par défaut sans argument pour faciliter le travail de l'injecteur).
package fr.upem.jacosa.annotations; import com.google.inject.Inject; import com.google.inject.name.Named; /** A simple customer for an online shop */ public class Customer { @Inject @Named("customerName") private String name = null; @Inject @Named("customerAddress") private String address = null; @Inject private ShoppingBasket basket; public String getName() { return name; } public String getAddress() { return address; } public ShoppingBasket getBasket() { return basket; } @Override public String toString() { return "Customer[" + name + "@" + address + "]"; } }
Cette classe dispose d'annotations d'injection pour les champs name, ''address`` et ``ShoppingBasket``.
ShoppingBasket est une interface. Nous implantons une classe concrète ShoppingBasketImpl que l'on peut instancier.
Ensuite nous mettons en place le fichier de configuration pour l'injection (le module d'injection) :
package fr.upem.jacosa.annotations; import com.google.inject.AbstractModule; import com.google.inject.name.Names; /** Module to configure the injection for the customer */ public class MyInjectionModule extends AbstractModule { @Override protected void configure() { bind(Customer.class).to(Customer.class); bind(ShoppingBasket.class).to(ShoppingBasketImpl.class); bind(String.class).annotatedWith(Names.named("customerName")).toInstance("Santa Claus"); bind(String.class).annotatedWith(Names.named("customerAddress")).toInstance("Laponia"); } }
On peut ensuite procéder à l'instanciation automatique de la classe Customer en utilisant le module précédemment écrit avec la bibliothèque d'injection de dépendances Guice :
package fr.upem.jacosa.annotations; import com.google.inject.Guice; import com.google.inject.Injector; public class CustomerMain { public static void main(String[] args) { Injector injector = Guice.createInjector(new MyInjectionModule()); Customer customer = injector.getInstance(Customer.class); System.out.println(customer); // ... we can add code here } }
Avantage de l'approche :
- Tout le code de configuration est centralisé dans MyInjectionModule
-
Différent scénarios de test peuvent être mis en place simplement en écrivant un nouveau module de configuration
-
Par exemple si l'on veut tester une nouvelle implantation de panier SuperShoppingBasket, on modifiera la ligne :
- bind(ShoppingBasket.class).to(ShoppingBasketImpl.class);
- en bind(ShoppingBasket.class).to(SuperShoppingBasket.class);
-
Par exemple si l'on veut tester une nouvelle implantation de panier SuperShoppingBasket, on modifiera la ligne :
Inconvénient de l'approche :
- Plus de complexité pour un exemple simple (mais gain significatif pour un gros projet)
- Nécessite de fournir avec l'application la bibliothèque d'injection de dépendance (ici Guice)
Les propriétés d'une annotation
On déclare les propriétés d'une annotation avec des méthodes. Une valeur par défaut peut être indiquée : dans ce cas la propriété n'a pas à être obligatoirement indiquée lors de son usage. Seulement certains types immutables peuvent être employés pour les annotations :
- tous les types primitifs (int, long, boolean, float, double, char...)
- String
- un tableau de type primitif ou String
Les valeurs par défaut doivent être des constantes ; elles ne peuvent pas être calculées à l'exécution. Pour une String, la valeur par défaut ne peut pas être null.
Il est possible de déclarer une unique propriété que l'on nommera value. Dans ce cas l'usage de l'annotation est simplifié car on n'a pas à indiquer le nom de la propriété (on spécifie uniquement sa valeur).
Pour les types tableau, il est possible d'indiquer une unique valeur sans spécifier d'accolade ou plusieurs valeurs avec des accolades.
Les emplacements des annotations
Lorsque l'on déclare une annotation, on annote l'annotation avec la méta-annotation @Target(...) pour indiquer la ou les cibles d'une annotation (endroits où elle peut être utilisée).
En Java, il est possible d'annoter :
- une variable locale (ElementType.LOCAL_VARIABLE)
- une classe
- un champ (ElementType.FIELD)
- une méthode (ElementType.METHOD) ou un constructeur (ElementType.CONSTRUCTOR)
- un paramètre de méthode ou de constructeur (ElementType.PARAMETER)
- un paquetage (ElementType.PACKAGE) dans le fichier package-info.java
- et même une déclaration d'annotation (ElementType.ANNOTATION_TYPE) : méta-annotation
Depuis Java 1.8, les annotations peuvent également accompagner des types (préfixage) lors :
- de l'instantiation d'un objet
- d'un cast
- dans un clause implements (implantation d'interface)
- dans une clause throws (déclaration d'exceptions levées par une méthode)
La rétention d'une annotation
La politique de rétention se définit avec la méta-annotation @Retention(...) à appliquer sur l'annotation. Elle indique comment conserver l'annotation
-
L'annotation peut être utilisée uniquement pour commenter le code-source avec RetentionPolicy.SOURCE sans qu'elle soit gardée dans le bytecode
- Utile pour des applications de génération de documentation à partir du code-source
-
L'annotation peut être conservée dans le bytecode avec RetentionPolicy.CLASS avec ignorance à l'exécution
- Utile pour des outils de débuggage
-
L'annotation peut être conservée à l'exécution avec RetentionPolicy.RUNTIME
- la rétention maximale RUNTIME est intéressante car l'annotation peut être consultée par introspection à l'exécution
Récupération d'annotations par introspection
Un objet de type Class, Field, Constructor ou Field dispose des méthodes suivantes pour récupérer les annotations declarées (implantation de l'interface AnnotatedElement) :
- Annotation[] getDeclaredAnnotations() pour récupérer toutes les annotations déclarées sur l'élément mais seulement celles déclarées (les annotations héritées ne sont pas incluses)
- Annotation[] getAnnotations() pour récupérer toutes les annotations même celles héritées
- T getAnnotation(Class<T> a) pour juste récupérer une annotation dont le type est spécifié (retourne null si l'annotation n'est pas présente)
Une fois une annotation récupérée, on peut appeler ces méthodes pour obtenir les informations qu'elle embarque.
Les annotations de l'API
L'API Java Standard Edition embarque déjà un certains nombres d'annotations utiles :
-
Pour les méthodes :
- @Override accompagne une méthode pour indiquer qu'il s'agit d'une redéfinition (le compilateur vérifiera que la redéfinition est bien faite sinon une erreur de compilation sera émise)
-
Pour les méthodes et constructeurs :
- @SafeVarargs pour supprimer les avertissements sur l'usage des varargs dans une méthode ou constructeur (variables avec un nombre variable d'éléments placés à la fin d'une méthode/constructeur déclarées avec l'ellipse ...)
-
Pour les classes/interfaces :
- @FunctionalInterface indique que la classe abtraite ou interface est une interface fonctionnelle pouvant être instantiée par une expression lambda. Une telle interface fonctionnelle ne doit posséder qu'une seule méthode abstraite.
-
Pour tous les éléments :
- @Deprecated indique qu'un élément est déprécié dans l'API et qu'il est donc conseillé de ne plus l'utiliser ; généralement un commentaire est indiqué dans la Javadoc pour spécifier comment l'usage de la méthode ou de la classe doit être remplacé
-
@SuppressWarnings indique que l'on souhaite supprimer des avertissements du compilateur (ce qui en règle générale n'est pas une bonne attitude). Cette annotation a une portée limitée à l'élément sur lequel on la place (par exemple une méthode). On pourra par exemple l'utiliser ainsi :
- @SupressWarnings("unchecked") pour supprimer les avertissements concernant les generics mal utilisés
- @SupressWarnings("deprecation") pour supprimer les avertissements sur l'usage de classes ou méthodes dépréciées
- @SupressWarnings({"unchecked", "deprecation"}) pour supprimer ces deux types d'avertissements
Les méta-annotations de l'API
Les méta-annotations sont des annotations d'annotations. Elles s'appliquent sur les annotations pour indiquer certaines propriétés. L'API standard en propose quelques une que voilà :
- @Retention(...) pour indiquer la politique de rétention
- @Documented pour indiquer que l'usage de l'annotation doit être obligatoirement documentée dans la Javadoc
- @Target(...) pour indiquer la cible de l'annotation
- @Inherited pour indiquer que l'annotation est héritable dans une classe dérivée (par défaut ce n'est pas le cas)
- @Repeatable(ClassName.class) : depuis Java 1.8, une annotation est répétable (exprimable plusieurs fois sur le même élément avec éventuellement des valeurs de propriétés différentes). Une annotation répétée est en fait encapsulée dans un tableau propriété d'une autre annotation : pour en savoir plus, on se référera à cette page