juil.
2009
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.
package net.thecodersbreakfast.packageannotations.model; public class Foo /* Not serializable */ { ... } public class Bar implements Serializable { ... }
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.
package net.thecodersbreakfast.packageannotations; import java.lang.annotation.*; @Target(value=ElementType.PACKAGE) @Retention(RetentionPolicy.SOURCE) @Documented public @interface SerializableClasses {}
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 aSOURCE
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.
@SerializableClasses /* DOES NOT WORK */ package net.thecodersbreakfast.packageannotations.model; public class Foo { ... }
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 :
@SerializableClasses package net.thecodersbreakfast.packageannotations.model; 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.
package net.thecodersbreakfast.packageannotations; // Imports omitted @SupportedSourceVersion(SourceVersion.RELEASE_6) @SupportedAnnotationTypes("net.thecodersbreakfast.packageannotations.SerializableClasses") public class SerializableClassesProcessor extends AbstractProcessor { @Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { // Utils Types typeUtils = processingEnv.getTypeUtils(); Elements elementUtils = processingEnv.getElementUtils(); Messager messager = processingEnv.getMessager(); // The Serializable interface - used for comparison TypeMirror serializable = processingEnv.getElementUtils().getTypeElement(Serializable.class.getCanonicalName()).asType(); Set<? extends Element> rootElements = roundEnv.getRootElements(); for (Element element : rootElements) { // We're only interested in packages if (element.getKind() != ElementKind.PACKAGE) { continue; } // Get some infos on the annotated package PackageElement thePackage = elementUtils.getPackageOf(element); String packageName = thePackage.getQualifiedName().toString(); // Test each class in the package for "serializability" List<? extends Element> classes = thePackage.getEnclosedElements(); for (Element theClass : classes) { // We're not interested in interfaces if (theClass.getKind() == ElementKind.INTERFACE) { continue; } // Check if the class is actually Serializable boolean isSerializable = typeUtils.isAssignable(theClass.asType(), serializable); if (! isSerializable) { messager.printMessage(Kind.ERROR, "The following class is not Serializable : " + packageName + "." + theClass.getSimpleName()); } } } // Prevent other processors from processing this annotation return true; } }
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 :
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 :
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 :
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
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
ne sert à rien.
A+ & merci
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 ?
> 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 :(
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.
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
{
}
@@
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
}}
Aurais-tu une idée ?
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...
Ah, bien vu !
Pas de bol, quand meme, de tomber sur un bug du compilo...
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
@@
@@
2/ Performance : Ajout d'un check dans la méthode
vérifiant que l'on est pas en postprocessing@@
@@
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 {
/**
}
@@
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.
@@
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface TestClass {
}
@@
Il faudra préciser la classe testée, mais il y aura un comportement par défaut si elle ne l'est pas.
@@
@TestClass
public class FooTest {}
@@
ou
@@
@TestClass(Foo.class)
public class FooTest {}
@@
@@
@SupportedSourceVersion(SourceVersion.RELEASE_6)
@SupportedAnnotationTypes("ged.salestools.common.control.TestClass")
public class TestClassProcessor extends AbstractAnnotationProcessor {
}
@@
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).
finalement j'ai remplacé les Kind.ERROR en Kind.WARNING