Date de première publication : 2019/10/24
Préparation
Objectif et contexte
L'objectif de cet exercice est de continuer la découverte de SpringBoot avec la manipulation de formulaire(s) et de Spring MVC.
Vous allez réaliser une application qui permet à un utilisateur de chercher un nombre que le programme a choisi aléatoirement
L'environnement de développement est le suivant :
- Java : Java SE 8, 11 ou 17
- Gradle 7+ ou Gradlew
- SpringBoot 2 ou 3
Ce sujet utilise la documentation officielle de Spring Boot "handling form submission" sur une idée de programme donnée dans un tutoriel Java EE.
Mise en place de la page d'index
Il faut créer un nouveau projet / répertoire et déposer un fichier de configuration gradle build.gradle
s'il n'a pas été généré automatiquement
plugins {
id 'java'
id 'org.springframework.boot' version '3.0.2'
id 'io.spring.dependency-management' version '1.1.0'
}
group = 'app'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
Il est ensuite nécessaire de créer une aborescence :
mkdir -p src/main/java/app
mkdir -p src/main/resources/templates
mkdir -p public
L'application principale ne doit pas se trouver dans le package par défaut sinon elle refuse de se lancer :
package app;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
Nous voulons dans un premier temps afficher une page de bienvenue index.html
qui affiche un message et un formulaire. Elle est très simple pour l'instant mais sera modifiée plus tard, on va donc la sauvegarder dans le répertoire templates
.
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Devine le nombre</title>
<meta charset="utf-8">
<link rel="stylesheet" href="/style/guess.css">
</head>
<body>
<div id="content">
<div id="bigBubble">
<p id="text">Je pense à un nombre entre 1 et 100. Pouvez-vous le trouver ?</p>
<div id="mediumBubble"></div>
<div id="smallBubble"></div>
</div>
<img id="tete" src="/images/loic.png" alt="tete du loic">
<form id="pro_form" method="POST" action="/">
<p><input name="valider" type="text" size="2">
<input type="submit" value="Ok"></p>
<p><input name="nouveau" type="submit" value="Recommencer" > <p>
</form>
</div>
</body>
</html>
La page n'est pas affichée sans contrôleur, il nous en faut donc un qui réagisse sur le point d'entrée. Le voici :
package app.controllers;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.bind.support.SessionStatus;
import org.springframework.validation.BindingResult;
// Decommentez en fonction de votre version de Spring Boot
// import javax.validation.Valid; // v2
// import jakarta.validation.Valid; // v3 et plus
//import app.Devinette;
@Controller
public class IndexController {
@GetMapping("/")
public String index() {
return "index";
}
}
Le contrôleur est défini dans un sous-package de app, il sera donc trouvé automatiquement. Si vous voulez le mettre dans un autre package, il faudra ajouter une annotation @ScanComponent
.
Compilez et exécutez, une page devrait s'afficher !
Si vous avez un problème de port déjà occupé, la solution est donnée à l'exercice précédent.
Cette page HTML semble incomplète, non ? Les informations de style ne sont pas prises en compte et une image est manquante. Toutes ces ressources, statiques, doivent être placées dans un répertoire public. Ainsi
Et là, VICTOIRE : tout s'affiche correctement !
Interactivité
Prise en compte du formulaire
Nous allons maintenant prendre en compte la saisie du formulaire. Les données sont stockées dans un objet "modèle". Créons la classe Devinette
. Trois attributs seront définis :
message
: le message affiché dans la bulle. Par exemple : texte de présentation, nombre trop petit ou trop grand et fin du jeu.nombre
: un nombre généré aléatoirement entre 1 et 100 dans le constructeurproposition
: en entier pour retenir le résultat du formulaire
Dans la terminologie Spring, Devinette
est un Baking Bean : un "bean" est une classe java qui dispose d'un constructeur par défaut et pour laquelles les attributs disposent de méthodes get/set/is.
Les conventions de nommage doivent être scrupuleusement respectées.
package app;
import java.util.Random;
import org.hibernate.validator.constraints.Range;
public class Devinette {
int nombre;
int proposition;
String message;
// random est une variable de classe définie ici
public Devinette() {
nombre = 1 + random.nextInt(100);
message = "Je pense à un nombre entre 1 et 100 ("+nombre+"). Pouvez-vous le trouver ?";
}
public void setProposition(int n) {
proposition = n;
/* logique métier */
}
public int getProposition() {
return proposition;
}
public String getMessage() {
return message;
}
}
random
est un attribut de classe pour éviter de réinitaliser le générateur de nombres aléatoires. Si celui-ci devait être partagé par plusieurs classes. Il ne faudrait pas le mettre dans cette classe.
Pour prendre tout cela en compte, il faut modifier le template index.html
Le template est analysé sur le serveur par le moteur de template (thymeleaf dans notre cas)
avant d'être envoyé au navigateur pour affichage.
En HTML, pour modifier le texte du paragraphe, il suffit de modifier ce qui est délimité par la balise p
.
Quand on veut faire cela, par "programmation" en thymeleaf, il faut le faire en donnant l'information par un attribut th:utext
spécifique (unescape text):
<p th:utext="${devinette.message}"> Texte plus utilisé </p>
Le langage HTML est un langage à balises. Chaque balise peut avoir des attributs. Le moteur de template propose des balises spécifiques que seul le moteur comprend et pour éviter les conflits de nommage, ces attributs sont préfixés par "th" comme spécifié dans l'entête du document.
Pour le formulaire, il y a un peu plus de boulot :
<form id="pro_form" method="post" th:object="${devinette}" th:action="@{/}">
<p><input type="text" size="2" th:field="*{proposition}">
<input name="valider" type="submit" value="Ok"></p>
<p><input name="nouveau" type="submit" value="Recommencer"></p>
</form>
Pour information, les attributs id
, method
et action
sont des attributs classiques pour la balise form
. th:action
est un raccourci pour donner des informations avec Thymeleaf, on pourrait aussi utiliser th:attr
.
La notation *{proposition}
se réfère au th.object
. Si vous voulez, vous pouvez utiliser ${devinette.proposition}.
Il ne faut pas oublier de mettre à jour le contrôleur en répondant de manière plus précise à l'affichage simple de la page avec GET et à la l'utilisation du formulaire POST
// Affichage de la page
@GetMapping("/")
public String index(@ModelAttribute Devinette devinette) {
return "index";
}
// Prise en compte du formulaire
@PostMapping("/")
public String traitement(@ModelAttribute Devinette devinette) {
return "index";
}
Qu'est-ce que vous constatez ?
Portée "session"
Effectivement, le nombre généré aléatoirement change à chaque nouvelle soumission de formulaire. L'objet à une portée Requête (request) ; il faudrait que l'objet ait une portée session.
Pour ce faire, l'objet va directement être déclaré dans le contrôleur et ne sera plus passé comme @ModelAttribute
en paramètre aux méthodes :
@Controller
@SessionAttributes("devinette")
public class IndexController {
@GetMapping("/")
public String index(Devinette devinette) {
return "index";
}
/* ... */
}
La dernière chose à savoir est comment réinitialiser l'objet devinette
. Il est facile de tuer la session et d'en recréer une (implicitement). Un objet de type SessionStatus
permet de faire cela
@PostMapping("/")
public String traitement(Devinette devinette, SessionStatus session) {
if (devinette.isFound())
session.setComplete(); // termine la session courante
return "index";
}
Gestion du deuxième bouton
Dans un formulaire, il y a généralement deux boutons un bouton pour envoyer le formulaire et un bouton pour réinitialiser le formulaire avec les valeurs initiales. On propose d'avoir deux boutons : le premier pour valider la saisie et le second pour au contraire recommencer le jeu.
C'est assez facile à faire bien que pas très naturel (HTML parlant), il faut paramétrer @PostMapping
avec l'attribut name
du bouton.
public class IndexController {
@PostMapping(value="/", params={"valider"})
public String traitement(@Valid Devinette devinette) {
// code à adapter
return "index";
}
@PostMapping(value="/", params={"nouveau"})
public String nouveau(@Valid Devinette devinette, SessionStatus session) {
// pareil
devinette.reset();
// session.setComplete();
return "index";
}
/* ... */
}
Pour qu'il soit plus facile de réinitialiser le jeu, j'ai ajouté une méthode reset()
appelée explicitement par le bon traitement ou par le constructeur de Devinette
Bien que l'attribut name
soit obsolète en HTML5 et soit généralement remplacé par l'attribut id
, cela ne marche pas avec le paramètre de mapping. On va donc avoir deux attributs id
et name
avec la même valeur.
Gestion des erreurs / mauvaise saisie
Que se passe-t-il si l'utilisateur ne saisit pas un entier dans le formulaire ? On obtient une page d'erreur par défaut qui nous signale l'erreur qui s'est produite ainsi que le message que l'on n'a rien fait pour s'en occuper ...
C'est une erreur de liaison entre la vue et le modèle et c'est une erreur assez haute dans la hiérarchie. Si on ajoute un gestionnaire d'erreur comme @ExceptionHandler
, cela ne suffit pas !
La solution est de passer un BindingResult
"juste après" la devinette ! L'objet possède alors une méthode hasErrors()
bien pratique : elle signale tous les types d'erreurs qu'elles soient globales ou de champ (#field
). Je vous laisse deviner quelle méthode ne marche pas :
@PostMapping(value="/", params={"valider"})
public String traitement(Devinette devinette, BindingResult br, SessionStatus session) {
return "index";
@PostMapping(value="/", params={"valider"})
public String traitement(Devinette devinette, SessionStatus session, BindingResult br) {
return "index";
}
Que peut-on faire maintenant ?
- rien (mais avec la version surchargée de la méthode tout de même)
- adapter le
message
dedevinette
- afficher l'erreur avec le moteur de template
Voici, par exemple, un morceau de code à placer dans le formulaire :
<ul th:if="${#fields.hasErrors('*')}">
<li th:each="err : ${#fields.errors('*')}" th:text="${err}">Input is incorrect</li>
</ul>
Toutes les erreurs formulaire sont listées dans une liste à puces. Le tutoriel Thymelead/Spring donne plus d'infos sur les erreurs et le moteur de template.
Il est également possible de limiter les valeurs possibles pour le formulaire en utilisant les annotations @Valid
et @Range
public class Devinette {
@Range(min = 1, max = 100)
int nombre;
// ...
}
public class IndexController {
@PostMapping(value="/", params={"valider"})
public String traitement(@Valid Devinette devinette, BindingResult br, SessionStatus session) {
// ...
}
}
Cela utilise le système de validation d'Hibernate (c'est une dépendance à donner dans le fichier gradle).
Internationalisation
par le système de template
On va maintenant utiliser le système d'internationalisation que nous proposent Thymeleaf et Spring. L'exemple se base sur le tutoriel de Baeldung.
Le système est relativement simple : on associe un texte à afficher dans une langue particulière à une clé qui sert de référence pour tous les fichiers de traductions. Ces fichiers sont à déposer dans le répertoire resources
(si ce n'est pas le cas, il faudra obligatoirement décommenter le code de messageSource()
à la section suivante avec le bon chemin).
Le fichier messages.properties
contient les textes de la locale par défaut. Les fichiers messages_en.properties
et messages_pt.properties
contiennent respectivement les traductions anglaise et portugaise.
intro:Je pense à un nombre entre <strong>1</strong> et <strong>100</strong>. Pouvez-vous le trouver ?
ok:Essayer
restart:Recommencer
won:Tu as gagné ! C''était bien {0}.
less:{0} est trop petit.
greater:{0} est trop grand.
On peut alors d'ores et déjà internationaliser certains textes. Pour le bouton du formulaire, il faut remplacer l'attribut value
par l'attribut :
th:value=#{ok}
S'il faut internationaliser un nœud texte, il faut un attribut th:text
Pour l'instant, le changement de langue n'est pas pris en compte. Il va falloir un peu de configuration et améliorer l'interface de la page web.
On appelle "locale" l'information de localisation que va retenir l'application. Cette information sera stockée au niveau "session" (on peut faire autrement :-)). La langue pourra être changée en passant un paramètre lang
dans l'URL.
package app;
import java.util.Locale;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
import org.springframework.web.servlet.i18n.SessionLocaleResolver;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.context.MessageSource;
@Configuration
public class Config implements WebMvcConfigurer {
@Bean
public LocaleResolver localeResolver() {
SessionLocaleResolver slr = new SessionLocaleResolver();
slr.setDefaultLocale(Locale.FRANCE);
return slr;
}
@Bean
public LocaleChangeInterceptor localeChangeInterceptor() {
LocaleChangeInterceptor lci = new LocaleChangeInterceptor();
lci.setParamName("lang");
return lci;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
}
/*
@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource source = new ReloadableResourceBundleMessageSource();
source.setBasename("classpath:./messages");
source.setDefaultEncoding("UTF-8");
return source;
}*/
}
Le texte des boutons change maintenant en fonction du paramètre lang
donné dans l'URL. Vous pouvez afficher la locale courante avec par exemple :
<div th:text="${#locale}"></div>
Maintenant, on peut s'arranger que l'objet devinette
renvoie le code de traduction de ce qui doit s'afficher, c'est-à-dire les clés de traduction. Si en revanche, vous voulez que devinette
puisse utiliser des messages traduits, c'est un peu tricky. C'est l'objet du paragraphe suivant.
dans le code java
Il va falloir décommenter le code en commentaire de la méthode messageSource()
. Si le chemin pour les fichiers "messages" n'est pas correct, les messages ne seront d'ores et déjà plus traduits.
messageSource
ne sera pas utilisable directement par un objet Devinette
car un tel objet n'est pas géré par Spring. Le contrôleur en revanche, lui, l'est. On pourra donc injecter une information dans le contrôleur sous forme d'attribut (@Autowired
) ou rendre le contrôleur MessageSourceAware
.
Je vous propose alors de doter la classe Devinette
d'un attribut de classe de type MessageSource
et d'initialiser cet attribut de classe lors de l'appel de la méthode GET du contrôleur. L'initialisation à la construction du contrôleur n'a pas l'air de marcher.
@GetMapping("/")
public String index(Devinette devinette) {
Devinette.setMessageSource(messageSource);
return "index";
}
Il ne reste plus qu'à récupérer les messages traduits dans du code java :
messageSource.getMessage("won", null, LocaleContextHolder.getLocale());
Si le message à afficher contient un paramètre, {0}
par exemple, il faut les donner en lieu et place de null
(un tableau d'Object
ou de String
fera l'affaire.
Dernières améliorations
Pour l'instant, seule la modification à la main du paramètre lang
permet de changer la langue. On va ajouter à la page HTML un peu d'interactivité.
Il y a bien évidemment plusieurs manières de faire, nous allons juste choisir une méthode récente en exploitant la classe Javascript URL
.
Il faut ajouter ce code javascript dans l'entête du fichier index.html
<script>
function change(l) {
var parsedUrl = new URL(window.location.href);
// ajoute ou change l'attribut "lang"
parsedUrl.searchParams.set("lang", l);
// permet de recharger la page
window.location = parsedUrl.href;
return false;
}
</script>
On va maintenant afficher les langues disponibles :
<p style="text-align:right;font-size:90%">
<span th:if="${!#strings.equals(#locale, 'fr')}"><a href="javascript:change('fr')">Français</a> </span>
<span th:if="${!#strings.equals(#locale, 'en')}"><a href="javascript:change('en')">English</a> </span>
<span th:if="${!#strings.equals(#locale, 'pt')}"><a href="javascript:change('pt')">Português</a></span>
</p>
Si on veut éviter la duplication de code, il est possible de définir un tableau des locales disponibles dans le contrôleur et de le parcourir avec moteur de template th:each
(comme pour le parcours des erreurs renvoyées par le formulaire). Vous pouvez aussi regarder la documentation sur les fragments ou les includes
Et ben voilà, c'est ter-mi-né !!!! J'espère que ça vous a bien amusé !