Skip to content

9. Architecture en couches et accès aux données

Les exemples précédents gardaient leurs données en mémoire. Une vraie application les garde dans une base de données, et s'organise en couches (cf. schéma de l'introduction) :

  • la couche web (contrôleurs, vues) : lire la requête, appeler la couche métier, choisir la vue ;
  • la couche métier : les règles de gestion ;
  • la couche DAO ([Data Access Object]) : les requêtes à la base de données.

Chaque couche ne connaît que la couche du dessous, et le moins possible de ses détails : la couche web ignore le SQL, la couche métier ignore le HTML. Cette séparation rend chaque couche plus simple à comprendre, à tester et à faire évoluer. L'exemple 28 l'applique à la gestion d'une liste de personnes : c'est, en plus petit, la gestion des médecins et des clients de l'étude de cas.

En [Java], l'accès à une base de données repose sur plusieurs étages :

  • [JDBC] ([Java Database Connectivity]) est l'interface standard de [Java] pour envoyer des ordres SQL à une base. Chaque SGBD fournit un pilote [JDBC] : pour [MySQL], la bibliothèque [mysql-connector-j]. Ouvrir une connexion est coûteux : [Spring Boot] utilise un pool de connexions ([HikariCP]), qui garde quelques connexions ouvertes et les prête aux requêtes ;
  • [JPA] ([Jakarta Persistence API]) est la norme [Java] des ORM ([Object-Relational Mapper]) : elle fait correspondre des classes [Java] (les entités) aux tables de la base, et transforme les lignes lues en objets. [JPA] n'est qu'une norme, faite d'interfaces et d'annotations ; l'ORM qui la met en œuvre est ici [Hibernate] ;
  • [Spring Data JPA] s'ajoute par-dessus : il suffit de déclarer une interface de DAO, et [Spring Data JPA] en écrit lui-même l'implémentation au démarrage de l'application, y compris les requêtes, qu'il déduit du nom des méthodes.

Le tout est apporté par le starter [spring-boot-starter-data-jpa].

9.1. Exemple 28 : une gestion de personnes en trois couches

28-couches/
├── database/
│   └── dbpersonnes.sql
├── src/
│   └── main/
│       ├── java/
│       │   └── exemple28/
│       │       ├── config/
│       │       │   └── WebConfig.java
│       │       ├── controllers/
│       │       │   └── PersonnesController.java
│       │       ├── dao/
│       │       │   └── PersonnesDao.java
│       │       ├── entities/
│       │       │   └── Personne.java
│       │       ├── metier/
│       │       │   ├── DonneesPersonne.java
│       │       │   ├── MetierException.java
│       │       │   └── PersonnesMetier.java
│       │       ├── models/
│       │       │   └── FormulairePersonne.java
│       │       └── Exemple28Application.java
│       └── resources/
│           ├── static/
│           │   ├── css/
│           │   │   └── styles.css
│           │   └── js/
│           │       └── app.js
│           ├── templates/
│           │   ├── fragments/
│           │   │   ├── champ.html
│           │   │   └── confirmation.html
│           │   ├── personnes/
│           │   │   ├── formulaire.html
│           │   │   └── liste.html
│           │   └── layout.html
│           ├── application.properties
│           └── messages.properties
└── pom.xml

9.1.1. La base de données

La base [dbpersonnes] est créée par un script SQL. Avec [Laragon], on l'exécute par exemple dans l'outil [HeidiSQL] (menu Fichier / Exécuter un fichier SQL), ou en ligne de commande, depuis le dossier de l'exemple :

mysql -u root < database/dbpersonnes.sql

Le fichier [database/dbpersonnes.sql] :

-- base de l'exemple 28 : une seule table, [personnes]
-- le script est écrit en UTF-8
SET NAMES utf8mb4;
DROP DATABASE IF EXISTS dbpersonnes;
CREATE DATABASE dbpersonnes CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE dbpersonnes;

CREATE TABLE personnes (
  id INT NOT NULL AUTO_INCREMENT,
  -- la version de la ligne, augmentée par JPA à chaque modification
  version INT NOT NULL DEFAULT 0,
  prenom VARCHAR(30) NOT NULL,
  nom VARCHAR(30) NOT NULL,
  age INT NOT NULL,
  PRIMARY KEY (id),
  -- deux personnes ne peuvent pas avoir les mêmes prénom et nom
  CONSTRAINT unq_personnes_prenom_nom UNIQUE (prenom, nom)
) ENGINE = InnoDB;

INSERT INTO personnes (prenom, nom, age) VALUES
  ('Marie', 'Pélissier', 42),
  ('Jacques', 'Bromard', 35),
  ('Philippe', 'Jandot', 58);
  • ligne 3 : le script est écrit en UTF-8 : on l'indique au serveur, pour que les accents des données ([Pélissier]) arrivent intacts ;
  • ligne 5 : le jeu de caractères [utf8mb4] accepte tous les caractères (accents, emojis...). La collation [utf8mb4_unicode_ci] compare les chaînes sans tenir compte des majuscules ni des accents : pour [MySQL], [pelissier] et [Pélissier] sont égaux ;
  • ligne 11 : un numéro de version, augmenté à chaque modification de la ligne (cf. verrou optimiste, plus bas) ;
  • ligne 17 : une contrainte d'unicité nommée : la base refuse deux personnes de mêmes prénom et nom. Son nom servira à reconnaître l'erreur.

9.1.2. La configuration

Le fichier [pom.xml] :

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>cours.springmvc</groupId>
    <artifactId>exemples</artifactId>
    <version>1.0</version>
  </parent>
  <artifactId>exemple28</artifactId>

  <dependencies>
    <!-- les vues Thymeleaf -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <!-- la validation (Bean Validation, Hibernate Validator) -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <!-- Spring Data JPA (Hibernate) et le pilote JDBC de MySQL -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
      <groupId>com.mysql</groupId>
      <artifactId>mysql-connector-j</artifactId>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
</project>
  • lignes 20-23 : la validation des données (cf. chapitre 3) ;
  • lignes 25-28 : [Spring Data JPA], avec [Hibernate], [HikariCP] et la gestion des transactions de [Spring] ;
  • lignes 29-33 : le pilote [JDBC] de [MySQL]. La portée [runtime] indique que le code de l'application ne l'utilise pas directement (il ne connaît que [JPA]) : le pilote n'est nécessaire qu'à l'exécution. Il fonctionne aussi avec [MariaDB].

Le fichier [application.properties] :

# le port d'écoute du serveur (8080 par défaut)
server.port=5000
# les réponses d'erreur de Spring Boot ne contiennent pas la pile d'appels
spring.web.error.include-stacktrace=never
# la session (qui transporte les attributs flash) est suivie par un cookie seulement
server.servlet.session.tracking-modes=cookie

# la connexion à la base : URL JDBC, utilisateur, mot de passe (ceux de Laragon)
spring.datasource.url=jdbc:mysql://localhost:3306/dbpersonnes
spring.datasource.username=root
spring.datasource.password=
# Hibernate ne crée ni ne modifie les tables : c'est le rôle du script SQL
spring.jpa.hibernate.ddl-auto=none
# la connexion à la base n'est pas gardée ouverte pendant la fabrication de la vue
spring.jpa.open-in-view=false
# true : les ordres SQL envoyés à la base sont affichés dans la console
spring.jpa.show-sql=false
  • ligne 6 : les messages de succès voyagent en attributs flash (cf. exemple 20), rangés dans la session : le cookie de session suffit à la retrouver, sans que l'identifiant de session soit ajouté aux URL ;
  • lignes 9-11 : la connexion à la base, pour la source de données ([DataSource]) que [Spring Boot] fabrique avec [HikariCP] : l'URL [JDBC] (pilote, serveur, port, base), l'utilisateur et le mot de passe. Ce sont les valeurs par défaut de [Laragon] (utilisateur [root], sans mot de passe). Le type de base (et sa version) n'est pas à indiquer : [Hibernate] le demande au serveur à la première connexion, et adapte le SQL qu'il produit ;
  • ligne 13 : [Hibernate] sait créer ou modifier les tables d'après les entités ; on le lui interdit ([none]) : la base est créée par le script SQL, et elle est la référence ;
  • ligne 15 : par défaut, [Spring Boot] garde la session [JPA] (et sa connexion à la base) ouverte jusqu'à la fin de la fabrication de la vue ([Open Session In View]) : une vue pourrait alors déclencher, sans qu'on le voie, des requêtes SQL. On désactive ce comportement : toutes les lectures sont faites par la couche métier, avant la vue. Quand cette propriété n'est pas précisée, [Spring Boot] le signale au démarrage par un avertissement ([spring.jpa.open-in-view is enabled by default...]) ;
  • ligne 17 : [true] affiche dans la console chaque ordre SQL envoyé par [Hibernate] (cf. l'exécution, plus bas).

Le fichier [messages.properties] contient le message d'une erreur de conversion (un âge [abc]), comme à l'exemple 19. La classe [config/WebConfig.java] sert [Bootstrap], comme dans les exemples précédents.

9.1.3. L'entité Personne

Le fichier [entities/Personne.java] :

package exemple28.entities;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;

// une ligne de la table [personnes]
@Entity
@Table(name = "personnes")
public class Personne {

    // la clé primaire, générée par la base (AUTO_INCREMENT) ; JPA relit sa
    // valeur après chaque INSERT
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Integer id;

    // le verrou optimiste : JPA augmente la version à chaque UPDATE, et ajoute
    // la condition "AND version = <valeur lue>"
    @Version
    @Column(name = "version")
    private Integer version;

    @Column(name = "prenom", length = 30)
    private String prenom;

    @Column(name = "nom", length = 30)
    private String nom;

    @Column(name = "age")
    private int age;

    // un constructeur sans paramètre est exigé par JPA
    public Personne() {
    }

    public Personne(String prenom, String nom, int age) {
        this.prenom = prenom;
        this.nom = nom;
        this.age = age;
    }

    public Integer getId() { return id; }

    public Integer getVersion() { return version; }

    public String getPrenom() { return prenom; }
    public void setPrenom(String prenom) { this.prenom = prenom; }

    public String getNom() { return nom; }
    public void setNom(String nom) { this.nom = nom; }

    public int getAge() { return age; }
    public void setAge(int age) { this.age = age; }
}
  • ligne 12 : [@Entity] fait de la classe [Personne] une entité [JPA] : chaque ligne de la table sera un objet [Personne]. Les annotations viennent du package [jakarta.persistence] : ce sont celles de la norme [JPA], pas celles d'[Hibernate] ;
  • ligne 13 : la table correspondante (par défaut, ce serait [Personne]) ;
  • lignes 18-21 : la clé primaire ([@Id]). [@GeneratedValue(strategy = GenerationType.IDENTITY)] indique que sa valeur est attribuée par la base ([AUTO_INCREMENT]) : [Hibernate] relit la valeur attribuée après chaque [INSERT]. Le type [Integer], et non [int], permet la valeur [null] : une personne pas encore enregistrée n'a pas d'identifiant. [@Column] donne le nom de la colonne correspondant à chaque champ ;
  • lignes 25-27 : la colonne de version. [@Version] confie à [Hibernate] le verrou optimiste : il met la version à 0 lors de l'[INSERT], l'augmente de 1 à chaque [UPDATE], et ajoute à chaque [UPDATE] (et à chaque [DELETE]) la condition [AND version = <valeur lue>]. Nous verrons plus loin à quoi cela sert ;
  • lignes 29-36 : les autres colonnes. [length] indique la taille de la colonne : elle ne servirait que si [Hibernate] créait lui-même la table, ce qui n'est pas le cas ici (cf. [ddl-auto]) ;
  • lignes 39-40 : [JPA] exige un constructeur sans paramètre : c'est lui qu'[Hibernate] utilise pour fabriquer un objet à partir d'une ligne lue, avant de remplir ses champs ;
  • lignes 48-59 : les accesseurs (méthodes [getX] et [setX]) : l'entité est un JavaBean. Il n'y a pas de [setId] ni de [setVersion] : ces deux valeurs ne sont écrites que par [Hibernate], qui accède directement aux champs.

9.1.4. La couche DAO

Le fichier [dao/PersonnesDao.java] :

package exemple28.dao;

import java.util.List;
import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;

import exemple28.entities.Personne;

// la couche DAO : des requêtes, aucune règle de gestion.
// Une interface, que Spring Data JPA IMPLÉMENTE lui-même au démarrage.
// JpaRepository<Personne, Integer> apporte déjà findById, save, deleteById...
public interface PersonnesDao extends JpaRepository<Personne, Integer> {

    // le nom de la méthode décrit la requête :
    // SELECT ... FROM personnes ORDER BY nom, prenom
    List<Personne> findAllByOrderByNomAscPrenomAsc();

    // une personne de mêmes prénom et nom, autre que [saufId] :
    // SELECT ... WHERE prenom = ? AND nom = ? AND id <> ? LIMIT 1
    Optional<Personne> findFirstByPrenomAndNomAndIdNot(String prenom, String nom, Integer saufId);
}

C'est toute la couche DAO : une interface, sans une ligne de code exécutable.

  • ligne 13 : l'interface hérite de [JpaRepository<Personne, Integer>] (une entité [Personne], dont la clé est un [Integer]). Au démarrage, [Spring Data JPA] fabrique une classe qui implémente cette interface, et en fait un bean, que l'on reçoit par injection. [JpaRepository] apporte déjà les méthodes usuelles : [findById] (lire par la clé, résultat dans un [Optional]), [findAll], [save] ([INSERT] ou [UPDATE]), [saveAndFlush], [deleteById], [count]... ;
  • ligne 17 : une requête dérivée : [Spring Data JPA] analyse le nom de la méthode et en déduit la requête. [findAll] : toutes les personnes ; [By] : sans condition, puisque rien ne suit ; [OrderByNomAscPrenomAsc] : triées par nom, puis par prénom, dans l'ordre croissant ;
  • ligne 21 : [findFirst] : la première personne trouvée ([LIMIT 1]), ou un [Optional] vide ; [ByPrenomAndNomAndIdNot] : dont le prénom et le nom sont ceux donnés en paramètres, et l'identifiant différent du troisième paramètre. C'est utile à la modification d'une fiche, qui ne doit pas se trouver en conflit avec elle-même. La comparaison est faite par [MySQL], donc selon la collation de la base : sans tenir compte des majuscules ni des accents.

Une faute dans le nom d'une méthode ([findAllByOrderByNomm]) est détectée au démarrage : l'application refuse de démarrer, en indiquant la propriété inconnue. Une requête trop complexe pour un nom de méthode s'écrit dans le langage [JPQL] ([Jakarta Persistence Query Language], un SQL qui porte sur les entités et leurs champs plutôt que sur les tables), dans une annotation [@Query] : l'étude de cas en utilise quelques-unes.

9.1.5. La couche métier

Le fichier [metier/MetierException.java] :

1
2
3
4
5
6
7
8
9
package exemple28.metier;

// une règle de gestion refuse l'opération : le message est destiné à l'utilisateur
public class MetierException extends RuntimeException {

    public MetierException(String message) {
        super(message);
    }
}

Une [MetierException] signale qu'une règle de gestion refuse une opération. Ce n'est pas une anomalie du programme : son message est destiné à l'utilisateur. Elle hérite de [RuntimeException] : c'est une exception non vérifiée, qu'une méthode peut lever sans la déclarer ([throws]). Nous allons voir que ce choix compte aussi pour les transactions.

Le fichier [metier/DonneesPersonne.java] :

1
2
3
4
5
package exemple28.metier;

// les données d'une personne, telles que la couche web les transmet
public record DonneesPersonne(String prenom, String nom, int age) {
}

Les données d'une personne, telles que la couche web les transmet : la couche métier ne connaît pas la classe du formulaire.

Le fichier [metier/PersonnesMetier.java] :

package exemple28.metier;

import java.util.List;
import java.util.Objects;

import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import exemple28.dao.PersonnesDao;
import exemple28.entities.Personne;

// la couche MÉTIER : les règles de gestion, et elles seules.
// @Transactional sur la classe : chaque méthode publique s'exécute dans une
// transaction, validée (COMMIT) à la fin de la méthode, annulée (ROLLBACK) si
// elle lève une exception
@Service
@Transactional
public class PersonnesMetier {

    private static final String HOMONYME = "Une personne porte déjà ces prénom et nom.";
    private static final String MODIFIEE = "Cette fiche a été modifiée entre-temps : rechargez-la.";

    private final PersonnesDao dao;

    public PersonnesMetier(PersonnesDao dao) {
        this.dao = dao;
    }

    // readOnly : une transaction en lecture seule
    @Transactional(readOnly = true)
    public List<Personne> lister() {
        return dao.findAllByOrderByNomAscPrenomAsc();
    }

    @Transactional(readOnly = true)
    public Personne lire(int id) {
        return dao.findById(id).orElseThrow(() -> new MetierException("Cette personne n'existe pas (ou plus)."));
    }

    public Personne ajouter(DonneesPersonne donnees) {
        // règle 1 : pas deux personnes de mêmes prénom et nom
        if (dao.findFirstByPrenomAndNomAndIdNot(donnees.prenom(), donnees.nom(), 0).isPresent()) {
            throw new MetierException(HOMONYME);
        }
        return enregistrer(new Personne(donnees.prenom(), donnees.nom(), donnees.age()));
    }

    public Personne modifier(int id, Integer version, DonneesPersonne donnees) {
        Personne personne = lire(id);
        // règle 2 : on ne modifie pas une fiche modifiée entre-temps par un autre
        if (!Objects.equals(personne.getVersion(), version)) {
            throw new MetierException(MODIFIEE);
        }
        if (dao.findFirstByPrenomAndNomAndIdNot(donnees.prenom(), donnees.nom(), id).isPresent()) {
            throw new MetierException(HOMONYME);
        }
        // l'entité lue est "suivie" par JPA : il suffit de la modifier
        personne.setPrenom(donnees.prenom());
        personne.setNom(donnees.nom());
        personne.setAge(donnees.age());
        return enregistrer(personne);
    }

    public Personne supprimer(int id) {
        Personne personne = lire(id);
        dao.deleteById(id);
        return personne;
    }

    // saveAndFlush : INSERT ou UPDATE envoyé TOUT DE SUITE à la base (et non au
    // COMMIT), pour pouvoir intercepter ici les erreurs de la base
    private Personne enregistrer(Personne personne) {
        try {
            return dao.saveAndFlush(personne);
        } catch (ObjectOptimisticLockingFailureException e) {
            // l'UPDATE "WHERE id = ... AND version = ..." n'a modifié aucune ligne
            throw new MetierException(MODIFIEE);
        } catch (DataIntegrityViolationException e) {
            // deux ajouts simultanés : la contrainte d'unicité de la base a refusé le second
            if (String.valueOf(e.getMostSpecificCause().getMessage()).contains("unq_personnes_prenom_nom")) {
                throw new MetierException(HOMONYME);
            }
            throw e;
        }
    }
}

Commentons ce code :

  • ligne 18 : la couche métier est un bean de type service ;
  • ligne 19 : [@Transactional], posée sur la classe, s'applique à chacune de ses méthodes publiques : la méthode s'exécute dans une transaction, ouverte ([START TRANSACTION]) avant son appel, validée ([COMMIT]) à son retour, annulée ([ROLLBACK]) si elle lève une exception non vérifiée. Comme pour [@PreAuthorize] (exemple 26), [Spring] enveloppe le bean dans un proxy, qui ouvre et ferme la transaction autour de l'appel. Tout ce que fait la méthode, y compris les appels au DAO, se passe dans la même transaction et sur la même connexion ;
  • lignes 32-35 : [readOnly = true] : une transaction en lecture seule, qui dispense [Hibernate] de rechercher, à la fin, les entités modifiées ;
  • lignes 37-40 : une personne absente est une [MetierException]. [orElseThrow] rend le contenu de l'[Optional], ou lève l'exception fabriquée par la lambda s'il est vide ;
  • lignes 42-48 : l'ajout. Règle : pas deux personnes de mêmes prénom et nom. On la vérifie avant l'écriture pour donner un message clair. Aucune personne n'a l'identifiant 0 : [IdNot] ne retire ici personne de la recherche ;
  • lignes 50-64 : la modification. Règle supplémentaire, le verrou optimiste (lignes 53-55) : le formulaire de modification a transporté la version de la fiche telle qu'elle était à l'ouverture du formulaire. Si elle a changé entre-temps (un autre utilisateur a modifié la même fiche), on refuse, plutôt que d'écraser silencieusement son travail. [Objects.equals] compare deux [Integer] en acceptant [null] ;
  • ligne 51 : l'appel de [lire] est un appel interne à la classe : il ne passe pas par le proxy, et son [readOnly] ne s'applique pas. [lire] s'exécute simplement dans la transaction de [modifier] ;
  • lignes 60-63 : l'entité lue dans la transaction est suivie par [Hibernate] (on dit qu'elle est « gérée ») : il suffit de modifier ses champs, [Hibernate] détecte les changements et produit un [UPDATE] des colonnes de la table ;
  • lignes 66-70 : la suppression. La personne est lue d'abord : pour vérifier qu'elle existe, et pour que le contrôleur puisse citer son nom ;
  • lignes 74-87 : l'enregistrement. Normalement, [Hibernate] n'envoie les [INSERT] et [UPDATE] à la base qu'à la fin de la transaction, au [COMMIT], donc après la sortie de la méthode : une erreur de la base ne pourrait plus être interceptée ici. [saveAndFlush] enregistre l'entité et envoie tout de suite ([flush]) les ordres SQL en attente ;
  • lignes 77-79 : la vérification de la version, plus haut, ne suffit pas toujours : deux utilisateurs peuvent enregistrer la même fiche au même instant, et passer tous deux la vérification. L'[UPDATE ... WHERE id = ? AND version = ?] du second ne modifie alors aucune ligne ; [Hibernate] s'en aperçoit, et [Spring] lève une [ObjectOptimisticLockingFailureException], qui devient la même [MetierException] ;
  • lignes 80-85 : de même, deux ajouts simultanés de la même personne passent la vérification des homonymes, mais la contrainte d'unicité de la base, elle, ne laisse rien passer. [Spring] traduit les exceptions des différentes bases et des différents ORM en une hiérarchie d'exceptions qui leur est commune : une violation de contrainte devient une [DataIntegrityViolationException]. [getMostSpecificCause()] rend l'exception d'origine, celle du pilote [MySQL], dont le message cite le nom de la contrainte ([Duplicate entry '...' for key 'unq_personnes_prenom_nom']). Une autre violation de contrainte poursuit son chemin (ligne 85) : c'est une anomalie.

Dans tous les cas où une exception quitte une méthode de [PersonnesMetier] (une [MetierException] comprise, puisqu'elle hérite de [RuntimeException]), le proxy annule la transaction : rien de ce qu'a fait la méthode n'est conservé. Attention : par défaut, une exception vérifiée (qui hérite d'[Exception] sans hériter de [RuntimeException]) ne provoque pas d'annulation.

Nous avons vérifié ce dernier cas en envoyant 30 requêtes d'ajout de la même personne au même instant : une seule a été acceptée ; pour d'autres, la base a refusé l'[INSERT] (erreur [MySQL] n° 1062, que [Hibernate] signale dans la console par un avertissement [HHH000247]), et l'utilisateur a reçu le message des homonymes, pas une erreur 500.

9.1.6. La couche web

Le fichier [models/FormulairePersonne.java] :

package exemple28.models;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

// les champs du formulaire et leurs contraintes (cf. exemple 19)
public class FormulairePersonne {

    @NotBlank(message = "Le prénom est obligatoire.")
    @Size(max = 30, message = "30 caractères au plus.")
    private String prenom;

    @NotBlank(message = "Le nom est obligatoire.")
    @Size(max = 30, message = "30 caractères au plus.")
    private String nom;

    @NotNull(message = "L'âge est obligatoire.")
    @Min(value = 0, message = "L'âge doit être compris entre 0 et 150.")
    @Max(value = 150, message = "L'âge doit être compris entre 0 et 150.")
    private Integer age;

    // la version de la fiche, transportée par un champ caché (modification seulement)
    private Integer version;

    public String getPrenom() { return prenom; }
    public void setPrenom(String prenom) { this.prenom = prenom; }

    public String getNom() { return nom; }
    public void setNom(String nom) { this.nom = nom; }

    public Integer getAge() { return age; }
    public void setAge(Integer age) { this.age = age; }

    public Integer getVersion() { return version; }
    public void setVersion(Integer version) { this.version = version; }
}

Le formulaire et ses contraintes de validation (cf. exemple 19), avec la version de la fiche (ligne 26), transportée par un champ caché. L'âge est un [Integer] et non un [int] : un champ vide donne [null], que [@NotNull] signale.

Le fichier [controllers/PersonnesController.java] :

package exemple28.controllers;

import org.springframework.beans.propertyeditors.StringTrimmerEditor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import exemple28.entities.Personne;
import exemple28.metier.DonneesPersonne;
import exemple28.metier.MetierException;
import exemple28.metier.PersonnesMetier;
import exemple28.models.FormulairePersonne;
import jakarta.validation.Valid;

// la couche WEB : elle ne connaît que la couche métier
@Controller
public class PersonnesController {

    private final PersonnesMetier metier;

    public PersonnesController(PersonnesMetier metier) {
        this.metier = metier;
    }

    // les chaînes reçues sont débarrassées de leurs espaces superflus (cf. exemple 19)
    @InitBinder
    public void initBinder(WebDataBinder binder) {
        binder.registerCustomEditor(String.class, new StringTrimmerEditor(true));
    }

    @GetMapping("/")
    public String accueil() {
        return "redirect:/personnes";
    }

    @GetMapping("/personnes")
    public String liste(Model model) {
        model.addAttribute("personnes", metier.lister());
        return "personnes/liste";
    }

    @GetMapping("/personnes/ajout")
    public String ajout(Model model) {
        return formulaire(model, "Ajouter une personne", "/personnes/ajout", new FormulairePersonne(), null);
    }

    @PostMapping("/personnes/ajout")
    public String ajouter(@Valid @ModelAttribute("personne") FormulairePersonne saisie, BindingResult resultat,
            Model model, RedirectAttributes redirection) {
        if (resultat.hasErrors()) {
            return formulaire(model, "Ajouter une personne", "/personnes/ajout", saisie, null);
        }
        try {
            Personne p = metier.ajouter(new DonneesPersonne(saisie.getPrenom(), saisie.getNom(), saisie.getAge()));
            redirection.addFlashAttribute("succes", p.getPrenom() + " " + p.getNom() + " a été ajouté(e).");
            return "redirect:/personnes";
        } catch (MetierException e) {
            // une règle métier refuse : on réaffiche le formulaire, le message en bas de page
            return formulaire(model, "Ajouter une personne", "/personnes/ajout", saisie, e.getMessage());
        }
    }

    @GetMapping("/personnes/{id:\\d+}/modification")
    public String modification(@PathVariable int id, Model model, RedirectAttributes redirection) {
        try {
            Personne p = metier.lire(id);
            FormulairePersonne valeurs = new FormulairePersonne();
            valeurs.setPrenom(p.getPrenom());
            valeurs.setNom(p.getNom());
            valeurs.setAge(p.getAge());
            valeurs.setVersion(p.getVersion());
            return formulaire(model, "Modifier une personne", "/personnes/" + id + "/modification", valeurs, null);
        } catch (MetierException e) {
            redirection.addFlashAttribute("erreur", e.getMessage());
            return "redirect:/personnes";
        }
    }

    @PostMapping("/personnes/{id:\\d+}/modification")
    public String modifier(@PathVariable int id, @Valid @ModelAttribute("personne") FormulairePersonne saisie,
            BindingResult resultat, Model model, RedirectAttributes redirection) {
        String action = "/personnes/" + id + "/modification";
        if (resultat.hasErrors()) {
            return formulaire(model, "Modifier une personne", action, saisie, null);
        }
        try {
            DonneesPersonne donnees = new DonneesPersonne(saisie.getPrenom(), saisie.getNom(), saisie.getAge());
            Personne p = metier.modifier(id, saisie.getVersion(), donnees);
            redirection.addFlashAttribute("succes", p.getPrenom() + " " + p.getNom() + " a été modifié(e).");
            return "redirect:/personnes";
        } catch (MetierException e) {
            return formulaire(model, "Modifier une personne", action, saisie, e.getMessage());
        }
    }

    @PostMapping("/personnes/{id:\\d+}/suppression")
    public String supprimer(@PathVariable int id, RedirectAttributes redirection) {
        try {
            Personne p = metier.supprimer(id);
            redirection.addFlashAttribute("succes", p.getPrenom() + " " + p.getNom() + " a été supprimé(e).");
        } catch (MetierException e) {
            // pas de formulaire à réafficher : l'erreur voyage elle aussi en attribut flash
            redirection.addFlashAttribute("erreur", e.getMessage());
        }
        return "redirect:/personnes";
    }

    // le modèle de la vue du formulaire : titre, adresse d'envoi, valeurs, erreur globale
    private String formulaire(Model model, String titre, String action, FormulairePersonne valeurs, String erreur) {
        model.addAttribute("titre", titre);
        model.addAttribute("action", action);
        model.addAttribute("personne", valeurs);
        model.addAttribute("erreur", erreur);
        return "personnes/formulaire";
    }
}

Commentons ce code :

  • lignes 26-30 : le contrôleur ne connaît que la couche métier ;
  • lignes 33-36 : les chaînes reçues sont débarrassées de leurs espaces de début et de fin, et une chaîne vide devient [null] (cf. exemple 19) ;
  • lignes 38-41 : la racine du site redirige vers la liste ;
  • lignes 54-68 : l'ajout, en trois temps : validation du formulaire (erreurs : réaffichage), appel de la couche métier (succès : message flash et redirection, cf. exemple 20), [MetierException] (réaffichage avec le message en bas de page). Une autre exception n'est pas interceptée : c'est une anomalie, qui produira une erreur 500 ;
  • ligne 70 : [{id:\\d+}] : une variable de chemin contrainte par une expression régulière (cf. exemple 02) : [\d+] désigne une suite de chiffres (la barre oblique inverse est doublée dans une chaîne [Java]). [/personnes/abc/modification] ne correspond à aucune action : erreur 404 ;
  • lignes 70-84 : le formulaire de modification, prérempli avec la fiche lue en base, y compris sa version ;
  • lignes 86-101 : même schéma que l'ajout ; la version renvoyée par le formulaire est transmise à la couche métier ;
  • lignes 103-113 : la suppression. Il n'y a pas de formulaire à réafficher : une éventuelle erreur voyage, elle aussi, en attribut flash ;
  • lignes 116-122 : le modèle de la vue du formulaire, qui sert à l'ajout et à la modification : titre et adresse d'envoi différents, valeurs du formulaire, erreur de la couche métier.

Le fichier [templates/personnes/formulaire.html] :

<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" th:replace="~{layout :: page(~{::main})}">
<body>
<main>
  <h1 class="h3" th:text="${titre}">Ajouter une personne</h1>
  <form method="post" th:action="${action}" th:object="${personne}" novalidate>
    <!--/* la version de la fiche lue : elle reviendra avec le formulaire */-->
    <input type="hidden" th:field="*{version}">
    <div th:replace="~{fragments/champ :: champ(nom='prenom', libelle='Prénom', type='text')}"></div>
    <div th:replace="~{fragments/champ :: champ(nom='nom', libelle='Nom', type='text')}"></div>
    <div th:replace="~{fragments/champ :: champ(nom='age', libelle='Âge', type='number')}"></div>
    <button class="btn btn-primary" type="submit">Enregistrer</button>
    <a class="btn btn-link" href="/personnes">Annuler</a>
  </form>
</main>
</body>
</html>
  • ligne 6 : [th:object] désigne l'objet du formulaire ; [th:action] reçoit l'adresse d'envoi fournie par le contrôleur ;
  • ligne 8 : le champ caché qui transporte la version de la fiche : [th:field="*{version}"] fabrique ses attributs [name] et [value] (cf. exemple 19) ;
  • lignes 9-11 : les trois champs, fabriqués par le fragment [champ] de l'exemple 19.

Le fichier [templates/layout.html] :

<!doctype html>
<html lang="fr" xmlns:th="http://www.thymeleaf.org" th:fragment="page(contenu)">
<head>
  <meta charset="utf-8">
  <title>Personnes</title>
  <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
  <link rel="stylesheet" href="/css/styles.css">
</head>
<body>
  <div class="container py-3" style="max-width: 40rem">
    <!--/* le message de succès (attribut flash, après une redirection) : en haut */-->
    <div class="alert alert-success" role="status" th:if="${succes != null}" th:text="${succes}">succès</div>
    <main th:replace="${contenu}"></main>
    <!--/* l'erreur : de la requête en cours, ou d'avant la redirection (attribut flash) */-->
    <div class="zone-erreur" role="alert" th:if="${erreur != null}" th:text="${erreur}">erreur</div>
  </div>
  <div th:replace="~{fragments/confirmation :: confirmation}"></div>
  <script src="/js/app.js"></script>
</body>
</html>
  • ligne 12 : le message de succès, en haut de la page ;
  • ligne 15 : le message d'erreur, en bas de page : celui de la requête en cours (placé dans le modèle par [formulaire]), ou celui déposé en attribut flash avant une redirection. Dans les deux cas, c'est un attribut [erreur] du modèle de la vue ;
  • lignes 17-18 : la fenêtre de confirmation et son script (cf. exemple 21).

La vue [personnes/liste.html] a la structure de la liste des contacts de l'exemple 21 : pour chaque personne, un lien vers le formulaire de modification et un formulaire POST de suppression, dont l'envoi est confirmé. Le fragment [fragments/champ.html] est celui de l'exemple 19, la fenêtre [fragments/confirmation.html] et le script [static/js/app.js] ceux de l'exemple 21, sans changement.

9.1.7. Exécution

Si les valeurs de [application.properties] ne conviennent pas (un mot de passe pour [root], par exemple), on peut les remplacer sans modifier le fichier, par des variables d'environnement : le nom de la propriété s'écrit en majuscules, les points devenant des caractères de soulignement. Sous [Windows] ([PowerShell]) :

$env:SPRING_DATASOURCE_PASSWORD="secret"; ..\mvnw spring-boot:run

Au démarrage, avec [MariaDB], [Hibernate] affiche un avertissement :

... WARN ... org.hibernate.orm.core : HHH000511: The 5.5.5 version for [org.hibernate.dialect.MySQLDialect] is no longer supported, hence certain features may not work properly.The minimum supported version is 8.0.0. Check the community dialects project for available legacy versions.

Il est sans conséquence : [MariaDB] se présente au pilote [MySQL] avec un numéro de version « 5.5.5 », qu'[Hibernate] prend pour un très vieux [MySQL]. Avec [MySQL] 8, il n'apparaît pas.

La liste, à l'URL http://localhost:5000/ :

Image

Un formulaire invalide :

Image

Un homonyme : la règle métier le détecte. [MySQL] comparant selon la collation de la base, [marie pelissier], écrit en minuscules et sans accent, est lui aussi refusé. Ici, la saisie [Marie Pélissier] :

Image

Un ajout réussi :

Image

Avec [spring.jpa.show-sql=true] dans [application.properties] (ou, sans modifier le fichier, [../mvnw spring-boot:run -Dspring-boot.run.arguments=--spring.jpa.show-sql=true]), la console montre chaque ordre SQL envoyé par [Hibernate]. Pour l'ajout d'une personne, puis l'affichage de la liste qui suit la redirection :

1
2
3
Hibernate: select p1_0.id,p1_0.age,p1_0.nom,p1_0.prenom,p1_0.version from personnes p1_0 where p1_0.prenom=? and p1_0.nom=? and p1_0.id<>? limit ?
Hibernate: insert into personnes (age,nom,prenom,version) values (?,?,?,?)
Hibernate: select p1_0.id,p1_0.age,p1_0.nom,p1_0.prenom,p1_0.version from personnes p1_0 order by p1_0.nom,p1_0.prenom

On y reconnaît la recherche d'homonyme (la requête déduite du nom [findFirstByPrenomAndNomAndIdNot]), l'[INSERT], puis la lecture de la liste ([findAllByOrderByNomAscPrenomAsc]). Les valeurs sont transmises à part, en paramètres (les [?]) : c'est ce qui protège des injections SQL. Les ordres de transaction ([START TRANSACTION], [COMMIT]) ne sont pas affichés. Pour la modification d'une fiche :

1
2
3
Hibernate: select p1_0.id,p1_0.age,p1_0.nom,p1_0.prenom,p1_0.version from personnes p1_0 where p1_0.id=?
Hibernate: select p1_0.id,p1_0.age,p1_0.nom,p1_0.prenom,p1_0.version from personnes p1_0 where p1_0.prenom=? and p1_0.nom=? and p1_0.id<>? limit ?
Hibernate: update personnes set age=?,nom=?,prenom=?,version=? where id=? and version=?

L'[UPDATE] écrit la nouvelle version et vérifie l'ancienne ([where id=? and version=?]) : c'est le verrou optimiste. Et pour une suppression :

Hibernate: select p1_0.id,p1_0.age,p1_0.nom,p1_0.prenom,p1_0.version from personnes p1_0 where p1_0.id=?
Hibernate: delete from personnes where id=? and version=?

[deleteById] n'a pas relu la personne : elle était déjà suivie dans la transaction, depuis l'appel de [lire].

Le verrou optimiste : deux formulaires sont ouverts sur la même fiche ; le premier est enregistré (la version passe de 0 à 1) ; le second, qui transporte encore la version 0, est refusé :

Image

La suppression, avec confirmation :

Image

Image