Industrialiser Tiles grâce aux Annotation Processors !

Apache Tiles est un framework populaire permettant d'assembler des pages JSP à partir de fragments réutilisables (ex: entête, menu, pied de page...). Un fichier de configuration permet de décrire la composition de chaque page, et de lui assigner un nom logique. C'est ce même nom logique qui permet ensuite, depuis un contrôleur (Servlet pure, Spring MVC...), de demander à Tiles de construire et d'afficher la page correspondante.

Malheureusement, suite à des renommages - ou tout simplement par étourderie - il est facile de faire des erreurs dans les noms des vues au niveau des contrôleurs. Et l'erreur n'est visible qu'une fois l'application déployée...

Je vous propose ici une technique pour générer automatiquement des constantes Java correspondant aux noms des vues Tiles. Ainsi, plus de typos !

Les Annotation Processors à la rescousse !

Pour réaliser cela, nous allons utiliser un Annotation Processor, et évidemment une annotation personnalisée.

Pour rappel, un Annotation Processor peut être vu comme un plugin pour le compilateur, activé par la présence de certaines annotations dans le code source, et qui peut générer dynamiquement de nouvelles classes ou ressources.
Je vous invite à revoir ma conférence sur les annotations pour plus de détails sur la création d'annotations personnalisées, et sur les Annotation Processors.

L'annotation @GenerateTilesViewConstants

Pour déclencher le processus, il nous faut en premier lieu une annotation. La nôtre s'appellera, de manière assez explicite, @GenerateTilesViewConstants.

Comme notre objectif est de générer une nouvelle classe de constantes (ou plus exactement, un enum), il paraît astucieux de placer l'annotation sur son package de destination.
Et pour plus de flexibilité, le nom de classe et les chemins (relatifs au classpath) des fichiers de configuration Tiles à traiter peuvent être précisés en paramètre (des valeurs par défaut raisonnables sont fournies).

Voici le code de l'annotation :

package net.thecodersbreakfast.tiles;
 
@Target(ElementType.PACKAGE)
@Retention(RetentionPolicy.SOURCE)
public @interface GenerateTilesViewConstants {
 
    String generatedClassName() default "TilesViews";
    String[] tilesDefinitionPaths() default {"config/tiles-definitions.xml"};
 
}

L'Annotation Processor

Nous voulons partir d'un fichier de configuration Tiles comme ceci...

<?xml version="1.0" encoding="ISO-8859-1" ?>
<!DOCTYPE tiles-definitions PUBLIC
    "-//Apache Software Foundation//DTD Tiles Configuration 3.0//EN"
    "http://tiles.apache.org/dtds/tiles-config_3_0.dtd">
 
<tiles-definitions>
 
    <definition name="welcome" template="/WEB-INF/pages/welcome.jsp">
        <put-attribute name="title" value="Welcome" />
    </definition>
 
    <definition name="myAccount" template="/WEB-INF/pages/account.jsp">
        <put-attribute name="title" value="My account" />
    </definition>
 
    <definition name="error/tech" template="/WEB-INF/pages/error/tech.jsp">
        <put-attribute name="title" value="Erreur technique !" />
    </definition>
 
</tiles-definitions>

...pour arriver à un enum comme cela (notez la nécessaire transformation des noms des constantes) :

public enum TilesViews {
 
    WELCOME("welcome"),
    MY_ACCOUNT("myAccount"),
    ERROR_TECH("error/tech");
 
    private String viewName;
    private String getViewName() {
        return viewName;
    }
 
}

Pour obtenir ce résultat, notre Annotation Processor doit

  • indiquer qu'il réagit à l'annotation @GenerateTilesViewConstants
  • déterminer le nom complet de la classe à générer, en fonction du package sur lequel l'annotation est placée et de la valeur de ses paramètres
  • analyser les fichiers de configuration spécifiés pour identifier les noms des vues Tiles
  • générer une constante Java valide pour chaque vue (attention, certains caractères parfaitement valides dans les noms de vue Tiles sont interdits dans les noms de constantes Java !)
  • et enfin, créer l'enum grâce au Filer

Voici le code correspondant.
Il n'est pas très compliqué et suit le plan énoncé ci-dessus, je vous laisse donc en étudier les détails par vous-mêmes.

package net.thecodersbreakfast.tiles;
 
@SupportedSourceVersion(SourceVersion.RELEASE_6)
@SupportedAnnotationTypes(value = {"net.thecodersbreakfast.tiles.GenerateTilesViewConstants"})
public class GenerateTilesViewConstantsAnnotationProcessor extends AbstractProcessor {
 
    /** Utilitaire pour accéder au système de fichiers */
    private Filer filer;
 
    /** Utilitaire pour afficher des messages lors de la compilation */
    private Messager messager;
 
    /** Caractères marquant le début d'un nouveau mot */
    private Pattern p = Pattern.compile("[A-Z]");
 
    /**
     * Initialisation de l'Annotation Processor.
     * Permet surtout de récupérer des références vers le Filer et le Messager
     */
    @Override
    public void init(ProcessingEnvironment processingEnv) {
        super.init(processingEnv);
        messager = processingEnv.getMessager();
        filer = processingEnv.getFiler();
    }
 
    /**
     * Coeur de l'Annotation Processor
     */
    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
 
        // Récupération des packages annotés       
        Set<? extends Element> annotatedElements = roundEnv.getElementsAnnotatedWith(GenerateTilesViewConstants.class);
        for (Element annotatedPackage : annotatedElements) {
 
            // Détermination du nom complet (package + nom) de l'enum
            GenerateTilesViewConstants annotation = annotatedPackage.getAnnotation(GenerateTilesViewConstants.class);
            String className = annotation.generatedClassName();
            String packageName = annotatedPackage.toString();
 
            // Analyse des fichiers de configuration Tiles et récupération des noms des vues
            Set<String> viewNames = new HashSet<>();
            String[] tilesDefinitions = annotation.tilesDefinitionPaths();
            for (String tilesDefinition : tilesDefinitions) {
                viewNames.addAll(parseTilesDefinitionFile(tilesDefinition));
            }
            List<String> viewNamesList = new ArrayList<>(viewNames);
            Collections.sort(viewNamesList);
 
            // Génération de l'enum
            String sourceCode = generateSourceCode(className, packageName, viewNamesList);
            createNewJavaFile(className, packageName, sourceCode);
        }
        return true;
    }
 
    /**
     * Analyse des fichiers de configuration Tiles
     * La validation XML (namespaces, etc.) est volontairement désactivée
     */
    private List<String> parseTilesDefinitionFile(String tilesDefinitionPath) {
        try {
            FileObject tilesConfig = filer.getResource(StandardLocation.CLASS_PATH, "", tilesDefinitionPath);
            InputStream inputStream = tilesConfig.openInputStream();
 
            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
 
            // Désactivation de la validation XML
            factory.setValidating(false);
            factory.setFeature("http://xml.org/sax/features/validation", false);
            factory.setFeature("http://apache.org/xml/features/nonvalidating/load-dtd-grammar", false);
            factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
            factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
            factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
 
            DocumentBuilder builder = factory.newDocumentBuilder();
            org.w3c.dom.Document doc = builder.parse(inputStream);
 
            // Préparation du XPath pour rechercher les noms des vues
            XPathFactory xPathfactory = XPathFactory.newInstance();
            javax.xml.xpath.XPath xpath = xPathfactory.newXPath();
            XPathExpression expr = xpath.compile("/tiles-definitions/definition/@name");
            NodeList nl = (NodeList) expr.evaluate(doc, XPathConstants.NODESET);
 
            // Récupération des vues
            List<String> views = new ArrayList<>();
            for (int i = 0; i < nl.getLength(); i++) {
                Node item = nl.item(i);
                views.add(item.getNodeValue());
            }
 
            return views;
 
        } catch (IOException | ParserConfigurationException | SAXException | XPathExpressionException e) {
            messager.printMessage(Diagnostic.Kind.ERROR, e.getMessage());
            e.printStackTrace();
        }
        return Collections.emptyList();
    }
 
    /**
     * Génération du code source de l'enum
     */
    private String generateSourceCode(String className, String packageName, List<String> viewNames) {
        StringBuilder sourceCode = new StringBuilder(1000);
 
        // Entête de classe
        sourceCode.append("package " + packageName + ";\n\n");
        sourceCode.append("public enum " + className + " {\n");
 
        // Génération des constantes de l'enum
        boolean first = true;
        for (String viewName : viewNames) {
            if (!first) {
                sourceCode.append(",\n");
            }
            first = false;
            String constantName = computeEnumConstantName(viewName);
            sourceCode.append("\t" + constantName + "(\"" + viewName + "\")");
        }
        sourceCode.append(";\n\n");
 
        // Association de l'identifiant Tiles à la propriété "view"
        sourceCode.append("\tprivate " + className + "(String view) {\n\t\tthis.view = view;\n\t}\n\n");
        sourceCode.append("\tprivate String view;\n\n");
        sourceCode.append("\tpublic String getViewName() {\n\t\treturn view;\n\t}\n\n");
        sourceCode.append("\tpublic String toString() {\n\t\treturn view;\n\t}\n\n");
        sourceCode.append("}");
 
        return sourceCode.toString();
    }
 
 
    /**
     * Conversion d'un nom arbitraire de vue Tiles en un nom Java valide.
     * Chaque mot (dont le début est repéré grâce au Pattern vu plus haut) est séparé par un soulignement (_), 
     * les lettres sont mises en majuscule, et les caractères non alphanumériques sont remplacés par un soulignement.
     * Ex: errors/techError -> ERRORS_TECH_ERROR
     */
    private String computeEnumConstantName(String viewName) {
        Matcher m = p.matcher(viewName);
        StringBuffer sb = new StringBuffer();
        while (m.find()) {
            m.appendReplacement(sb, "_"+m.group());
        }
        m.appendTail(sb);
 
        return sb.toString().replaceAll("[^a-zA-Z0-9]","_").toUpperCase();
    }
 
    /**
     * Création (sur disque) du nouvel enum.
     */
    private void createNewJavaFile(String className, String packageName, String sourceCode) {
        try {
            JavaFileObject sourceFile = filer.createSourceFile(packageName + "." + className);
            Writer writer = sourceFile.openWriter();
            writer.write(sourceCode.toString());
            writer.close();
        } catch (IOException e) {
            e.printStackTrace();
            messager.printMessage(Diagnostic.Kind.ERROR, e.getMessage());
        }
    }
 
 
}

Déploiement

Pour que notre Annotation Processors soit automatiquement détecté et utilisé par le compilateur javac, le plus simple est de fournir un descripteur SPI :

Fichier META-INF/services/javax.annotation.processing.Processor :

net.thecodersbreakfast.tiles.GenerateTilesViewConstantsAnnotationProcessor

Pour terminer, il suffit de créer un jar contenant l'annotation, l'Annotation Processor et le répertoire META-INF contenant le descripteur SPI. Une fois placé dans le classpath de votre application Tiles, vous aurez accès à l'enum généré dans vos contrôleurs, et adieu les typos !

Un exemple d'utilisation avec Spring MVC :

public class MyController {
 
    @RequestMapping("/welcome")
    public String welcome() {
        return TilesViews.WELCOME.getViewName();
    }
 
}

Conclusion

En tirant parti de l'outillage du JDK, il est possible d'industrialiser, de séuriser et d'automatiser certains aspects du développement d'applications.

La technique présentée ici est facilement transposable à de nombreux autres cas d'utilisation. N'hésitez pas à expérimenter !


Commentaires

1. Le mercredi 20 novembre 2013, 11:01 par Nicolas

Oui, ce concept de génération de code avec les annotations est très pratique. Je l'ai utilisé plusieurs fois. Pour faire un peu plus propre dans la génération de code, tu peux aussi utiliser du Velocity avec son système de template.

2. Le lundi 26 mai 2014, 23:59 par Sebastien Lorber

Salut,

Simple curiosité mais pour la génération de l'enum, au lieu d'écrire la classe Java sur le disque comme tel (ou avec Velocity...), on a la possibilité de modifier le modele de classes a la compilation en utilisant les API?

Je les ai pas trop testées et c'est vrai que ces API sont quand meme assez compliquées à utiliser, mais je ne suis pas bien sur d'avoir compris: est-ce que les données qui en proviennent sont de la lecture seule? Ex est-ce qu'on peut modifier une List<TypeMirror> qui proviendrait du retour d'une methode? Est-ce qu'il y a des interfaces spécifiques a connaitre pour effectuer des modifications, ou alors il faut utiliser des classes propres au JDK utilisé?

En fait je cherche à faire le rapprochement avec les macros de Scala, qui permettent de modifier dynamiquement l'AST.

Voir: http://stackoverflow.com/questions/...

Ajouter un commentaire

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