Copy of https://perso.isima.fr/loic/java/exo_spring02.php
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
Institut d'informatique ISIMA
  • twitter
  • linkedin
  • viadeo

eco Devine le nombre eco

 Cette page commence à dater. Son contenu n'est peut-être plus à jour. Contactez-moi si c'est le cas!

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 / 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 :

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 ?

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.

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.

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