tete du loic

 Loïc YON [KIUX]

  • Enseignant-chercheur
  • Référent Formation Continue
  • Responsable des contrats pros ingénieur
  • Référent entrepreneuriat
  • Responsable de la filière F2 ingénieur
  • Secouriste Sauveteur du Travail
mail
loic.yon@isima.fr
phone
(+33 / 0) 4 73 40 50 42
location_on
ISIMA
  • twitter
  • linkedin
  • viadeo

eco Devine le nombre eco

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 :

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 Spring Boot. Vous pouvez reprendre le fichier de configuration du TP précédent ou suivre ce lien .

On a sélectionné les options suivantes :

En résumé, c'est le même fichier de configuration que le TP précédent avec des dépendances supplémentaires :

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'
}

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>  

En général, il est nécessaire d'avoir un contrôleur qui associe route (url) et page à afficher. Voici un exemple :

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;
// import jakarta.validation.Valid; // v3 et plus

//import app.Devinette;

@Controller
public class IndexController {

    @GetMapping("/")
    public String index() {
        return "index";
    }

}

Le nom de la méthode n'est pas important. Ce qui l'est ce sont les annotations et les paramètres avec leurs éventuelles annotations :-). @RequestMapping permet de répondre à tout type de message ; @GetMapping se limite aux requêtes GET et ainsi de suite...

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 @ComponentScan.

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 de ressources statiques, à savoir : public, static ou encore resources.Ainsi

Et là, VICTOIRE : tout s'affiche correctement !

mais sans interaction, si vous mettez une valeur dans le formulaire, vous aurez l'erreur suivante


There was an unexpected error (type=Method Not Allowed, status=405).
Method 'POST' is not supported.
org.springframework.web.HttpRequestMethodNotSupportedException: Request method 'POST' is not supported

On file vite au point suivant...

Interactivité

Affichage de la bulle

On a vu au TP précédent que grâce à Thymeleaf on pouvait afficher des messages en fonction des paramètres des requêtes. On va désormais stocker les informations dans un "modèle" qui sera un objet instancié automatiquement. Le texte de la bulle est stockée dans un attribut message d'un objet de classe Devinette

Devinette
- message : entier
+ constructeur()
+ getMessage()

Avec le code java suivant :

package app;
public class Devinette {
  String message; 
  int    nombre;

  public Devinette() {
    message = "Je pense à un nombre entre 1 et 100 ("+nombre+"). Pouvez-vous le trouver ?";
  }

  public String getMessage() {
    return message;
  }
}

Pour afficher le contenu de la chaine "message" en lieu et place de ce contenu statique :


<p id="text">Je pense à un nombre entre 1 et 100. Pouvez-vous le trouver ?<p/p>

on générera le contenu de la balise p par un attribut th:utext spécifique (unescape text):


<p th:utext="${devinette.message}"> Texte plus utilisé </p>

Le texte de la balise p est maintenant celui de devinette.getMessage(). Il va sans dire que si la variable "message" contient le même texte, vous ne verrez pas la différence !

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.

Il reste encore le lien à faire entre l'objet et le template, c'est le contrôleur qui le permet :


// Affichage de la page
@GetMapping("/")
public String index(@ModelAttribute Devinette devinette) {
  return "index";
}

Logique applicative

Notre application doit initialiser un état de générateur aléatoire (une fois) puis déterminer un nombre aléatoire à chaque fois que c'est nécessaire (reset()). L'utilisateur fait une proposition et un message adapté est affiché (update()).

Complétons la classe Devinette :

Devinette
- proposition : entier
- nombre : entier
- message : entier
- found : booléen
- random : une instance
+ Devinette()
+ getMessage()
+ get/setProposition()
+ isFound();
+ update();
+ reset();

Ce qui correspond plus ou moins au code java :

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 a ajouter

  public Devinette() {
    reset();
  }

  public void reset() {
    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;
  }

  public int getProposition() {
    return proposition;
  }

  public String getMessage() {
    return message;
  }

  // logique metier
  // mise a jour de message en fonction de proposition
  public void update() {

  }
}

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.

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.

Prise en compte du formulaire

Nous allons maintenant prendre en compte la saisie du formulaire. Si vous regardez le code du fichier index.html, les données du formulaire sont passées en POST. Il faut donc un contrôleur capable de gérer cette méthode, par exemple :


// Prise en compte du formulaire
@PostMapping("/")
public String traitement(@ModelAttribute Devinette devinette) {
  return "index";
} 

Mais il n'y a toujours pas de donnée qui transite entre le formulaire et la page web. Pour ce faire, nous allons utiliser une technique qui s'appelle le binding qui permet de lier un objet java avec des données dans une page web. On associe au travers du moteur de template l'attribut proposition de l'objet devinette à l'élément de formulaire idoine :


<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}.

Que constatez-vous ?

le nombre à chercher change à chaque nouvelle requête

Portée "session"

Le nombre généré aléatoirement change à chaque nouvelle soumission de formulaire : l'objet a implicitement une portée Requête (request) ; il faudrait que l'objet ait une portée session.

Il y a six portées disponibles en Spring. On retiendra par exemple pour le web : requête, session, application et websocket (prototype et singleton complètent la liste)

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 {
  
  // ...

  @PostMapping("/")
  public String index(Devinette devinette) {
    devinette.update();
    return "index";
  }
}

Si vous avez bien écrit la méthode update(), vous devez avoir une petite application qui affiche les bons messages mais qui ne permet pas encore de recommencer quand le nombre a été trouvé.

On va donc s'intéresser au bouton Recommencer

Gestion du deuxième bouton

Dans un formulaire, il y a généralement deux boutons :

On propose d'avoir deux boutons submit : 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(Devinette devinette) {
    devinette.update();
    return "index";
  }    

  @PostMapping(value="/", params={"nouveau"})
  public String nouveau(Devinette devinette) {
    devinette.reset();
    return "index";
  }  
}  

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.

On va améliorer l'affichage du formulaire et n'afficher la partie "saisie" du formulaire que quand le nombre n'a pas encore été trouvé. Pour ce faire, on a va utiliser un attribut d'affichage conditionnel th.if ou sa négation th.unless


<p th:if="${devinette.isFound()}">
<p th:unless="${devinette.isFound()}">

Le bouton Recommencer est affiché en permanence dans le cas où l'utilisateur voudrait recommencer avant d'avoir trouvé.

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 ...


This application has no explicit mapping for /error, so you are seeing this as a fallback.

Mon Jan 15 19:02:38 CET 2024
There was an unexpected error (type=Bad Request, status=400).
Validation failed for object='devinette'. Error count: 1
org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public java.lang.String app.controllers.IndexController.traitement(app.Devinette): [Field error in object 'devinette' on field 'proposition': rejected value [a]; codes [typeMismatch.devinette.proposition,typeMismatch.proposition,typeMismatch.int,typeMismatch]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [devinette.proposition,proposition]; arguments []; default message [proposition]]; default message [Failed to convert property value of type 'java.lang.String' to required type 'int' for property 'proposition'; For input string: "a"]] 
	at org.springframework.web.method.annotation.ModelAttributeMethodProcessor.resolveArgument(ModelAttributeMethodProcessor.java:158)

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 dans l'hypothèse où l'on passerait un objet sur la session :

// CHOIX 1 : bon ou mauvais ?
@PostMapping(value="/", params={"valider"})
public String traitement(Devinette devinette, BindingResult br, SessionStatus session) {
  return "index";

// CHOIX 2 : bon ou mauvais ?
@PostMapping(value="/", params={"valider"})
public String traitement(Devinette devinette, SessionStatus session, BindingResult br) {
  return "index";
}

Que peut-on faire maintenant ?

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}">La saisie est incorrecte</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 que vous avez normalement déjà spécifée dans le fichier de build).

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 submit 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.

Le 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.

// AU NIVEAU DU CONTROLEUR
// import org.springframework.beans.factory.annotation.Autowired
@Autowired
MessageSource messageSource;

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).


// code dans le contrôleur
@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.

La solution mémorise deux informations, la clé du message à afficher et les éventuelles informations complémentaires. La méthode getMessage() est modifiée comme suit :


// trad = "won";
// params = new Object[] {proposition}; 
public String getMessage() {
		if (messageSource !=null )
		return messageSource.getMessage(trad, params, LocaleContextHolder.getLocale());
}

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é !