L’un des arguments majeurs pour l’adoption d’un langage statiquement typé à l’instar de Java est que la phase de compilation détecte les erreurs de types bien avant que le programme ne soit exécuté et s’assure qu’il n’y aurait pas de surprises en production. Cependant, avec la panoplie des frameworks et des bibliothèques qui utilisent massivement les annotations (Spring/Hibernate), plusieurs problèmes émergent suite à un oubli d’une annotation ou d’un paramètre donné.
Valider la présence des annotations dès la phase de compilation est un plus qu’on va essayer de présenter tout au long de cet article.
Ce tutoriel se compose de deux parties : en premier lieu, nous aurons à développer une simple API de sérialisation Json, qui va permettre de générer des fabriques (factories) en phase de compilation. En second lieu, nous aurons à développer un processeur d’annotations qui intégrera les introspections du code et le traitement des annotations au compilateur Java grâce à l’API “Pluggable annotations processing” définie par la JSR 269 permettant la détection des erreurs de types au moment de la compilation.
N.B. : Pour les impatients, les codes source de l’article sont disponibles sous Github en Java 8 bien que les notions sont quasi valables en Java 7 et 6.
Les Annotations
L’idée consiste à développer une mini API qui analyse des annotations prédéfinies et génère des classes de sérialisation au moment de la compilation.
Pour ce, on aura à développer :
- 1. L’annotation @Jzon pour distinguer les classes à sérialiser :
@Target({TYPE, TYPE_USE})
@Retention(RetentionPolicy.CLASS)
public @interface Jzon {}
- 2. L’interface Writer : fonction qui génère la représentation Json d’un objet de type T :
@FunctionalInterface
public interface Writer<T> extends Function<T, String> {}
- 3. La classe JzonWriterFactory : fabrique permettant de générer un Writer pour une classe donnée :
public class JzonWriterFactory {
public static <T> Writer<T> writer(Class<T> clazz) { //TODO}
}
L’utilisation de cette librairie sera comme suit :
//Une classe Person annnotée par @Jzon
@Jzon
public class Person {
public String name = "slim";
public String surname = "ouertani";
}
//avoir une instance de writer
static Writer<Person> writer = JzonWriterFactory.writer(Person.class);
// jzonRepr est une sérialisation json de Person
// jzonRepr = {name: "slim",surname: "ouertani"}
String jzonRepr= writer.apply(new Person());
La Génération des sources
La classe JzonWriterFactory, est une fabrique qui instancie un Writer pour une classe particulière (Writer dans l’exemple précédent). Les Writers sont générés au moment de la compilation du code grâce à un processeur qui hérite de la classe abstraite AbstractProcessor défini par l’API “Pluggable Annotation Processor” (introduite à partir de Java 6 : JSR 269) et qui implémente la méthode suivante :
boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv)
La méthode process sera exécutée par le compilateur à plusieurs reprises. Chaque reprise traite les classes générées lors des phases précédentes. On définit notre générateur JzonAnnotationGenerator comme suit :
@SupportedAnnotationTypes({"org.technozor.jzon.Jzon" })
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class JzonAnnotationGenerator extends AbstractProcessor
-
SupportedAnnotationTypes : Pour indiquer au compilateur les annotations traitées par le processeur d’annotations (Jzon dans notre cas).
-
SupportedSourceVersion : Pour supporter les sources de la version Java 8.
Dans la méthode process ci-dessous, on récupère le Set d’éléments annotés par @Jzon. On transforme le Set en Stream pour enchaîner ensuite par le filtrage des classes annotées par @Jzon. Chaque élément de notre stream sera consommé par generateJsonclass pour générer les classes de sérialisation.
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
roundEnv.getElementsAnnotatedWith(org.technozor.jzon.Jzon.class)
.stream()
.filter(element -> element.getKind().isClass())
.map(x -> (TypeElement) x)
.forEach(this::generateJsonclass);
return false;
}
Pour l’implémentation du générateur de code, on a opté pour l’utilisation de la librairie javawriter qui est à la fois simple, intuitive et dotée d’une DSL fluente.
Une première implémentation de la méthode generateJsonclass est :
private void generateJsonclass(TypeElement clazz) {
String adapterName = new StringBuilder(clazz.getSimpleName()).append(JzonConstants.MAPPER_CLASS_SUFFIX).toString();
try {
JavaFileObject sourceFile = processingEnv.getFiler().createSourceFile(adapterName, clazz);
JavaWriter writer = new JavaWriter(sourceFile.openWriter());
// StringWriter stringWriter = new StringWriter();
// JavaWriter writer = new JavaWriter(stringWriter);
writer.emitSingleLineComment(JzonConstants.GENERATED_BY_JZON);
writer.emitPackage(toPackage.apply(clazz))
.beginType(toClass.apply(clazz, adapterName), "class", EnumSet.of(PUBLIC, FINAL))
.emitJavadoc("Returns the json representation.")
.beginMethod("String", "apply", EnumSet.of(PUBLIC), clazz.getQualifiedName().toString(), "p")
.emitStatement("return \"" + toParams.andThen(toJson).apply(clazz) + "\" ")
.endMethod()
.endType();
writer.close();
} catch (Exception e) {
warn.accept(e);
}
}
Donc, cette méthode va générer une classe qui aura comme nom « XXXJzonGeneratedClass » (avec XXX le nom de la classe à Sérialiser) ayant le contenu suivant :
public final class PersonJzonGeneratedClass implements org.technozor.jzon.Writer<Person> {
/**
* Returns the json representation.
*/
public String apply(Person p) {
return "{name: \"" + p.name+"\",surname: \"" + p.surname+"\"}" ;
}
}
// pour avoir le nom du package
Function<TypeElement, String> toPackage = x -> processingEnv.getElementUtils().getPackageOf(x).getQualifiedName().toString();
//On définit une fonction qui à partir de paramètres (TypeElement et un Suffix) et retourne le nom de classe de sérialisation, dans notre cas PersonJzonGeneratedClass implements org.technozor.jzon.Writer<Person> :
BiFunction<TypeElement, String, String> toClass = (x, y) -> new StringBuilder(toPackage.apply(x)).append(".").append(y).append(" implements org.technozor.jzon.Writer<").append(x.getQualifiedName()).append(">").toString();
// La classe ElementFilter pour sélectionner les attributs de classe à partir des éléments qu’elle enclose.
Function<TypeElement, List<? extends Element>> toParams = x -> ElementFilter.fieldsIn(x.getEnclosedElements());
// pour afficher des messages avec la sévérité warn
Consumer<Exception> warn = e -> processingEnv.getMessager().printMessage(Diagnostic.Kind.WARNING, e.getMessage());
Dans la méthode process, on s’est servi du filer pour ajouter un nouveau fichier source qui sera pris en compte par le compilateur dans la seconde phase de compilation.
processingEnv.getFiler().createSourceFile(adapterName, clazz)
Enfin, pour mettre notre processeur à disposition du compilateur, il faudra ajouter le fichier javax.annotation.processing.Processor sous le répertoire META-INF/services et référencer la classe :
org.technozor.jzon.internal.processor.JzonAnnotationGenerator
La factory
L’implémentation de la méthode writer nécessite le passage par le ClassLoader pour charger la classe en question :
public static <T> Writer<T> writer(Class<T> clazz) {
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
try {
String className = clazz.getCanonicalName() + JzonConstants.MAPPER_CLASS_SUFFIX;
Class<Writer<@Jzon T>> jzonImpl = (Class<Writer<@Jzon T>>) classLoader.loadClass(className);
MethodHandle constructor = MethodHandles.publicLookup().findConstructor(jzonImpl, methodType(Void.class));
return (Writer<T>) constructor.invoke();
} catch (Throwable throwable) {
return toRuntimeException (throwable);
}
}
La classe MethodHandles, est une classe utilitaire introduite à partir de Java 7, qui permet la recherche des méthodes d’une classe et retourne un objet directement exécutable de type MethodeHandle. Dans l’exemple ci-dessus, nous avons utilisé MethodHandles pour invoquer le constructeur par défaut de la classe Writer. La méthode toRuntimeException n’est utilisée que pour transformer l’exception et la rendre plus discrète.
@SuppressWarnings("unchecked")
static <T extends Throwable, V > Writer<V> toRuntimeException(Throwable throwable) throws T {
throw (T) throwable;
}
Le Processus de validation
Jusque là, on a créé une annotation personnalisée et on a défini une fabrique d’objets qui retourne une classe de sérialisation générée pendant la phase de compilation après introspection des types annotés par @Jzon. Cependant, pour une classe non annotée par @Jzon et qui sera utilisée par notre fabrique de writer, il faudra attendre jusqu’à l’exécution du code pour s'apercevoir que la classe de sérialisation n’existe pas et qu’on aura l’exception suivante : java.lang.ClassNotFoundException: CarJzonGeneratedClass.
public class Car {
public String power = "four";
}
static Writer<Car> carWriter = JzonWriterFactory.writer(Car.class);
Pour être en mesure de détecter ce problème au cours de la phase de compilation, on aura à développer un second processeur JzonAnnotationVerifier et le déclarer dans le fichier META-INF/javax.annotation.processing.Processor :
org.technozor.jzon.internal.processor.JzonAnnotationGenerator
org.technozor.jzon.internal.processor.JzonAnnotationVerifier
avec :
@SupportedAnnotationTypes({"*"})
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class JzonAnnotationVerifier extends AbstractProcessor {
public static Set<TypeElement> _notChecked = new HashSet<>();
private Consumer<TypeElement> error = (e) -> processingEnv.getMessager().printMessage(ERROR, e.getQualifiedName() + " must be annotated by @Jzon annotation !");
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
if (roundEnv.processingOver()) return false;
JzonAnnotationVisitor visitor = new JzonAnnotationVisitor(processingEnv);
roundEnv.getRootElements().forEach(visitor::scan);
_notChecked.forEach(error);
return true;
}
}
On a eu recours à la classe ElementScanner8. Cette classe permet plus de souplesse avec le pattern visiteur.
Avec JzonAnnotationVisitor, nous avons poussé la visite des variables (on a fait le choix de filtrer les variables de type Writer).
@Override
public Void visitVariable(VariableElement e, ProcessingEnvironment processingEnvironment) {
try {
TypeMirror typeMirror = e.asType();
Element asElement = typeUtils.asElement(typeMirror);
if (asElement.getKind().isInterface()
&& writerPredicate.test((TypeElement) asElement) == true
&& declaredPredicate.test(typeMirror)) {
DeclaredType dt = (DeclaredType) typeMirror;
dt.getTypeArguments()
.stream()
.map(typeUtils::asElement)
.filter(x -> x.getKind().isClass())
.map(castToTypeElement)
.filter(this::isJzonAnnotationPresent)
.forEach(_notChecked::add);
}
} catch (Exception ex) {}
return super.visitVariable(e, processingEnvironment);
}
De plus, pour vérifier la présence d’annotations au niveau d’une classe, on a utilisé la méthode isJzonAnnotationPresent :
boolean isJzonAnnotationPresent(TypeElement typeElement) {
return ! typeElement.getAnnotationMirrors()
.stream()
.map(AnnotationMirror::getAnnotationType)
.map(DeclaredType::asElement)
.filter(annotationPredicate)
.map(castToTypeElement)
.filter(jzonPredicate)
.findFirst()
.isPresent();
}
L’absence d’annotations dans les classes cibles est ainsi détectée et rajoutée dans le Set _notChecked.
Par conséquent, on aura un échec de compilation avec le message d’erreur ci dessous :
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.1:compile (default-compile) on project jzon.samples: Compilation failure
[ERROR] org.technozor.jzon.samples.Car must be annotated by @Jzon annotation !
[ERROR] -> [Help 1]
Conclusion
Tout au long de ce tutoriel, on a commencé à barboter dans la phase de compilation avec la génération de code source, la compilation et la validation de certains aspects par défaut non contrôlés par le compilateur.
Ensuite, l’API de sérialisation Json développée est très minimaliste rien que pour se focaliser sur le contrôle et la génération des parseurs tout au long de la phase de compilation.
Enfin, je vous invite à découvrir les coulisses des annotations processing avec en particulier le framework checker.
A propos de l'Auteur
Slim Ouertani est un Architecte logiciel avec une expérience dans le monde télécoms et systèmes d’information. Il a participé à la construction et la mise en place de plusieurs solutions notamment au sein de multinationales. Certifié Java, Spring et MongoDB, Slim est passionné par Scala et JEE.
Vous pouvez en savoir plus sur ses récents travaux sur son blog et le suivre sur Twitter : @ouertani.