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

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 :

La fichier gradle est encore au format 5.

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:2.1.6.RELEASE")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

bootJar {
    baseName = 'sb-guess'
    version =  '0.1.0'
}

repositories {
    mavenCentral()
}

sourceCompatibility = 1.8
targetCompatibility = 1.8

dependencies {
    compile("org.springframework.boot:spring-boot-starter-web")
    compile("org.springframework.boot:spring-boot-starter-thymeleaf")
    compile("org.springframework.boot:spring-boot-devtools")
    testCompile("junit:junit")
}

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;
import javax.validation.Valid;

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

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

Pour afficher le texte dans la bulle, il faut enlever le contenu de l'élément p et ajouter un nouvel attribut th:utext qui permet d'afficher un texte avec du HTML dedans :


th:utext="${devinette.message}"

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> 

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 normalement un bouton et un bouton . 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, 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

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

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 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 (@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("gagne", 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é !