4. La vue et son modèle
Jusqu'ici, les actions fabriquaient elles-mêmes leur réponse : du texte, du JSON, quelques balises HTML écrites dans une chaîne de caractères. Dans une application MVC, c'est le rôle d'une vue : un fichier qui décrit la page HTML, avec des emplacements pour les données que lui transmet l'action, le modèle de la vue. [Spring MVC] ne fournit pas lui-même de langage de vues : il sait travailler avec plusieurs moteurs de vues ([Thymeleaf], [FreeMarker], [Mustache], [JSP]...). Ce cours utilise [Thymeleaf], le moteur de vues le plus répandu avec [Spring MVC], que [Spring Boot] sait configurer tout seul.
Une vue [Thymeleaf] est un fichier [.html] ordinaire : un fichier HTML valide, que l'on peut ouvrir tel quel dans un navigateur. Les données y sont insérées par des attributs supplémentaires, préfixés par [th:] ([th:text], [th:if], [th:each]...), que [Thymeleaf] exécute sur le serveur, puis retire de la page. C'est ce que [Thymeleaf] appelle des modèles naturels ([natural templates]) : un graphiste peut travailler sur la vue sans lancer l'application, et le développeur y ajoute les attributs [th:]. C'est une différence avec [Razor], le moteur de vues d'[ASP.NET Core MVC], qui mélange HTML et code [C#] précédé du caractère [@].
4.1. Exemple 13 : une première vue
Pour utiliser [Thymeleaf], il suffit d'ajouter une dépendance au fichier [pom.xml] de l'exemple :
- lignes 3-6 : le starter [spring-boot-starter-thymeleaf]. Un starter est une dépendance « tout compris » : elle apporte [Thymeleaf], son intégration à [Spring] (la bibliothèque [thymeleaf-spring6]), et la configuration automatique de [Spring Boot] qui les relie à [Spring MVC]. Il n'y a rien d'autre à écrire : pas de version (elle est fixée par le projet parent [spring-boot-starter-parent]), pas de classe de configuration.
Le modèle de la vue est une simple classe. Le fichier [models/PageModele.java] :
- ligne 4 : un record [Java] (cf. exemple 03) est une classe dont les champs sont fixés, une fois pour toutes, par le constructeur : ici trois chaînes [titre], [message] et [heure]. Le compilateur écrit pour nous le constructeur et les accesseurs qui lisent les champs. Attention : ces accesseurs portent le nom du champ ([titre()]), et non [getTitre()] comme dans une classe [JavaBean] (cf. exemple 08). C'est une façon concise d'écrire une classe qui ne fait que transporter des données.
Le fichier [controllers/VuesController.java] :
Commentons ce code :
- ligne 17 : l'annotation [@Controller] remplace l'annotation [@RestController] des exemples précédents. Avec [@RestController], ce que rend une action est la réponse (du texte, du JSON). Avec [@Controller], une action qui rend une chaîne de caractères rend le nom d'une vue : c'est la vue qui fabriquera la réponse ;
- ligne 25 : l'action reçoit un paramètre de type [Model], fourni par [Spring MVC]. C'est le modèle de la vue : un dictionnaire, dont chaque entrée (un attribut) associe un nom à un objet ;
- lignes 26-30 : l'action fabrique un objet [PageModele] (un titre, un message, l'heure courante) et le range dans le modèle sous le nom [modele]. C'est sous ce nom que la vue le retrouvera ;
- ligne 31 : l'action rend le nom de la vue, [vues/index]. Elle ne fabrique plus la réponse : elle désigne une vue et lui a préparé ses données ;
- ligne 36 : l'action [bonjour] rend un objet [ModelAndView], qui réunit en un seul objet le nom de la vue et son modèle. C'est l'autre façon d'écrire une action qui utilise une vue ;
- ligne 38 : quand la vue dépend de la requête, l'action choisit la vue. Ici, le nom [inconnu] affiche la vue [vues/inconnu], avec le code 404 : le troisième argument du constructeur de [ModelAndView] est le code de la réponse. Le modèle de cette vue contient une simple chaîne, le nom demandé. [Map.of(...)] fabrique un dictionnaire non modifiable ;
- ligne 44 : les deux actions partagent la même vue [vues/index], avec des modèles différents.
Où [Spring MVC] cherche-t-il la vue [vues/index] ? Il confie le nom de la vue à un résolveur de vues ([ViewResolver]), qui le transforme en une vue concrète. Celui que configure [Spring Boot] pour [Thymeleaf] ([ThymeleafViewResolver]) ajoute au nom un préfixe, [classpath:/templates/], et un suffixe, [.html] : la vue [vues/index] est le fichier [templates/vues/index.html] du classpath, c'est-à-dire des dossiers et archives où la machine virtuelle [Java] cherche les classes et les ressources de l'application. Le dossier [src/main/resources] du projet en fait partie : son contenu est copié par [Maven] dans [target/classes], à côté des classes compilées. Ces deux valeurs peuvent être changées dans [application.properties] (propriétés [spring.thymeleaf.prefix] et [spring.thymeleaf.suffix]). Contrairement à [ASP.NET Core MVC], il n'y a pas de convention qui déduirait le nom de la vue du nom du contrôleur et de l'action : l'action donne toujours le chemin de la vue, relatif au dossier [templates]. Les fichiers du projet :
Le fichier [templates/vues/index.html] :
Commentons ce code :
- ligne 2 : un commentaire [Thymeleaf] s'écrit [<!--/* ... */-->]. C'est un commentaire HTML dont le contenu est encadré par [/*] et [*/] : [Thymeleaf] le retire de la page produite, alors qu'un commentaire HTML ordinaire [<!-- -->] serait envoyé au navigateur ;
- ligne 3 : l'attribut [xmlns:th] déclare l'espace de noms des attributs [th:]. Il ne sert qu'aux éditeurs de code (complétion, vérification) : [Thymeleaf] traite les attributs [th:] même sans lui, et le retire de la page produite ;
- ligne 6 : l'attribut [th:text] remplace le contenu de la balise par la valeur de l'expression. [${modele.titre}] est une expression de variable : [modele] est l'attribut du modèle déposé par l'action, [.titre] appelle son accesseur [titre()]. Le texte « Titre de la page » écrit dans la balise n'est qu'un exemple, affiché si l'on ouvre le fichier directement dans un navigateur ; il disparaît de la page produite ;
- lignes 9-11 : même principe pour le titre, le message et l'heure. Pour insérer une valeur au milieu d'un texte, on l'enveloppe dans une balise [<span>], qui porte l'attribut [th:text].
Les expressions [${...}] sont écrites dans le langage d'expressions de [Spring], [SpEL] ([Spring Expression Language]). [modele.titre] appelle la méthode [getTitre()] d'un [JavaBean], ou la méthode [titre()] d'un record : on écrit la même chose dans les deux cas.
Une vue [Thymeleaf] n'est pas compilée avec l'application : elle est lue et interprétée au moment où la page est demandée. Une erreur dans une vue n'est donc pas signalée à la compilation, mais à l'affichage de la page. Si l'on écrit [${modele.titr}] dans la vue, la requête échoue (code 500) et la console du serveur affiche : [Property or field 'titr' cannot be found on object of type 'exemple13.models.PageModele']. De même, un nom de vue qui ne correspond à aucun fichier provoque l'erreur [Error resolving template [vues/absente], template might not exist or might not be accessible by any of the configured Template Resolvers]. Le résultat, à l'URL http://localhost:5000/ :

Le code source de la page reçue par le navigateur ne contient plus aucune trace de [Thymeleaf] :
On remarque que :
- les attributs [th:text] ont disparu, remplacés par les valeurs du modèle, ainsi que l'attribut [xmlns:th] ;
- le commentaire [Thymeleaf] a disparu, mais il laisse une ligne vide ;
- les caractères accentués sont écrits tels quels ([première], [été]) : [Thymeleaf] n'échappe que les caractères qui ont un sens en HTML ([<], [>], [&], ["], [']). [Razor], lui, code par défaut les caractères accentués ([è] pour [è]), ce qui oblige le cours [ASP.NET Core MVC] à changer un réglage ; rien de tel ici.
L'URL http://localhost:5000/bonjour/martin affiche la même vue avec un autre modèle :

Dans le code HTML de cette page, l'apostrophe de « l'action » est devenue ['] : c'est l'un des caractères échappés par [th:text]. L'URL http://localhost:5000/bonjour/inconnu affiche une autre vue :
La réponse a bien le code 404 fixé par l'objet [ModelAndView] ([Tomcat] n'envoie que le code, sans son libellé [Not Found]). Le fichier [templates/vues/inconnu.html] :
- ligne 9 : un texte encadré par deux barres verticales [|...|] est une substitution littérale : les expressions [${...}] qu'il contient sont remplacées par leur valeur. C'est plus lisible qu'une suite de [<span>]. [${nom}] est l'attribut [nom] du modèle, une simple chaîne.
4.2. Exemple 14 : le langage Thymeleaf
Une vue [Thymeleaf] ne contient pas de code [Java] : tout passe par les attributs [th:] et par les expressions qu'ils contiennent. On y trouve pourtant des conditions, des boucles, des variables locales, des appels de méthodes. Cette puissance demande de la discipline : la vue doit rester une mise en forme ; les calculs et les décisions appartiennent à l'action et aux services. L'exemple 14 fait le tour de la syntaxe. Le modèle, fichier [models/BasesModele.java] :
- lignes 6-15 : le modèle est un record : quelques chaînes, un booléen, deux listes de produits et une adresse ;
- lignes 17-21 : deux records imbriqués dans le premier : [BasesModele.Produit] et [BasesModele.Adresse]. Un prix est un [BigDecimal], le type [Java] des nombres décimaux exacts (un [double] ne sait pas représenter exactement [0.1]).
Le fichier [controllers/BasesController.java] :
- lignes 19-31 : l'action remplit le modèle : quelques chaînes, un booléen, une liste de trois produits, un panier vide et une adresse. Le surnom (ligne 22) est volontairement [null]. [List.of(...)] fabrique une liste non modifiable ; [List.of()] une liste vide.
La vue, fichier [templates/bases/index.html] :
Commentons ce code :
- ligne 8 : l'attribut [th:with] déclare des variables locales, utilisables dans la balise qui le porte et dans toutes ses descendantes : ici [total] et [nombre], dans tout le corps de la page. [modele.produits.![prix]] est une projection [SpEL] : la liste des prix des produits. [#aggregates] et [#lists] sont des objets utilitaires de [Thymeleaf] (ils commencent par [#]) : [#aggregates.sum] fait la somme d'une liste de nombres, [#lists.size] compte les éléments d'une liste ;
- lignes 10-11 : un commentaire [Thymeleaf], absent de la page produite ;
- ligne 12 : une valeur [null] ([${modele.surnom}]) n'affiche rien, sans erreur ;
- lignes 14-15 : [th:text] échappe les caractères spéciaux du HTML ([<] devient [<]...) : le texte s'affiche tel quel. [th:utext] ([unescaped text]) l'insère sans échappement : il est interprété comme du HTML. On n'utilise [th:utext] que pour du HTML fabriqué par l'application, jamais pour une donnée saisie par un utilisateur : celui-ci pourrait injecter du HTML, voire du [JavaScript], dans la page (attaque dite [XSS], [cross-site scripting]) ;
- lignes 17-18 : une condition. [th:if] garde la balise (et son contenu) si l'expression est vraie, et la supprime sinon ; [th:unless] fait l'inverse. Il n'y a pas de [th:else] : on écrit la condition contraire ;
- ligne 22 : une boucle. L'attribut [th:each] répète la balise qui le porte, ici [<li>], une fois par élément de la liste. [produit] est l'élément courant ; [stat], facultatif, est l'état de l'itération : [stat.index] (à partir de 0), [stat.count] (à partir de 1), [stat.size], [stat.first], [stat.last], [stat.even], [stat.odd]. Dans le texte de la balise, [[[${produit.nom}]]] est une expression en ligne : elle insère la valeur, échappée comme avec [th:text], sans balise [<span>] (la forme [[(...)]] insère sans échappement, comme [th:utext]) ;
- ligne 23 : l'opérateur conditionnel [condition ? valeur1 : valeur2]. Les chaînes littérales s'écrivent entre apostrophes ;
- lignes 24-25 : [th:block] est une balise propre à [Thymeleaf] : elle ne sert qu'à porter des attributs [th:] et disparaît de la page produite, en ne laissant que son contenu. [stat.first] et [stat.last] testent la position dans la boucle ;
- lignes 30-31 : le cas de la liste vide. [#lists.isEmpty] teste si la liste est vide ; une boucle [th:each] sur une liste vide ne produit rien ;
- ligne 34 : une substitution littérale [|...|] qui utilise les variables de [th:with], et deux appels de méthodes : [total.doubleValue()] convertit le [BigDecimal] en [double], et [#numbers.formatDecimal(valeur, 1, 2)] formate un nombre avec au moins un chiffre avant la virgule et exactement deux après ;
- ligne 35 : un chemin pointé [modele.adresse.rue], et l'objet utilitaire [#strings], qui regroupe les opérations sur les chaînes ([toUpperCase], [isEmpty], [contains], [startsWith]...) ;
- ligne 36 : [Thymeleaf] ne traite que les attributs [th:] et les expressions en ligne [[[...]]] : le reste du texte est recopié tel quel. Le caractère [@] n'a ici aucun sens particulier.
Le résultat, à l'URL http://localhost:5000/ :

Le code HTML produit :
On remarque que :
- les balises [th:block], les commentaires [Thymeleaf] et les balises supprimées par [th:if] / [th:unless] laissent des lignes blanches, sans effet sur l'affichage ;
- le prix moyen est [62.17] pour [curl], [62,17] dans le navigateur. [#numbers.formatDecimal] utilise la langue de la requête. Par défaut, [Spring MVC] la lit dans l'entête HTTP [Accept-Language] envoyé par le navigateur : un navigateur réglé en français envoie [Accept-Language: fr-FR] et obtient une virgule ; [curl] n'envoie pas cet entête, et la langue est alors celle du système sur lequel tourne le serveur (ici l'anglais ; sur un poste réglé en français, [curl] obtiendrait lui aussi une virgule). L'exemple 16 et le chapitre « Internationalisation » reviennent sur ce mécanisme ;
- les prix eux-mêmes ([12.5]) sont écrits par la méthode [toString] de [BigDecimal], qui ne dépend pas de la langue. L'exemple 16 les met en forme.
Pour conclure cet exemple, voici les cinq sortes d'expressions de [Thymeleaf], dont les exemples de ce chapitre et du suivant se serviront :
Expression | Rôle | Exemple |
[${...}] | expression de variable ([SpEL]) : un attribut du modèle, une propriété, un appel de méthode, un bean ([${@nom...}]), un objet utilitaire ([${#strings...}]) | 13, 14, 16 |
[*{...}] | expression de sélection : une propriété de l'objet désigné par l'attribut [th:object] | 18, 19, 21 |
[#{...}] | message : un texte du fichier [messages.properties], dans la langue de la requête | chapitre suivant |
[@{...}] | lien : une URL, avec ses variables et ses paramètres | 16, 21 |
[~{...}] | fragment : un morceau de page à insérer | 15 à 21 |
Les objets utilitaires, eux, sont nombreux : [#strings], [#numbers], [#lists], [#aggregates], [#dates] (dates [java.util.Date]), [#temporals] (dates [java.time] : [LocalDate], [LocalDateTime]...), [#locale] (la langue de la requête), [#ids] et [#fields] (formulaires, exemples 18 et 19)... La documentation de [Thymeleaf] (https://www.thymeleaf.org) les décrit tous.
4.3. Exemple 15 : gabarit, fragments, fichiers statiques
Les pages d'un site partagent un gabarit commun (entête, barre de navigation, pied de page). Les morceaux de page réutilisables sont, pour [Thymeleaf], des fragments ; et le gabarit lui-même est un fragment, dans lequel chaque page insère son contenu. Enfin, les pages ont besoin de fichiers statiques (feuilles de style, images, scripts), servis tels quels, sans passer par un contrôleur. Les fichiers de l'exemple :
4.3.1. Les fichiers statiques
[Spring Boot] sert automatiquement, sans contrôleur, les fichiers du dossier [static] des ressources : le fichier [src/main/resources/static/css/styles.css] est servi à l'URL [/css/styles.css]. Si aucun fichier ne correspond à l'URL, et qu'aucune action ne la traite, la réponse est une 404. Vérifions :
L'entête [Last-Modified] donne la date de modification du fichier : le navigateur pourra la renvoyer pour demander le fichier seulement s'il a changé.
Le framework CSS [Bootstrap] est commun à tous les exemples : il est rangé une seule fois, dans le dossier [exemples/commun/bootstrap], hors des ressources des exemples. Il faut donc dire à [Spring MVC] où le trouver. Le fichier [config/WebConfig.java] :
Commentons ce code :
- ligne 8 : une classe de configuration (cf. exemple 06) ;
- ligne 9 : l'interface [WebMvcConfigurer] permet de compléter la configuration de [Spring MVC] faite par [Spring Boot], sans la remplacer : on redéfinit seulement les méthodes qui nous intéressent (toutes les autres ont une implémentation par défaut, vide) ;
- lignes 14-19 : la méthode [addResourceHandlers] déclare des dossiers de fichiers statiques. Les URL qui commencent par [/bootstrap/] (ligne 15 ; [**] désigne n'importe quelle suite de dossiers et de fichiers) sont servies à partir du dossier [commun/bootstrap] ;
- ligne 18 : le préfixe [file:] désigne un dossier du disque (et non du classpath). Le chemin est relatif au dossier courant, celui depuis lequel l'application a été lancée : [../commun/bootstrap/] si on la lance depuis le dossier de l'exemple, [commun/bootstrap/] si on la lance depuis le dossier [exemples]. Les deux emplacements sont essayés dans l'ordre. Ainsi, [exemples/commun/bootstrap/css/bootstrap.min.css] est servi à l'URL [/bootstrap/css/bootstrap.min.css]. L'étude de cas, elle, range [Bootstrap] dans son propre dossier [static].
Tous les exemples suivants ont ce fichier [WebConfig.java].
4.3.2. Le gabarit
Le fichier [templates/layout.html] :
Commentons ce code :
- ligne 4 : l'attribut [th:fragment] fait de la balise [<html>], et donc de toute la page, un fragment nommé [page], qui attend deux paramètres : [titre] et [contenu]. Les pages de l'application appelleront ce fragment en lui passant leur titre et leur contenu ;
- ligne 8 : le paramètre [titre] s'utilise comme un attribut du modèle ;
- lignes 9-10 : les feuilles de style, servies comme fichiers statiques ;
- ligne 15 : l'attribut [th:replace] remplace la balise qui le porte (ici [<nav>]) par un fragment. [~{fragments/navbar :: navbar}] est une expression de fragment : le fragment [navbar] du fichier [templates/fragments/navbar.html] (à gauche de [::], le nom de la vue ; à droite, le nom du fragment) ;
- ligne 17 : l'emplacement où est inséré le contenu de la page, reçu dans le paramètre [contenu] : ce paramètre est lui-même un fragment, que [th:replace] insère à la place de la balise [<main>] ;
- ligne 19 : [chemin] est le chemin de la page demandée. Ce n'est pas un paramètre du fragment : c'est un attribut ajouté au modèle de toutes les vues, par une classe que nous verrons plus loin. [#dates.createNow()] est la date courante, et [#dates.year] en extrait l'année.
Il existe un second attribut d'insertion, [th:insert], qui insère le fragment à l'intérieur de la balise hôte, au lieu de la remplacer. Avec un fragment [<span th:fragment="f">...</span>], l'écriture [<div th:replace="~{:: f}"></div>] produit [<span>...</span>], et [<div th:insert="~{:: f}"></div>] produit [<div><span>...</span></div>] ([~{:: f}], sans nom de vue, désigne un fragment de la vue courante). Les exemples de ce cours utilisent [th:replace].
Le fichier [templates/fragments/navbar.html] :
- ligne 2 : un fichier de fragments n'est pas une page complète : il contient une ou plusieurs balises qui portent un attribut [th:fragment]. Ce fragment-ci n'a pas de paramètres. On remarque que le fichier ne déclare pas l'espace de noms [xmlns:th] : ce n'est pas nécessaire.
Le fichier [templates/fragments/carte.html] :
- ligne 2 : le fragment [carte] a deux paramètres, un nom et une fonction ;
- lignes 4-5 : les paramètres s'utilisent comme des attributs du modèle.
4.3.3. Les pages
Le fichier [controllers/PagesController.java] :
- lignes 13-14 : le type des membres de l'équipe, un record déclaré dans le contrôleur ([PagesController.Membre]) ;
- lignes 17-19 : l'action [accueil] n'a pas de modèle : elle n'a pas besoin du paramètre [Model] ;
- lignes 22-28 : l'action [equipe] transmet une liste de membres.
Le fichier [templates/pages/accueil.html] :
Commentons ce code :
- ligne 4 : l'attribut [th:replace] de la balise [<html>] remplace toute la page par le fragment [page] du gabarit [layout.html]. Il lui passe deux paramètres : le titre, une chaîne littérale écrite entre apostrophes, et le contenu, [~{::main}]. Cette expression de fragment, sans nom de vue, désigne une balise de la vue courante : ici la balise [<main>] (lignes 6-12). Le gabarit est ainsi produit, avec, à la place de sa propre balise [<main>], celle de la page ;
- ligne 11 : l'appel du fragment [carte], avec deux paramètres écrits sur place.
Tout ce qui, dans la page, n'est pas dans la balise [<main>] (la balise [<body>], par exemple) disparaît : seul compte le fragment transmis au gabarit. Les balises [<html>] et [<body>] ne sont là que pour que le fichier reste une page HTML valide, que l'on peut ouvrir dans un navigateur.
Le fichier [templates/pages/equipe.html] :
- ligne 7 : la balise [<div class="col-md-4">] est répétée pour chaque membre ;
- ligne 9 : le fragment reçoit le nom et la fonction du membre courant : la balise qui porte [th:replace] est à l'intérieur de celle qui porte [th:each], où la variable [membre] est définie.
Le fichier [templates/pages/brute.html] :
- lignes 3-11 : cette vue n'appelle pas le gabarit : c'est une page complète, sans aucun attribut [th:]. [Thymeleaf] la recopie telle quelle (sauf le commentaire de la ligne 2). Rien n'oblige une vue à utiliser le gabarit.
Le code HTML de la page http://localhost:5000/equipe montre le résultat de l'assemblage :
Le titre, la barre de navigation, les trois cartes et le pied de page ont été assemblés à partir de quatre fichiers : [layout.html], [navbar.html], [equipe.html] et [carte.html].
4.3.4. Un attribut commun à toutes les vues
Le pied de page du gabarit affiche le chemin de la page demandée, [chemin]. Avec [Razor], une vue a directement accès à la requête en cours. Une vue [Thymeleaf] le pouvait aussi, jusqu'à la version 3.0, par l'objet [#request] : on aurait écrit [${#request.requestURI}]. Ce n'est plus le cas depuis la version 3.1. Une vue qui utilise [#request] provoque une erreur 500, et la console affiche :
Les objets [#request], [#session], [#servletContext] et [#response] ont été retirés : ils permettaient à une vue de lire n'importe quel paramètre de la requête, ce qui ouvrait la porte à des injections, et ils liaient [Thymeleaf] à l'API des servlets. L'écriture [${#ctx.request.requestURI}] ne marche pas non plus : [#ctx.request] vaut [null]. Le message d'erreur donne la solution : ajouter au modèle ce dont la vue a besoin. Pour que cela vaille pour toutes les vues, sans que chaque action ait à le faire, on écrit le fichier [config/ModeleCommun.java] :
Commentons ce code :
- ligne 9 : l'annotation [@ControllerAdvice] désigne une classe qui complète tous les contrôleurs de l'application. Ce qu'elle déclare s'applique à chacun d'eux, comme si c'était écrit dans chacun ;
- ligne 14 : une méthode annotée par [@ModelAttribute("chemin")] est exécutée avant chaque action ; ce qu'elle rend est ajouté au modèle sous le nom [chemin]. Écrite dans un contrôleur, elle concerne les actions de ce contrôleur (cf. exemple 18) ; écrite dans une classe [@ControllerAdvice], elle concerne toutes les actions de l'application ;
- lignes 15-16 : la méthode peut recevoir les mêmes paramètres qu'une action : ici la requête en cours, [HttpServletRequest] (cf. exemple 05). [getRequestURI()] rend le chemin demandé, sans les paramètres.
L'étude de cas utilise le même procédé (son fichier [web/ModeleCommun.java]) pour fournir à toutes ses vues le chemin, la langue et l'utilisateur connecté.
Les pages obtenues, aux URL http://localhost:5000/, http://localhost:5000/equipe et http://localhost:5000/brute :



Le gabarit, les fragments et l'attribut commun [chemin] sont exactement ce qu'utilise l'étude de cas : un gabarit [layout.html], des fragments pour la barre de navigation, les messages, les fenêtres de confirmation.
4.4. Exemple 16 : les helpers
Une vue a souvent besoin de petites fonctions de mise en forme : formater une date ou un prix, mettre un texte en majuscules, fabriquer un badge HTML... On les appelle des helpers (des aides). Avec [Thymeleaf], ce sont des méthodes de beans [Spring], appelées dans une expression, ou des fragments. [Thymeleaf] permet en outre de créer ses propres attributs, rassemblés dans un dialecte.
4.4.1. Un bean appelé par les vues
Le fichier [aides/Formats.java] :
Commentons ce code :
- ligne 16 : l'annotation [@Component] fait de la classe un bean : [Spring] la trouve au démarrage (comme il trouve les contrôleurs), en crée une instance et la garde. ["formats"] est le nom du bean : c'est sous ce nom que les vues l'appelleront ;
- lignes 20-22 : la mise en majuscules. [Locale.ROOT] rend le résultat indépendant de la langue de l'utilisateur ;
- lignes 25-31 : un montant dans une devise, au format de la langue de la requête. [LocaleContextHolder.getLocale()] (ligne 27) rend cette langue, un objet [Locale] fixé par [Spring MVC] au début de chaque requête. [NumberFormat.getCurrencyInstance] fournit le format monétaire de cette langue : séparateur décimal, séparateur des milliers, place du symbole ; [setCurrency] choisit la devise, désignée par son code international ([EUR], [USD]) ;
- lignes 34-36 : une surcharge de la méthode : même nom, autres paramètres. Sans devise, c'est l'euro ;
- lignes 39-41 : une date en toutes lettres ([FormatStyle.FULL]), dans la langue de la requête.
La page [templates/pages/produits.html] appelle ces méthodes :
- lignes 10-12 : dans une expression [${...}], la notation [@formats] désigne le bean de nom [formats] : [${@formats.monnaie(produit.prix)}] appelle sa méthode [monnaie]. Ce que rend la méthode est inséré par [th:text], donc échappé ;
- ligne 14 : le badge est fabriqué par un fragment, décrit ci-dessous.
Le fichier [templates/fragments/badge.html] :
- ligne 3 : un helper qui fabrique du HTML est, avec [Thymeleaf], un fragment : ici une balise [<span>] qui reçoit en paramètre le stock du produit ;
- ligne 4 : l'attribut [th:classappend] ajoute une classe CSS à celles de la balise ([badge]) : verte ([text-bg-success]) si le produit est en stock, rouge sinon ;
- ligne 5 : le texte du badge, échappé par [th:text]. Contrairement à un helper qui fabriquerait du HTML dans une chaîne de caractères, un fragment n'a pas à se soucier de l'échappement : [th:text] s'en charge.
Le contrôleur, fichier [controllers/PagesController.java] :
- ligne 19 : la page d'accueil reçoit la date du jour, un [LocalDate] (une date sans heure) ;
- lignes 25-28 : la liste des produits.
Le fichier [templates/pages/accueil.html] :
- ligne 7 : l'appel de la méthode [date] du bean [formats], dans une substitution littérale ;
- ligne 9 : l'objet utilitaire [#locale] est la langue de la requête.
4.4.2. La langue de la requête
Qui fixe la langue de la requête ? Un bean de [Spring MVC], le résolveur de langue ([LocaleResolver]). Celui que [Spring Boot] installe par défaut, [AcceptHeaderLocaleResolver], lit l'entête [Accept-Language] envoyé par le navigateur : c'est lui qui donnait des résultats différents au navigateur et à [curl] dans l'exemple 14. Pour pouvoir tester les deux langues facilement, l'exemple 16 le remplace par un résolveur qui lit le paramètre [lang] de l'URL. Le fichier [config/WebConfig.java] :
Commentons ce code :
- lignes 21-27 : les fichiers de [Bootstrap], comme dans l'exemple 15 ;
- lignes 30-33 : notre dialecte [Thymeleaf], décrit plus loin, déclaré comme bean. [Spring Boot] ajoute automatiquement au moteur [Thymeleaf] tous les beans qui sont des dialectes ;
- lignes 38-41 : notre résolveur de langue. Le bean doit s'appeler [localeResolver] : c'est sous ce nom que [Spring MVC] le cherche. Une méthode [@Bean] donne son nom au bean : [localeResolver()]. En déclarant ce bean, on remplace celui de [Spring Boot] ;
- lignes 45-56 : le résolveur est une classe imbriquée qui implémente l'interface [LocaleResolver] ;
- lignes 48-50 : la méthode [resolveLocale] est appelée au début de chaque requête : elle rend l'anglais des États-Unis ([Locale.US]) si l'URL contient [lang=en], le français de France ([Locale.FRANCE]) sinon. [Spring MVC] range cette langue dans [LocaleContextHolder], où la retrouvent le bean [formats] et les objets utilitaires de [Thymeleaf] ;
- lignes 53-55 : la méthode [setLocale] permettrait de changer la langue (en la mémorisant, par exemple, dans un cookie). Ici, la langue vient de l'URL : la méthode refuse de travailler en levant une exception.
C'est une façon simple de tester ; le chapitre suivant fera mieux.
4.4.3. Un dialecte Thymeleaf
La barre de navigation met en évidence le lien de la page courante (classe CSS [active] de [Bootstrap]). Le fichier [templates/fragments/navbar.html] montre trois façons d'écrire les liens :
Commentons ce code :
- ligne 3 : l'espace de noms [xmlns:ex], comme [xmlns:th], ne sert qu'aux éditeurs ;
- ligne 7 : première façon de mettre en évidence le lien de la page courante : une condition écrite dans la vue, avec l'attribut [chemin] fourni par la classe [ModeleCommun] (identique à celle de l'exemple 15). Sans partie « sinon », l'opérateur conditionnel ne produit rien quand la condition est fausse, et [th:classappend] n'ajoute alors aucune classe. C'est la façon qu'utilise l'étude de cas ;
- ligne 9 : deuxième façon, plus concise : un attribut « maison », [ex:actif], apporté par notre dialecte ;
- ligne 11 : une expression de lien [@{...}]. [Spring] fabrique l'URL : les parenthèses contiennent des paramètres, ajoutés à l'URL ([?lang=en]). Nous verrons à l'exemple 21 qu'elles servent aussi à remplir les variables d'un chemin. Une expression de lien ajoute en outre, si besoin, le chemin de base de l'application quand celle-ci n'est pas installée à la racine du serveur.
Un dialecte est un ensemble d'attributs (ou de balises) qui partagent un préfixe : les attributs [th:] sont ceux du dialecte standard de [Thymeleaf]. Chaque attribut est traité par un processeur. Le fichier [aides/ActifDialect.java] :
- ligne 12 : un dialecte qui ne fournit que des processeurs dérive de la classe [AbstractProcessorDialect] ;
- ligne 16 : le nom du dialecte, le préfixe de ses attributs ([ex]) et sa priorité : la même que celle des attributs [th:] ;
- lignes 21-23 : les processeurs du dialecte : un seul, celui de l'attribut [ex:actif]. [Set.of(...)] fabrique un ensemble non modifiable.
Le fichier [aides/ActifProcessor.java] :
Commentons ce code :
- ligne 13 : un processeur d'attribut dérive de la classe [AbstractAttributeTagProcessor] ;
- ligne 18 : son constructeur décrit ce qu'il traite : le mode HTML, le préfixe du dialecte, les balises [<a>] seulement, l'attribut [actif] (préfixé : [ex:actif]), une priorité, et [true] : l'attribut [ex:actif] sera retiré de la page produite ;
- lignes 23-24 : la méthode [doProcess] est appelée pour chaque balise [<a>] qui porte l'attribut [ex:actif]. Elle reçoit le contexte de la vue, la balise, et la valeur de l'attribut ; l'objet [structure] permet de modifier la balise ;
- ligne 26 : le processeur est du code [Java], et non une expression de vue : il a le droit de lire la requête en cours. Quand la vue est produite pour une requête web, le contexte est un [IWebContext], qui donne accès à l'échange (la requête et sa réponse) ; [getRequestPath()] rend le chemin demandé. L'écriture [contexte instanceof IWebContext web] teste le type du contexte et, s'il convient, le range dans la variable [web] ;
- lignes 27-32 : si le chemin de la page commence par la valeur de l'attribut, on ajoute la classe [active] à l'attribut [class] de la balise.
Le code HTML de la barre de navigation, à l'URL [/produits] :
Le lien « Produits » a reçu la classe [active], et l'attribut [ex:actif] a disparu. L'expression de lien a produit [/produits?lang=en]. On remarque que [Thymeleaf] a retiré l'attribut [xmlns:th], mais pas l'attribut [xmlns:ex] : il ne connaît pas ce dernier. On aurait pu le laisser de côté, puisqu'il ne sert qu'aux éditeurs.
Le gabarit [layout.html] est celui de l'exemple 15, sans le pied de page (et le titre des pages se termine par [- Helpers] au lieu de [- Mon site]). Les pages obtenues, en français (http://localhost:5000/, http://localhost:5000/produits) puis en anglais (http://localhost:5000/produits?lang=en) :



Les prix changent de format avec la langue : [1 149,90 €] en français, [€1,149.90] en anglais. En français, les dollars sont notés [$US], pour les distinguer des autres dollars (canadiens, australiens...). Le code HTML du tableau, en français :
et en anglais ([/produits?lang=en]), où le dollar s'écrit simplement [$] :
Seuls les montants ont changé : le texte des badges est écrit en dur dans le fragment (le chapitre suivant traduira les textes).
4.5. Exemple 17 : un formulaire GET
Un formulaire HTML envoyé par la méthode GET place ses champs dans l'URL. C'est la méthode qui convient à une recherche : elle ne modifie rien, et l'URL obtenue peut être mise en favori ou partagée. L'étude de cas l'utilise pour choisir un médecin et un jour.
Les fichiers [models/Livre.java] et [models/RechercheModele.java] :
- ligne 6 : le modèle de la vue contient les valeurs saisies ([motCle], [genre]), pour que le formulaire les réaffiche, la liste des genres, et les résultats.
Le fichier [controllers/LivresController.java] :
Commentons ce code :
- lignes 17-24 : les livres et les genres, en mémoire ;
- ligne 27 : une même action peut répondre à plusieurs chemins : elle traite [GET /] et [GET /recherche] ;
- lignes 28-29 : les paramètres [motCle] et [genre] sont liés aux paramètres de même nom de l'URL (cf. exemple 07). [required = false] les rend facultatifs : à la première visite, le formulaire n'a pas encore été envoyé, et ils valent [null] ;
- lignes 31-35 : le filtrage, avec un flux ([stream]) [Java] (cf. exemple 06) : [stream()] parcourt la liste, chaque [filter] ne garde que les livres qui satisfont une condition, et [toList()] rassemble les livres retenus dans une nouvelle liste. Les conditions sont des lambdas : [l -> ...] est une fonction anonyme qui reçoit un livre [l] et rend un booléen. Un mot-clé vide ou un genre vide ne filtrent rien ;
- lignes 36-37 : le modèle de la vue. On y remplace les [null] par des chaînes vides.
Le gabarit [templates/layout.html] est simplifié : un seul paramètre, le contenu de la page, et un titre fixe :
- ligne 2 : le fragment [page] n'a plus qu'un paramètre, [contenu] ;
- ligne 5 : le titre est le même pour toutes les pages.
Le fichier [templates/livres/recherche.html] :
Commentons ce code :
- ligne 2 : la page passe au gabarit son seul paramètre, sa balise [<main>] ;
- ligne 7 : [method="get"] et [action="/recherche"] : l'envoi du formulaire est une requête [GET /recherche?motCle=...&genre=...]. Le nom des paramètres est l'attribut [name] de chaque champ ;
- ligne 11 : l'attribut [th:value] écrit l'attribut [value] du champ : il réaffiche la saisie. D'une façon générale, [th:xxx] calcule la valeur de l'attribut HTML [xxx] : [th:value], [th:href], [th:action], [th:class]...
- lignes 18-19 : une option par genre. Sur la même balise, [th:each] est traité en premier, puis [th:value] et [th:text], qui utilisent la variable de la boucle. [th:selected] est un attribut booléen : si l'expression est vraie, [Thymeleaf] écrit [selected="selected"] ; si elle est fausse, il n'écrit pas l'attribut du tout. C'est exactement ce que demande le HTML, pour qui la seule présence de l'attribut [selected] suffit à sélectionner l'option. Il en va de même pour [th:checked], [th:disabled], [th:required], [th:readonly]... ;
- ligne 26 : le nombre de résultats ;
- ligne 28 : la liste des résultats, avec une balise [<i>] et deux expressions en ligne.
Le code HTML de la liste déroulante, après une recherche du genre [poésie] :
Au premier affichage (http://localhost:5000/), puis après une recherche :


La barre d'adresse montre les paramètres envoyés. Les caractères accentués y sont codés ([%C3%A9] pour [é]) : c'est l'encodage URL, que [Spring MVC] décode avant la liaison des paramètres.
4.6. Exemple 18 : les champs d'un formulaire
L'exemple 18 réunit tous les types de champs HTML : texte, mot de passe, date, boutons radio, cases à cocher, liste déroulante, liste à sélection multiple, zone de texte, champ caché. Il montre comment le serveur les reçoit et comment la vue les réaffiche. Il présente aussi l'attribut [th:field], qui relie un champ du formulaire à une propriété d'un objet du modèle.
Le fichier [services/ListesService.java] :
- ligne 11 : l'annotation [@Service] fait de la classe un bean, comme [@Component] (exemple 16) : [Spring] la trouve au démarrage et en crée une instance unique, un singleton (cf. exemple 06), injectable dans les contrôleurs. [@Service] ne diffère de [@Component] que par l'intention qu'elle exprime : la classe est un service. Les listes proposées par le formulaire sont les mêmes pour tous les utilisateurs : un singleton est l'endroit naturel pour les garder ;
- lignes 14-15 : un pays : un code et un nom ;
- lignes 17-32 : les listes, rendues par des méthodes [getXxx()]. Pour une vue, ce sont les propriétés [titres], [pays], [loisirs] et [langues] du bean : [${listes.titres}] appellera [getTitres()].
Le fichier [models/Saisie.java] :
Commentons ce code :
- lignes 13-23 : la saisie est une classe [JavaBean] (des champs privés, un constructeur sans paramètre, des accesseurs [getXxx] et [setXxx]), et non un record : la liaison de modèle la crée vide, puis remplit chaque propriété avec le paramètre de même nom, par son accesseur [setXxx] ;
- lignes 17 et 19 : les cases à cocher et la liste multiple sont des listes : elles reçoivent toutes les valeurs envoyées (cf. exemple 10). Elles sont initialisées à une liste vide, jamais [null] ;
- lignes 21-22 : un champ [<input type="date">] envoie la date au format [ISO 8601] [aaaa-mm-jj]. L'annotation [@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)] indique ce format à [Spring], qui s'en sert dans les deux sens : pour convertir le texte reçu en [LocalDate], et pour réécrire la date dans le champ.
Le fichier [controllers/FormulaireController.java] :
Commentons ce code :
- lignes 22-26 : le service des listes est injecté par le constructeur (cf. exemple 06) ;
- lignes 30-33 : une méthode annotée par [@ModelAttribute("listes")], déclarée dans un contrôleur, est exécutée avant chaque action de ce contrôleur : les deux actions trouveront l'attribut [listes] dans leur modèle, sans avoir à l'y mettre (cf. [ModeleCommun], exemple 15, pour toutes les actions de l'application) ;
- lignes 36-45 : [GET /formulaire] affiche le formulaire avec des valeurs initiales. L'objet est rangé dans le modèle sous le nom [saisie] ;
- ligne 50 : [POST /formulaire] reçoit la saisie. L'annotation [@ModelAttribute], sur un paramètre, demande à la liaison de modèle de fabriquer l'objet à partir des paramètres de la requête (c'est le comportement par défaut pour un paramètre de type classe, cf. exemple 08), et de le ranger dans le modèle de la vue. Sans nom explicite, le nom de l'attribut est celui de la classe, avec une minuscule initiale : [saisie]. Le formulaire est réaffiché avec les valeurs reçues ;
- lignes 52-56 : [getParameterMap()] donne en plus accès aux paramètres tels qu'ils ont été envoyés : un dictionnaire dont chaque valeur est un tableau de chaînes (un paramètre peut être répété). On en fait un texte, une ligne par paramètre, rangé dans le modèle sous le nom [recu]. [Collectors.joining("\n")] assemble les lignes, séparées par un saut de ligne.
Le gabarit [templates/layout.html] est celui de l'exemple 17 (seul le titre change). Le fichier [templates/formulaire/formulaire.html] :
Commentons ce code :
- ligne 7 : l'attribut [th:object] désigne l'objet du formulaire : l'attribut [saisie] du modèle. À l'intérieur de la balise, une expression de sélection [*{nom}] désigne la propriété [nom] de cet objet : c'est l'équivalent de [${saisie.nom}] ;
- ligne 9 : l'attribut [th:field="*{jeton}"] relie le champ à la propriété [jeton] : il écrit les attributs [id] et [name] (le nom de la propriété) et l'attribut [value] (sa valeur). Un champ caché est envoyé avec le formulaire mais n'est pas affiché. Il sert à transporter une information d'une page à la suivante (l'étude de cas y met, par exemple, la version d'une fiche). Attention : l'utilisateur peut le modifier ;
- lignes 14-18 : des boutons radio portent le même [name] : un seul peut être coché, et une seule valeur est envoyée. Pour un bouton radio, [th:field] n'écrit pas la valeur de la propriété dans [value] (c'est [th:value] qui donne la valeur du bouton) : il coche le bouton si sa valeur est égale à la propriété. Comme les boutons ont tous le même nom, [th:field] leur donne des [id] numérotés : [titre1], [titre2]... L'objet utilitaire [#ids.prev('titre')] (ligne 17) rend l'[id] donné au dernier champ [titre], pour l'attribut [for] de son libellé ;
- ligne 25 : un champ texte : [th:field] écrit [id="nom"], [name="nom"] et la valeur. Le libellé de la ligne 23 peut donc écrire [for="nom"] en dur ;
- ligne 30 : un mot de passe n'est jamais renvoyé dans une page : le champ n'utilise pas [th:field] et n'a pas d'attribut [value] ;
- ligne 35 : un champ date. Le navigateur affiche la date dans son propre format, mais envoie et reçoit toujours [aaaa-mm-jj] ; [th:field] écrit la date dans ce format, grâce à l'annotation [@DateTimeFormat] ;
- lignes 42-45 : des cases à cocher de même [name] : chaque case cochée envoie sa valeur, et le paramètre est donc répété. [th:field] coche la case si sa valeur appartient à la liste [loisirs] ;
- lignes 51-53 : une liste déroulante. [th:field] sur la balise [<select>] sélectionne l'option dont la valeur est égale à la propriété [pays] ;
- lignes 58-60 : l'attribut [multiple] permet de choisir plusieurs options : [th:field] sélectionne toutes celles qui appartiennent à la liste [langues] ;
- ligne 67 : la valeur d'une zone de texte est entre ses balises, pas dans un attribut [value] : [th:field] le sait ;
- lignes 73-76 : ce que le serveur a reçu, affiché seulement après un envoi, dans une balise [<pre>], qui respecte les sauts de ligne.
Voici un extrait du code HTML produit par ces attributs [th:field] (le champ caché, le deuxième bouton radio, les deux premières cases à cocher, la liste multiple) :
On y remarque des champs cachés que nous n'avons pas écrits : [_loisirs] après chaque case à cocher, [_langues] avant la liste multiple. Ils résolvent un problème du HTML : une case non cochée n'est pas envoyée du tout. Si l'utilisateur décoche toutes les cases, le paramètre [loisirs] est absent de la requête, et la liaison de modèle ne toucherait pas à la propriété [loisirs], qui garderait son ancienne valeur. Le marqueur [_loisirs], lui, est toujours envoyé : quand [Spring] le trouve sans le paramètre [loisirs], il vide la liste (ou met à [false] un booléen). Ici, la saisie est un objet neuf, dont la liste est déjà vide, et la différence ne se voit pas ; elle compte quand l'objet du formulaire a été rempli auparavant (par exemple, conservé en session, cf. chapitre « Portées des données »).
Le formulaire initial (http://localhost:5000/formulaire), puis après envoi :


On remarque que :
- la zone de texte a réaffiché [Bonjour <b>tout</b> le monde] tel quel : [th:field] échappe la valeur, et les balises saisies ne sont pas interprétées ;
- le mot de passe a été reçu mais n'est pas réaffiché ;
- le navigateur a envoyé les marqueurs [_loisirs] (un par case) et [_langues].
Avec [curl], on voit la différence entre une case cochée :
et deux cases cochées :
(seule la balise [<pre>] de la page est reproduite). L'option [-d] de [curl] envoie une requête POST, avec ce corps, et l'entête [Content-Type: application/x-www-form-urlencoded] d'un formulaire HTML.
4.7. Exemple 19 : la validation d'un formulaire
Un formulaire invalide doit être réaffiché, avec les valeurs saisies et un message sous chaque champ erroné. L'exemple 19 met en œuvre la technique de l'exemple 09 (validation faite par l'action, avec un paramètre [BindingResult]) sur un formulaire d'inscription, et montre comment [Thymeleaf] affiche les erreurs.
La validation demande une dépendance supplémentaire. Le fichier [pom.xml] :
- lignes 7-11 : le starter de la validation : l'API [Bean Validation] (les annotations [@NotBlank], [@Size]...) et son implémentation, [Hibernate Validator] (cf. exemple 08).
Le fichier [models/InscriptionModele.java] :
Commentons ce code :
- lignes 16-18 : le nom est obligatoire ([@NotBlank] refuse [null], une chaîne vide et une chaîne faite d'espaces) et limité à 20 caractères ;
- lignes 20-22 : [@Email] vérifie la forme d'une adresse électronique ;
- lignes 25-28 : l'âge est converti en entier, puis validé. Le type [Integer] (et non [int]) permet la valeur [null] : un âge absent reste [null], et [@NotNull] le signale ;
- lignes 30-32 : au moins 6 caractères. [@Size] ne vérifie pas une valeur [null] : c'est le rôle de [@NotBlank] ;
- lignes 38-41 : la règle « la confirmation est égale au mot de passe » porte sur deux champs. [Bean Validation] n'a pas d'annotation toute faite pour cela ; le plus simple est une méthode booléenne qui fait la comparaison, annotée par [@AssertTrue] : la contrainte est violée si la méthode rend [false]. Pour [Bean Validation], une méthode [isXxx()] est l'accesseur d'une propriété booléenne : l'erreur éventuelle sera donc attachée à la propriété [confirmationValide]. [Objects.equals] compare deux objets, même [null].
Le fichier [controllers/InscriptionController.java] :
Commentons ce code :
- lignes 25-28 : une méthode annotée par [@InitBinder] configure la liaison de modèle des actions de ce contrôleur. Elle reçoit l'objet [WebDataBinder] qui fait la liaison. On y enregistre un éditeur de propriétés pour le type [String] : [StringTrimmerEditor] retire les espaces au début et à la fin de chaque chaîne reçue ; avec l'argument [true], une chaîne vide devient [null]. Un nom saisi [" Martin "] devient ["Martin"]. Attention : cela vaut pour toutes les chaînes, mots de passe compris ;
- lignes 31-36 : le formulaire vide. Le paramètre [html5] sera expliqué plus loin. L'objet du formulaire est rangé dans le modèle sous le nom [inscription] ;
- ligne 40 : [@Valid] demande la validation de l'objet ; [@ModelAttribute("inscription")] le range dans le modèle sous le nom [inscription], le même qu'à la ligne 33, celui qu'utilise la vue. Le paramètre [BindingResult] doit suivre immédiatement l'objet validé : il reçoit les erreurs de liaison et de validation, et l'action est alors toujours exécutée (cf. exemple 09) ;
- ligne 41 : [@RequestParam] lit un paramètre de l'URL ou du corps de la requête : ici, [html5] vient de l'URL du formulaire ;
- lignes 42-50 : s'il y a des erreurs, on réaffiche le formulaire, avec les valeurs saisies, sauf les mots de passe, et un message global, rangé dans le modèle pour le gabarit. Il n'y a rien d'autre à faire : l'objet [inscription] et ses erreurs (le [BindingResult]) sont déjà dans le modèle, où la vue les trouvera ;
- ligne 51 : sinon, la page de bienvenue, qui utilise elle aussi l'objet [inscription] du modèle.
Une erreur de conversion (un âge [abc]) produit par défaut un message en anglais, peu lisible : [Failed to convert property value of type java.lang.String to required type java.lang.Integer for property age; For input string: "abc"]. Le fichier [messages.properties] le remplace :
- ligne 3 : [Spring Boot] lit automatiquement le fichier [messages.properties] des ressources : c'est le fichier des messages de l'application (le chapitre suivant le déclinera en plusieurs langues). Pour une erreur de conversion, [Spring] cherche un message sous plusieurs clés, de la plus précise à la plus générale : [typeMismatch.inscription.age] (l'objet et sa propriété), [typeMismatch.age] (la propriété), [typeMismatch.java.lang.Integer] (le type attendu), enfin [typeMismatch]. La première trouvée l'emporte ; à défaut, c'est le message anglais. Dans ce fichier, l'apostrophe se double ([n''est]) : les messages sont traités par la classe [MessageFormat] de [Java], pour qui l'apostrophe simple est un caractère spécial. L'étude de cas remplace ainsi tous ces messages.
4.7.1. Un fragment de champ
Les champs sont tous construits sur le même modèle : un libellé, un champ, un message d'erreur. Un fragment les factorise. Le fichier [templates/fragments/champ.html] :
Commentons ce code :
- ligne 5 : le fragment [champ] est déclaré sans signature : sans liste de paramètres. On l'appellera avec des paramètres nommés ([champ(nom='age', libelle='Âge', ...)]), qui deviennent des variables du fragment ; un paramètre non fourni vaut simplement [null]. C'est pratique pour un fragment qui a beaucoup de paramètres facultatifs. [th:with] déclare la variable [cle] : le nom de la propriété dont on affiche l'erreur. L'opérateur [?:] (dit « Elvis ») rend son opérande de gauche s'il n'est pas [null], celui de droite sinon : [erreurDe] s'il est fourni, [nom] sinon ;
- ligne 9 : [th:type] écrit l'attribut [type]. [th:field] doit recevoir le nom de la propriété, qui est dans la variable [nom]. On ne peut pas écrire [*{nom}] (ce serait la propriété [nom]) : on utilise le prétraitement [__...__]. L'expression entre doubles soulignés est évaluée d'abord, et son résultat remplace le texte : si [nom] vaut [email], l'attribut devient [th:field="*{email}"], qui est ensuite évalué normalement. [th:field] utilise l'objet du formulaire ([th:object]), déclaré par la vue qui insère le fragment ;
- ligne 10 : [#fields.hasErrors(cle)] dit si la propriété a des erreurs dans le [BindingResult] de l'objet du formulaire. La classe [Bootstrap] [is-invalid] encadre alors le champ en rouge ;
- ligne 11 : l'attribut booléen [required] est écrit si [requis] est vrai, omis sinon ;
- ligne 12 : [th:attr] écrit plusieurs attributs à la fois. Un attribut dont la valeur est [null] n'est pas écrit : chaque champ ne reçoit que les contraintes HTML5 qui le concernent ;
- ligne 14 : le message d'erreur, sous le champ (classe [Bootstrap] [invalid-feedback]). [#fields.errors(cle)] rend la liste des messages de la propriété ; une propriété peut violer plusieurs contraintes, et l'on n'affiche que le premier ([[0]]).
Le fichier [templates/inscription/inscription.html] :
- lignes 6-7 : le lien qui active ou désactive la validation par le navigateur ;
- ligne 10 : l'attribut [action] du formulaire garde le paramètre [html5] ;
- ligne 11 : l'attribut booléen [novalidate] est écrit quand [html5] est faux. [th:object] désigne l'objet du formulaire, [inscription] ;
- lignes 12-16 : chaque champ est une insertion du fragment, avec des paramètres nommés. Pour la confirmation (ligne 16), l'erreur à afficher est celle de la propriété [confirmationValide], celle de la règle [@AssertTrue].
Le fichier [templates/layout.html] :
- ligne 13 : le message global est affiché en bas de la page, sur un fond rose léger (classe [zone-erreur] de [static/css/styles.css]), comme dans l'étude de cas.
Le fichier [templates/inscription/bienvenue.html] :
- ligne 4 : [th:object] n'est pas réservé aux formulaires : sur la balise [<main>], il permet d'écrire [*{nom}] au lieu de [${inscription.nom}] ;
- lignes 5-6 : les valeurs converties et nettoyées : l'âge est un nombre, le nom a perdu ses espaces.
Le formulaire vide (http://localhost:5000/inscription), puis envoyé avec plusieurs erreurs (un nom fait d'espaces, une adresse incomplète, un âge trop petit, des mots de passe trop courts et différents) :


puis correctement rempli (avec des espaces autour du nom) :

Et avec un âge non numérique, envoyé par [curl] (le navigateur l'aurait refusé dans un champ [type="number"]) :
On remarque que :
- l'erreur de conversion de l'âge utilise le message de [messages.properties] ;
- le champ [age] ne réaffiche pas la valeur rejetée [abc] : pour un champ [type="number"], [th:field] n'écrit pas une valeur qui n'est pas un nombre (avec [type="text"], il aurait écrit [value="abc"]) ;
- le mot de passe absent n'a qu'une erreur, « obligatoire » : [@Size] ne vérifie pas une valeur [null]. La confirmation, elle, n'en a pas : deux mots de passe absents sont égaux ;
- les champs non erronés n'ont pas la classe [is-invalid], et leur fragment ne produit pas de message.
4.7.2. La validation par le navigateur
Les attributs [required], [maxlength], [min], [max], [type="email"]... demandent au navigateur de vérifier le formulaire avant de l'envoyer : l'utilisateur est prévenu immédiatement, par une bulle d'aide, sans aller-retour avec le serveur. C'est le comportement obtenu avec le lien « l'activer » (http://localhost:5000/inscription?html5=true), qui retire l'attribut [novalidate] du formulaire.
Cette validation est un confort, pas une protection : il suffit de retirer l'attribut [required] avec les outils de développement du navigateur, ou d'envoyer la requête avec [curl], pour la contourner. La validation par le serveur reste indispensable. C'est pourquoi l'exemple désactive par défaut la validation du navigateur : pour voir travailler le serveur. L'étude de cas, elle, garde les deux : les attributs HTML5 pour le confort, et la validation serveur pour la sûreté.
4.8. Exemple 20 : le schéma Post / Redirect / Get
Quand un formulaire POST réussit, l'action peut afficher directement une page. Le problème apparaît si l'utilisateur recharge ensuite cette page (touche F5) : le navigateur renvoie le formulaire, et l'opération est faite une seconde fois (une commande passée deux fois, un rendez-vous pris deux fois...). Le schéma Post / Redirect / Get (PRG) évite ce problème : une action POST réussie ne fabrique pas de page, elle redirige vers une page GET. Recharger cette page ne refait qu'un GET, sans effet.
Reste un détail : la page GET doit afficher un message (« la tâche a été ajoutée »), alors que ce message a été produit par l'action POST, dans une autre requête. [Spring MVC] fournit pour cela les attributs flash : des attributs déposés avant la redirection, qui survivent jusqu'à la requête suivante, où ils sont ajoutés au modèle, puis disparaissent. C'est ce qu'on appelle un message flash. Par défaut, [Spring MVC] conserve les attributs flash dans la session HTTP de l'utilisateur : une mémoire que le serveur associe à chaque navigateur (le chapitre « Portées des données » lui est consacré).
Le fichier [services/TachesService.java] :
- ligne 13 : un singleton est partagé par toutes les requêtes, qui peuvent s'exécuter en même temps. Une [ArrayList] ordinaire ne le supporte pas ; [CopyOnWriteArrayList] est une liste prévue pour cet usage ;
- lignes 15-17 : [List.copyOf] rend une copie non modifiable de la liste : l'appelant ne peut pas modifier la liste du service.
Le fichier [controllers/TachesController.java] :
Commentons ce code :
- lignes 24-28 : la page de la liste ;
- ligne 33 : un paramètre de type [RedirectAttributes], fourni par [Spring MVC] : les attributs de la redirection ;
- lignes 35-40 : une saisie invalide : on réaffiche la page, sans redirection (il faut garder la saisie et le message d'erreur). La ligne 39 appelle simplement la méthode [afficher], comme n'importe quelle méthode [Java] : elle ajoute la liste des tâches au modèle et rend le nom de la vue ;
- ligne 43 : une saisie valide : après l'opération, [addFlashAttribute] dépose le message flash ;
- ligne 45 : une chaîne qui commence par [redirect:] n'est pas un nom de vue : [Spring MVC] répond par une redirection (code 302) vers l'adresse qui suit (cf. exemple 04) ;
- lignes 49-54 : le contre-exemple, pour comparer : une page en réponse au POST.
Le fichier [templates/taches/taches.html] :
- ligne 10 : la saisie, réaffichée après une erreur (l'attribut [libelle] n'existe pas dans les autres cas : le champ est vide) ;
- ligne 13 : l'attribut [formaction] d'un bouton envoie le formulaire à une autre adresse que celle de l'attribut [action] du formulaire. Le formulaire a ainsi deux boutons, qui déclenchent deux actions différentes. L'étude de cas utilise ce procédé sur sa page de connexion ;
- ligne 15 : un attribut absent du modèle vaut [null], et [th:if] considère [null] comme faux.
Le fichier [templates/layout.html] :
Le message flash est affiché en haut de la page (ligne 12), l'erreur en bas (ligne 14). Pour la vue, un attribut flash est un attribut du modèle comme un autre.
Voici ce que voit [curl] pour l'envoi réussi du formulaire. L'option [-c fichier] enregistre les cookies reçus, [-b fichier] les renvoie ; [--data-urlencode] code les espaces et les accents du champ :
La réponse est une redirection vers [/taches]. Pour ranger le message flash, [Spring MVC] a créé une session HTTP : le serveur ([Tomcat]) lui a donné un identifiant, envoyé au navigateur dans le cookie [JSESSIONID]. La requête suivante renvoie ce cookie, ce qui permet au serveur de retrouver la session et le message :
Le message flash est affiché ; si l'on recharge la page, il a disparu. La session, elle, reste ouverte : son identifiant ne change pas, et le serveur ne renvoie pas le cookie.
Le fichier [application.properties] contient un réglage lié à la session :
- ligne 7 : la session est suivie par un cookie seulement. Sans ce réglage, [Tomcat] ne sait pas, lors de la première réponse, si le navigateur accepte les cookies : il envoie le cookie, mais ajoute aussi l'identifiant de session aux URL de redirection. On obtient alors :
L'URL [/taches;jsessionid=...] fonctionne, mais elle s'affiche dans la barre d'adresse du navigateur, et un identifiant de session qui circule dans les URL (historique, favoris, liens copiés) est un risque de sécurité. Tous les exemples qui utilisent des attributs flash ont ce réglage.
Dans le navigateur (http://localhost:5000/taches), après l'ajout d'une tâche :

La barre d'adresse affiche [/taches] : c'est la page GET. On peut la recharger sans risque, le message disparaît. Une saisie faite d'espaces :

Et le contre-exemple :

Ici, la barre d'adresse affiche [/taches/sans-prg], l'adresse du POST. Recharger la page provoque un avertissement du navigateur (« confirmer le renvoi du formulaire »), puis ajoute la tâche une seconde fois. Tous les formulaires POST de l'étude de cas suivent le schéma PRG. L'étude de cas range cependant ses attributs flash dans un cookie plutôt que dans la session, car son serveur ne garde rien entre deux requêtes.
4.9. Exemple 21 : liens, boutons et confirmation
Une règle simple guide le choix entre un lien et un bouton :
- un lien ([<a href>]) produit une requête GET. Une requête GET ne doit rien modifier : les navigateurs, les moteurs de recherche, les antivirus se permettent de suivre des liens tout seuls, pour préparer une page ou l'analyser ;
- une opération qui modifie des données (ajouter, modifier, supprimer, se déconnecter) est un bouton dans un formulaire POST.
L'exemple 21 gère une liste de contacts : on consulte un contact par un lien, on le supprime par un bouton, avec une fenêtre de confirmation. Les fichiers de l'exemple :
Le fichier [services/ContactsService.java] :
Commentons ce code :
- lignes 14-15 : un contact ;
- lignes 17-20 : la liste des contacts : une [ArrayList] (modifiable), initialisée avec trois contacts ;
- ligne 24 : le mot-clé [synchronized] garantit qu'une seule requête à la fois exécute une méthode [synchronized] de l'objet : les requêtes simultanées ne peuvent pas modifier la liste en même temps. C'est une autre solution que la liste [CopyOnWriteArrayList] de l'exemple 20 ;
- lignes 29-31 : [lire] rend un [Optional<Contact>] (cf. exemple 06) : un objet qui contient un contact, ou rien si aucun contact n'a cet identifiant. [findFirst()] rend le premier élément du flux, dans un [Optional] ;
- lignes 36-38 : un record est immuable : on ne modifie pas ses champs, on le remplace par un nouveau ;
- ligne 46 : [++dernierId] incrémente le compteur avant de l'utiliser : les identifiants suivants sont 4, 5... ;
- ligne 53 : [ifPresent] exécute une action si l'[Optional] contient un contact. [contacts::remove] est une référence de méthode : une façon courte d'écrire la lambda [c -> contacts.remove(c)].
Le fichier [controllers/ContactsController.java] :
Commentons ce code :
- lignes 28-38 : les deux pages : la liste et la fiche d'un contact. La contrainte [{id:\\d+}] (cf. exemple 02) n'accepte qu'un identifiant fait de chiffres ;
- ligne 36 : [orElseThrow] rend le contact contenu dans l'[Optional] ou, s'il est vide, lève l'exception fabriquée par la fonction qu'on lui passe : ici la référence de méthode [ContactsController::introuvable] ;
- lignes 41-47 : l'enregistrement d'une fiche, en PRG. Les paramètres [nom] et [telephone] sont obligatoires : [@RequestParam] sans [required = false] ;
- lignes 50-56 : la duplication, déclenchée par un second bouton du même formulaire (attribut [formaction]) ;
- lignes 58-63 : la suppression, par un POST ;
- lignes 66-68 : une exception [ResponseStatusException] porte un code HTTP et une raison. Levée par une action, elle produit une réponse d'erreur avec ce code : ici 404. Un contact inexistant provoque ainsi une 404 dans les actions [fiche], [enregistrer] et [supprimer] ; la duplication, elle, ne relit pas le contact : elle crée une copie à partir des champs reçus.
Le fichier [templates/contacts/liste.html] :
- ligne 13 : consulter : un lien, présenté comme un bouton par [Bootstrap]. L'expression de lien [@{/contacts/{id}(id=${contact.id})}] contient une variable de chemin [{id}], remplie par le paramètre de même nom entre parenthèses : [/contacts/1]. Un paramètre qui ne correspond à aucune variable du chemin est ajouté après [?] (cf. exemple 16) ;
- lignes 15-16 : supprimer : un petit formulaire POST, réduit à son bouton. L'attribut [data-confirmation] contient la question à poser avant l'envoi. Les attributs [data-*] sont libres : le HTML les ignore, le [JavaScript] de la page peut les lire. [Thymeleaf] sait écrire n'importe quel attribut : [th:data-confirmation] calcule l'attribut [data-confirmation], dont la valeur est échappée.
Le fichier [templates/contacts/fiche.html] :
- ligne 4 : [th:object] sur la balise [<main>] : le contact est l'objet sélectionné, et [*{id}] désigne son identifiant ;
- ligne 6 : l'adresse du formulaire, avec une variable de chemin remplie par une expression de sélection ;
- lignes 9 et 13 : ce formulaire n'utilise pas [th:field] : les champs ne sont pas liés à un objet de formulaire, [th:value] suffit ;
- lignes 16-17 : deux boutons d'envoi pour un même formulaire ; le second a son propre attribut [formaction].
4.9.1. La fenêtre de confirmation
La boîte de dialogue du navigateur ([window.confirm]) ne peut être ni mise en forme, ni traduite : ses boutons restent dans la langue du navigateur. On la remplace par une fenêtre [Bootstrap], écrite dans la page et cachée tant qu'on n'en a pas besoin. Le fichier [templates/fragments/confirmation.html] :
- ligne 2 : un fragment sans paramètre. La classe [d-none] cache la fenêtre ;
- ligne 3 : le fond grisé ;
- ligne 11 : l'emplacement de la question ;
- lignes 14-15 : les deux boutons, repérés par un attribut [data-action].
Le fichier [templates/layout.html] :
- ligne 11 : le message flash ;
- ligne 15 : la fenêtre est présente dans toutes les pages ;
- ligne 17 : le script est chargé en fin de page : quand il s'exécute, les éléments qu'il cherche existent déjà. Il est servi comme fichier statique, depuis [static/js/app.js].
Un peu de [JavaScript] fait le lien entre les formulaires et la fenêtre. Le fichier [static/js/app.js] :
- ligne 3 : le code est enveloppé dans une fonction exécutée aussitôt, pour ne pas créer de variables globales ;
- lignes 9-16 : on intercepte l'envoi de chaque formulaire qui porte l'attribut [data-confirmation]. [preventDefault()] suspend l'envoi ; on mémorise le formulaire, on place la question dans la fenêtre ([textContent], et non [innerHTML] : la question est affichée comme du texte, jamais interprétée comme du HTML) et on affiche la fenêtre ;
- lignes 19-27 : « Oui » envoie le formulaire mémorisé. La méthode [submit()] d'un formulaire l'envoie sans déclencher l'événement [submit] : il n'y a pas de nouvelle demande de confirmation. « Non » cache la fenêtre.
Si le [JavaScript] est désactivé, les formulaires partent simplement sans confirmation : l'application fonctionne toujours. C'est le principe d'amélioration progressive : le [JavaScript] ajoute un confort, il n'est pas indispensable. L'étude de cas suit ce principe (son script [static/js/app.js] ajoute la gestion du clavier : touche Échap, focus). Les pages obtenues, à partir de http://localhost:5000/contacts : la liste, la demande de confirmation, la liste après suppression, la fiche d'un contact :




Enfin, on vérifie que la suppression ne peut pas être déclenchée par une requête GET :
Le chemin existe, mais pas pour la méthode GET : [Spring MVC] répond [405 Method Not Allowed], et l'entête [Allow] indique la méthode acceptée. Le corps de la réponse est la réponse d'erreur de [Spring Boot], en JSON pour [curl] (un navigateur, qui demande du HTML, reçoit une page d'erreur). Avec POST :
La suppression a eu lieu, suivie de la redirection vers la liste ; le cookie [JSESSIONID] est celui de la session qui transporte le message flash. Et un contact inexistant :
La réponse 404 reprend la raison donnée à l'exception [ResponseStatusException] ([contact inconnu]). Ce champ [message] n'est présent que pendant le développement : les outils de développement de [Spring Boot] ([devtools], une dépendance de tous les exemples) activent son affichage ; sans eux, la réponse ne contient que le code, l'erreur et le chemin.