Vous aimez ce que vous lisez sur ce blog ?
Envie d'aller plus loin avec véritable formation d'expertise en Java ?
Venez suivre ma formation Masterclasse Expertise Java !

"Même un développeur experimenté a besoin de continuer à apprendre. Et dans cette formation... j'ai appris beaucoup !" - A.G., Java Champion

Sessions intra-entreprises sur demande : contact[at]mokatech.net.
Inscrivez-vous vite !

Enforcing design rules with the Pluggable Annotation Processor

As a Java architect, your role is to define rules and best-practices, based on well-known patterns and personal experience.
Being a conscientious professional, you take time to painstakingly write them down in some specification document or on the local Wiki, complete with code samples and colorful UML diagrams - only to find out that most of your developers never remember (or even bother) to read your literature and apply your rules.
So at one point, you realize that what you really need is an automated mean to enforce your design rules at development time.

In this article, I will present a technique to apply package-level restrictions with annotations and a custom annotation processor.

The setup

Let's say your architecture guidelines require all your model classes to be Serializable.

Here are two model classes, Foo and Bar, which belong to the "net.thecodersbreakfast.packageannotations.model" package. Notice that Foo is not serializable, whereas Bar is.

  1. package net.thecodersbreakfast.packageannotations.model;
  2.  
  3. public class Foo /* Not serializable */
  4. { ... }
  5.  
  6. public class Bar implements Serializable
  7. { ... }

What we want here is the compiler to complain that class Foo is not serializable and produce a compilation error.
To achieve this, we will need :

  • A custom annotation to flag the package as containing only serializable classes : @SerializableClasses
  • A custom annotation processor to enforce this rule at compile-time

Let's get to work.

The @SerializableClasses marker annotation

Developping the annotation

As annotations have been around since Java 5.0 (2004), this should look easy and familiar to you.
An annotation declaration looks just like an empty interface declaration, except for the additional "@" symbol.

  1. package net.thecodersbreakfast.packageannotations;
  2.  
  3. import java.lang.annotation.*;
  4.  
  5. @Target(value=ElementType.PACKAGE)
  6. @Retention(RetentionPolicy.SOURCE)
  7. @Documented
  8. public @interface SerializableClasses
  9. {}

As you can see, our annotation is itself annotated :

  • @Target tells on which entities it can be applied : classes, methods... Here, only packages are valid targets.
  • @Retention specifies whether the annotation should be retained in the bytecode, past the compilation process. Ours will be consumed by the compiler, so a SOURCE level is sufficient.
  • @Documented annotations appear in their target's javadoc documentation. As our annotation defines and enforces a design rule, it better be documented.

Now, let's apply that shiny new annotation on our model package.

Applying the annotation to a package

Annotating a class or method is easy : just put the annotation on top of its declaration.
Packages, in another hand, are not declared in a unique location ; so how can they be annotated ?

You might try to put the annotation on a random classe or interface (or all of them) belonging to that package.

  1. @SerializableClasses /* DOES NOT WORK */
  2. package net.thecodersbreakfast.packageannotations.model;
  3.  
  4. public class Foo
  5. { ... }

Nice try, but that just won't work.

The proper way to annotate a package is a rather unknown feature of Java, partly because the need seldom arises, and partly because it is a bit more convoluted than expected - though very easy once you get the hang of it.

In fact, all you need to do is create a a file named "package-info.java" in the desired package, containing the package declaration and its related annotations :

  1. @SerializableClasses
  2. package net.thecodersbreakfast.packageannotations.model;
  3. import net.thecodersbreakfast.packageannotations.SerializableClasses;

Note for Eclipse users : despites it ".java" extension, "package-info" is an invalid Java identifier, so you cannot create it with Eclipse's "New class" wizard. Create a simple plain text file instead.

The model package is flagged with our custom annotation. Now let's see how we can use that information to enforce our "all classes must be serializable" rule.

The annotation processor

Java 6 defines "Pluggable Annotation Processing API" (JSR 269) that gives developers a chance to perform custom annotation-driven tasks during the compilation process. Typical use-cases include generating configuration files or additional classes - modifying existing classes is not possible though.

This API is powerful but quite complex to master ; fortunately, our use-case is simple, so our implementation shall be rather straightforward.

Development

Let's take a look at the code before discussing it.

  1. package net.thecodersbreakfast.packageannotations;
  2.  
  3. // Imports omitted
  4.  
  5. @SupportedSourceVersion(SourceVersion.RELEASE_6)
  6. @SupportedAnnotationTypes("net.thecodersbreakfast.packageannotations.SerializableClasses")
  7. public class SerializableClassesProcessor extends AbstractProcessor
  8. {
  9. @Override
  10. public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv)
  11. {
  12. // Utils
  13. Types typeUtils = processingEnv.getTypeUtils();
  14. Elements elementUtils = processingEnv.getElementUtils();
  15. Messager messager = processingEnv.getMessager();
  16.  
  17. // The Serializable interface - used for comparison
  18. TypeMirror serializable = processingEnv.getElementUtils().getTypeElement(Serializable.class.getCanonicalName()).asType();
  19.  
  20. Set<? extends Element> rootElements = roundEnv.getRootElements();
  21. for (Element element : rootElements)
  22. {
  23.  
  24. // We're only interested in packages
  25. if (element.getKind() != ElementKind.PACKAGE)
  26. { continue;
  27. }
  28.  
  29. // Get some infos on the annotated package
  30. PackageElement thePackage = elementUtils.getPackageOf(element);
  31. String packageName = thePackage.getQualifiedName().toString();
  32.  
  33. // Test each class in the package for "serializability"
  34. List<? extends Element> classes = thePackage.getEnclosedElements();
  35. for (Element theClass : classes)
  36. {
  37. // We're not interested in interfaces
  38. if (theClass.getKind() == ElementKind.INTERFACE)
  39. { continue;
  40. }
  41.  
  42. // Check if the class is actually Serializable
  43. boolean isSerializable = typeUtils.isAssignable(theClass.asType(), serializable);
  44. if (! isSerializable)
  45. {
  46. messager.printMessage(Kind.ERROR,
  47. "The following class is not Serializable : " + packageName + "." + theClass.getSimpleName());
  48. }
  49. }
  50. }
  51.  
  52. // Prevent other processors from processing this annotation
  53. return true;
  54. }
  55. }

The @SupportedAnnotationTypes annotation defines the set of annotations this processor can handle (in our case, only those of type @SerializableClasses) ; the compiler will call the process() method if at least one of the compilation units bears one of the supported annotations.
The process() method paramters are :

  • The set of elements to be processed
  • A RoundEnvironment variable providing information on the ongoing compilation process : whether an error has been raised, if the process is over, the items to be compiled...

Technical details left apart, our algorithm is easy to understand :

  • for each @SerializableClasses package,
  • for each enclosed class,
  • verify that the class implements Serializable. If not, raise a compilation error.

Now that we are done coding, let's do some tests.

Test and deployment

Test

First, we need to compile the annotation and the processor :

  1. javac -d bin src/net/thecodersbreakfast/packageannotations/*.java

Then, activating the processor while compiling the model classes is only a matter of passing the compiler an additional option :

  1. javac -d bin -cp bin -processor net.thecodersbreakfast.packageannotations.SerializableClassesProcessor src/net/thecodersbreakfast/packageannotations/model/*.java

This should result in :

error: The following class is not Serializable : net.thecodersbreakfast.packageannotations.model.Foo
1 error

As you can see, our design requirement is now a hard, compiler-enforced rule.

Automatic discovery with the ServiceProvider API

Specifying manually the processor(s) is tedious and error-prone. Furthermore, it doesn't work that nice with automated build systems nor with IDEs.

Using the Service Provider API (JSR 000024) would allow the compiler to auto-discover and use every annotation processor available in the classpath. Way better.
You may refer to this previous blog post to learn more about it : Présentation du Service Provider API.

This system uses the "META-INF/services" directory (case-sensitive), which you may have to create.
Each file in this directory is named after the service is provides (often an interface name), and contains the fully-qualified names of the service's available implementations.

Let's get back to our use-case.
Since we implement the Processor service with our SerializableClassesProcessor class, we must create this file (without the comment) :

# File : META-INF/services/javax.annotation.processing.Processor
net.thecodersbreakfast.packageannotations.SerializableClassesProcessor

Finally, create a jar containing the compiled classes (the annotation and the processor) and the "META-INF" directory :

  1. jar cvf SerializableClassesProcessor.jar -C bin net/thecodersbreakfast/packageannotations/SerializableClassesProcessor.class -C bin net/thecodersbreakfast/packageannotations/SerializableClasses.class META-INF/

Now you can import the resulting jar in any project, annotate packages with the @SerializableClasses annotation, and have the compiler automatically enforce the rule for you !

Conclusion

In this article, we have seen how to develop a custom annotation and how to apply it to a package. Then, we developped a custom annotation processor to enforce our design guidelines. Finally, we took advantage of the Service Provider API to bundle and deploy our system.

As a conclusion, I encourage you to get to know Java's obscure features and lesser-known APIs, as their combination may yield surprising results !

The source code is available as an attachment below.


Commentaires

1. Le mercredi 15 juillet 2009, 15:30 par AA

Salut,

J'ai mis en pratique immédiatement, et cela fonctionne bien !! Je n'aurais pas réussi à faire le Processor comme ça !!! enfin ...
Par contre j'aimerai étendre cette vérification aux sous packages, mais je n'ai pas réussi, est-ce possible ?

deplus j'ai l'impression que le test

  if (element.getKind() != ElementKind.PACKAGE)

ne sert à rien.

A+ & merci

2. Le jeudi 16 juillet 2009, 13:07 par Olivier Croisier

Il n'y a pas de véritable notion de hiérarchie entre les packages, à la différence des classes : un "sous-package" n'est qu'un package partageant une partie du nom de son package "parent", c'est tout. Les annotations ne sont donc pas héritées, et il faut les appliquer sur tous les packages souhaités.

Quant au test sur la nature "package" de l'élément, il est nécessaire. Le processeur reçoit l'ensemble des "compilation units" traitées par le compilateur, et pas seulement celles portant l'annotation gérée.

Appliques-tu uniquement le use-case présenté ici, ou as-tu trouvé d'autres usages à cette technique ?

3. Le vendredi 17 juillet 2009, 16:33 par AA

> Il n'y a pas de véritable notion de hiérarchie entre les packages
Je m'attendais à cette réponse, et je trouve ca dommage d'ailleurs...

> Le processeur reçoit l'ensemble des "compilation units" traitées par le compilateur
pour le package, j'ai craqué...

J'applique exactement cet use-case, mais j'aimerais bien en ajouter d'autre plutôt que faire des rêgles check-style. Je pense le mettre en place afin de vérifier que les méthodes des classes d'un package possèdent bien une annotation. C'est pour XFire, mais comme on va passer à CXF, c'est pas dit...

Ah et petit détail encore, Eclipse ne détecte pas l'erreur :(

4. Le vendredi 17 juillet 2009, 21:52 par Olivier Croisier

Effectivement, Eclipse ne le gère pas, mais IntelliJ oui, et les maven/ant/gradle aussi puisqu'ils utilisent le compilateur standard.

N'hésite pas à publier tes créations, je pourrais par exemple les mettrai en téléchargement ici avec les crédits associés évidemment.
Le mieux serait un jar par use-case, contenant l'annotation, le processeur et les sources.

5. Le lundi 10 août 2009, 13:07 par AA

Salut,

J'ai créé un autre processor d'annotations, et après quelques difficultés que j'exposerai dans un prochain post, je suis arrivé à un résultat satisfaisant.

Néanmoins, en voulant l'utiliser mon jar dans un autre projet, je me suis rendu compte que le compilateur renvoyai une erreur. Pensant que c'était dû à mon code, je n'ai mis que ton processor. Et j'ai eu la même erreur…

Dans ce projet, une annotation existe avec une valeur par défaut :
@@
package ged.salestools.model;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(value = ElementType.FIELD)
public @interface ComparableField
{

 public ComparatorFieldType type() __ default ComparatorFieldType.Single; __

}
@@
Lors de la compilation avec Maven, j'ai l'erreur suivante.
{{
\SALESTOOLSMODEL\model\src\main\java\ged\salestools\model\ComparableField.java:12,63 incompatible types
found : ged.salestools.model.ComparatorFieldType
required: ged.salestools.model.ComparatorFieldType
}}

  • Si je retire la valeur par défaut, je n'ai plus l'erreur.
  • Si je retire ma dépendance sur le jar de processor, je n'ai plus l'erreur.

Aurais-tu une idée ?

6. Le mercredi 2 septembre 2009, 15:49 par AA

Bon voilà la raison :
http://bugs.sun.com/view_bug.do?bug...
http://bugs.sun.com/view_bug.do?bug...

Bug corrigé dans OpenJDK 6 b16 mais pas encore dans le JDK de Sun ... Et compiler OpenJDK sur Windows n'est pas un mince affaire...

7. Le mercredi 2 septembre 2009, 15:56 par Olivier Croisier

Ah, bien vu !
Pas de bol, quand meme, de tomber sur un bug du compilo...

8. Le mercredi 2 septembre 2009, 16:08 par AA

A lors voila tout d'abord les améliorations que j'ai faites

1/ Performance : Déplacement de la déclaration des propriétés utils dans la méthode init

@@

@Override
 public synchronized void init(ProcessingEnvironment processingEnv) {
   super.init(processingEnv);
   elementUtils = processingEnv.getElementUtils();
   typeUtils = processingEnv.getTypeUtils();
   messager = processingEnv.getMessager();
   // The Serializable interface - used for comparison
   serializableType = elementUtils.getTypeElement(Serializable.class.getCanonicalName()).asType();
 }

@@

2/ Performance : Ajout d'un check dans la méthode process vérifiant que l'on est pas en postprocessing
@@

  @Override
 public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
   if (roundEnv.processingOver()) {
     // We're not interested in the postprocessing round.
     return false;
   }
   ...

@@

4/ Debugging : Ajout d'une classe abstraite permettant de mettre un point d'arrêt pour le degugger tout ça, mes classes processors en dérivent

@@
public abstract class AbstractAnnotationProcessor extends AbstractProcessor {
/**

  * Method to add for debugging
  * 1/ add break point on code: "while (me)"
  * 2/ set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,address=7111,server=y,suspend=n
  * 3/ In Eclipse, create java remote application on port 7111 
  */
 protected void breakpoint() {
   boolean me = true;
   try {
     while (me) {
       Thread.sleep(2000);
     }
   } catch (InterruptedException e) {
   }
 }

}
@@

9. Le mercredi 2 septembre 2009, 16:12 par AA
  1. Oui c'est pas de bol :(
  2. Je vais arrêter avec les puces numérotées manuellement car je ne sais pas compter ...
  3. Pourquoi le code n'est pas formaté ?
10. Le mercredi 2 septembre 2009, 16:47 par AA

Alors comme je l'ai promis un nouveau processor, je t'enverrai les sources par mail.

Il est courant de créer une classe de test dans le même package que la classe testée (ceci principalement afin de pouvoir d'accéder au membre protected).
Mais avec les refactoring, la classe va être renommée ou déplacée, et souvent on en oublie la classe de test.

L'objectif est donc de vérifier que la classe à tester et la classe de test restent dans le même package.

  • Voici la déclaration de l'annotation qui sera à ajouter sur les classes de tests :

@@
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface TestClass {

 /** Tested Class  */
 Class<?> value() default Void.class;

}
@@

Il faudra préciser la classe testée, mais il y aura un comportement par défaut si elle ne l'est pas.

  • Utilisation de l'annotation

@@
@TestClass
public class FooTest {}
@@
ou
@@
@TestClass(Foo.class)
public class FooTest {}
@@

  • Voici le code du processor

@@

@SupportedSourceVersion(SourceVersion.RELEASE_6)
@SupportedAnnotationTypes("ged.salestools.common.control.TestClass")
public class TestClassProcessor extends AbstractAnnotationProcessor {

 private Elements elementUtils;
 private Types typeUtils;
 private Messager messager;
 private TypeMirror voidType;
 @Override
 public synchronized void init(ProcessingEnvironment processingEnv) {
   super.init(processingEnv);
   elementUtils = processingEnv.getElementUtils();
   typeUtils = processingEnv.getTypeUtils();
   messager = processingEnv.getMessager();
   // The Serializable interface - used for comparison
   voidType = elementUtils.getTypeElement(Void.class.getCanonicalName()).asType();
 }
 @Override
 public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
   if (roundEnv.processingOver()) {
     // We're not interested in the postprocessing round.
     return false;
   }
   Set<? extends Element> rootElements = roundEnv.getElementsAnnotatedWith(TestClass.class);
   for (Element element : rootElements) {
     // We're only interested in classes
     if (element.getKind() != ElementKind.CLASS) {
       continue;
     }
     //breakpoint();
     //Get package info
     //- Get package of this element
     PackageElement thePackage = elementUtils.getPackageOf(element);
     //- Get package name
     String packageName = thePackage.getQualifiedName().toString();
     //-----------------------------------------------------------------
     //Get tested class from annotation
     //-----------------------------------------------------------------
     TestClass testClassAnnotation = element.getAnnotation(TestClass.class);
     TypeMirror testClassType = null;
     //[http://java.sun.com/javase/6/docs/api/javax/lang/model/element/Element.html#getAnnotation%28java.lang.Class%29]
     try {
       testClassAnnotation.value();
     } catch (MirroredTypeException mte) {
       testClassType = mte.getTypeMirror();
     }
     Element testClassElement = typeUtils.asElement(testClassType);
     //Test class has been set (diff of void)
     if (!typeUtils.isAssignable(testClassType, voidType)) {
       //Get package
       PackageElement testClassPackage = elementUtils.getPackageOf(testClassElement);
       String testClassPackageName = testClassPackage.getQualifiedName().toString();
       //Test package
       if (!testClassPackageName.equals(packageName)) {
         messager.printMessage(Kind.ERROR, "TestClass: The class "
             + testClassElement.getSimpleName() + " is in the package " + testClassPackageName
             + " instand of " + packageName);
       }
     }
     //-----------------------------------------------------------------
     //No tested class on annotation found, try to find a class in the same package
     //-----------------------------------------------------------------
     else {
       //Build tested class name
       String simpleName = element.getSimpleName().toString();
       if (element.getSimpleName().toString().endsWith("Test")) {
         simpleName = simpleName.substring(0, simpleName.length() - "Test".length());
       }
       String className = packageName + "." + simpleName;
       //Try to find the test class
       TypeElement buildTestClass = elementUtils.getTypeElement(className);
       if (buildTestClass == null) {
         messager.printMessage(Kind.ERROR, "TestClass: No class " + className + " found");
       }
     }
   }
   // Prevent other processors from processing this annotation
   return true;
 }

}
@@

Il y a deux modes de fonctionnement
- le premier où l'on précise le nom de la classe testé dans l'annotation, dans ce cas on compare le nom des deux packages des classes
- le second où l'on ne le précise pas, dans ce cas on cherche une classe de même nom (en enlevant Test s'il existe).

11. Le mardi 15 septembre 2009, 14:17 par AA

finalement j'ai remplacé les Kind.ERROR en Kind.WARNING

Ajouter un commentaire

Le code HTML est affiché comme du texte et les adresses web sont automatiquement transformées.