Skip to content

6. Portées des données

Une application web sert de nombreux utilisateurs à la fois, et chacun d'eux enchaîne de nombreuses requêtes. Une donnée peut donc être partagée à trois niveaux, qu'on appelle sa portée :

  • la portée application : la donnée est partagée par tous les utilisateurs, pendant toute la vie du serveur (la liste des médecins, par exemple) ;
  • la portée requête : la donnée n'existe que le temps de traiter une requête (le modèle d'une vue) ;
  • la portée client : la donnée est propre à un utilisateur, et doit survivre d'une requête à la suivante (la langue choisie, l'utilisateur connecté).

Les applications web ont longtemps utilisé pour cette dernière portée la session : une mémoire, tenue par le serveur, propre à chaque navigateur, et retrouvée grâce à un cookie d'identification ([JSESSIONID] en [Java]). [Spring MVC] s'en sert encore par défaut pour les attributs flash (exemple 20). Mais l'étude de cas s'en passe, comme les exemples de ce document (à part les attributs flash de quelques exemples, comme les exemples 20 et 21) : les données de portée client sont gardées par le navigateur, dans des cookies. Le serveur ne garde rien entre deux requêtes d'un même utilisateur : on dit qu'il est sans état ([stateless]). C'est ce qui permet, par exemple, de faire tourner plusieurs serveurs identiques derrière un répartiteur de charge. La contrepartie : un cookie est sous le contrôle de l'utilisateur, qui peut le lire et le modifier. On n'y met donc que des données sans secret, ou des données signées ou chiffrées par le serveur, qui détecte alors toute modification : c'est le cas du jeton d'authentification (chapitre 8).

Côté serveur, les portées application et requête correspondent aux portées des beans de [Spring] (cf. exemple 06). Un bean est créé et gardé par [Spring] ; sa portée dit combien d'instances en sont créées, et pour combien de temps. Trois nous intéressent :

Annotation
Portée
Une instance...
(aucune)
singleton
pour toute la vie de l'application, partagée par toutes les requêtes. C'est la portée par défaut
[@RequestScope]
requête
par requête HTTP, partagée par tous ceux qui la demandent pendant cette requête
[@Scope("prototype")]
prototype
à chaque fois qu'on demande le bean à [Spring] : deux demandes, deux instances

Ce sont les durées de vie [Singleton], [Scoped] et [Transient] d'[ASP.NET Core]. Il existe aussi une portée session ([@SessionScope]), que nous n'utiliserons pas.

6.1. Exemple 23 : trois portées

23-portees/
├── src/main/java/exemple23/
│   ├── config/
│   │   ├── DebutFilter.java
│   │   └── WebConfig.java
│   ├── controllers/
│   │   └── PorteesController.java
│   ├── services/
│   │   ├── CompteurService.java
│   │   ├── OutilService.java
│   │   └── RequeteService.java
│   └── Exemple23Application.java
├── src/main/resources/
│   ├── templates/portees/
│   │   └── portees.html
│   └── application.properties
└── pom.xml

Les fichiers [pom.xml], [application.properties], [Exemple23Application.java] et [WebConfig.java] (qui ne fait que servir les fichiers de [Bootstrap]) sont ceux des exemples précédents.

6.1.1. Les trois beans

Le fichier [services/CompteurService.java] :

package exemple23.services;

import java.util.concurrent.atomic.AtomicInteger;

import org.springframework.stereotype.Service;

// portée APPLICATION (singleton, la portée par défaut des beans) : une seule
// instance, partagée par TOUTES les requêtes de TOUS les utilisateurs
@Service
public class CompteurService {

    // plusieurs requêtes peuvent s'exécuter EN MÊME TEMPS : AtomicInteger
    // garantit que deux incrémentations simultanées ne se perdent pas
    private final AtomicInteger total = new AtomicInteger();

    public int incrementer() {
        return total.incrementAndGet();
    }
}
  • ligne 9 : un bean, sans autre précision : c'est un singleton. Son champ [total] est donc de portée application ;
  • lignes 14-18 : un serveur web traite plusieurs requêtes en même temps, sur plusieurs fils d'exécution ([threads]). Si deux requêtes exécutent [total++] au même instant, elles peuvent lire toutes deux la même valeur, et une incrémentation est perdue. Un [AtomicInteger] est un entier dont la méthode [incrementAndGet] fait la lecture, l'addition et l'écriture en une seule opération indivisible. Un bean singleton doit toujours se méfier de ces accès simultanés.

Le fichier [services/RequeteService.java] :

package exemple23.services;

import java.util.concurrent.atomic.AtomicInteger;

import org.springframework.stereotype.Service;
import org.springframework.web.context.annotation.RequestScope;

// portée REQUÊTE (@RequestScope) : une NOUVELLE instance pour chaque requête HTTP,
// partagée par tous ceux qui la demandent PENDANT cette requête
@Service
@RequestScope
public class RequeteService {

    // static : un compteur commun à toutes les instances
    private static final AtomicInteger INSTANCES_CREEES = new AtomicInteger();

    private final int numero = INSTANCES_CREEES.incrementAndGet();

    public int getNumero() {
        return numero;
    }
}
  • ligne 11 : l'annotation [@RequestScope] donne au bean la portée requête ;
  • ligne 15 : un champ [static] appartient à la classe, et non à une instance : il est commun à toutes les instances. Il compte les instances créées ;
  • ligne 17 : chaque instance reçoit un numéro à sa création. Le bean étant de portée requête, le numéro change à chaque requête.

Le fichier [services/OutilService.java] :

package exemple23.services;

import java.util.concurrent.atomic.AtomicInteger;

import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Service;

// portée PROTOTYPE : une NOUVELLE instance à CHAQUE fois qu'on demande le bean à Spring
@Service
@Scope("prototype")
public class OutilService {

    private static final AtomicInteger INSTANCES_CREEES = new AtomicInteger();

    private final int numero = INSTANCES_CREEES.incrementAndGet();

    public int getNumero() {
        return numero;
    }
}

Le même compteur d'instances, pour un bean de portée [prototype] (ligne 10).

6.1.2. Une donnée de portée requête : les attributs de la requête

Le fichier [config/DebutFilter.java] :

package exemple23.config;

import java.io.IOException;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

// un filtre (cf. chapitre 7) : exécuté pour chaque requête, AVANT Spring MVC.
// Portée REQUÊTE : les attributs de la requête naissent et meurent avec elle.
@Component
public class DebutFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain suite)
            throws ServletException, IOException {
        request.setAttribute("debut", LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss.SSS")));
        suite.doFilter(request, response); // on passe la main à la suite
    }
}
  • lignes 17-18 : un filtre de servlet (le chapitre suivant les présente en détail). Il voit passer chaque requête avant [Spring MVC]. La classe [OncePerRequestFilter] de [Spring] garantit qu'il n'est exécuté qu'une fois par requête ; l'annotation [@Component] suffit pour que [Spring Boot] l'ajoute à la chaîne des filtres ;
  • ligne 23 : le filtre range l'heure de réception de la requête dans un attribut de la requête. L'objet [HttpServletRequest] contient un dictionnaire d'attributs, qui naît et meurt avec la requête : tout ce qui la traite (filtres, contrôleur, vue) peut y lire et y écrire. C'est l'équivalent de [HttpContext.Items] d'[ASP.NET Core] ;
  • ligne 24 : le filtre passe la main à la suite : les autres filtres, puis [Spring MVC].

6.1.3. Le contrôleur

Le fichier [controllers/PorteesController.java] :

package exemple23.controllers;

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;

import exemple23.services.CompteurService;
import exemple23.services.OutilService;
import exemple23.services.RequeteService;
import jakarta.servlet.http.HttpServletResponse;

// un contrôleur est lui-même un bean SINGLETON : il est créé une seule fois
@Controller
public class PorteesController {

    private final CompteurService compteur;
    // un bean de portée requête injecté dans un singleton : Spring injecte un
    // "proxy", qui transmet chaque appel à l'instance de la requête EN COURS
    private final RequeteService requete;
    // un prototype injecté dans un singleton : créé UNE fois, avec le contrôleur
    private final OutilService outilInjecte;
    // pour obtenir un nouveau prototype à chaque fois : le demander à Spring
    private final ObjectProvider<OutilService> outils;

    public PorteesController(CompteurService compteur, RequeteService requete, OutilService outilInjecte,
            ObjectProvider<OutilService> outils) {
        this.compteur = compteur;
        this.requete = requete;
        this.outilInjecte = outilInjecte;
        this.outils = outils;
    }

    public record PorteesModele(int total, int instance, int outilInjecte, int outilDemande, int mesVisites) {
    }

    @GetMapping("/")
    public String portees(@CookieValue(name = "mesVisites", required = false) String cookie,
            HttpServletResponse response, Model model) {
        // portée CLIENT : une donnée conservée par le NAVIGATEUR (cookie),
        // renvoyée au serveur à chaque requête
        int mesVisites = versEntier(cookie) + 1;
        ResponseCookie nouveau = ResponseCookie.from("mesVisites", String.valueOf(mesVisites))
                .path("/").httpOnly(true).sameSite("Lax").build();
        response.addHeader(HttpHeaders.SET_COOKIE, nouveau.toString());
        model.addAttribute("modele", new PorteesModele(compteur.incrementer(), requete.getNumero(),
                outilInjecte.getNumero(), outils.getObject().getNumero(), mesVisites));
        return "portees/portees";
    }

    // "41" -> 41 ; null ou "abc" -> 0
    private static int versEntier(String texte) {
        try {
            return Integer.parseInt(texte);
        } catch (NumberFormatException e) {
            return 0;
        }
    }
}

Commentons ce code :

  • lignes 16-17 : un contrôleur est lui-même un bean, et donc un singleton : [Spring] le crée une seule fois, et lui injecte ses dépendances à ce moment-là, une fois pour toutes (lignes 29-35). C'est là que les choses deviennent intéressantes ;
  • ligne 20 : un singleton injecté dans un singleton : rien de particulier ;
  • ligne 23 : un bean de portée requête injecté dans un singleton. Au démarrage, il n'y a aucune requête en cours, et donc aucune instance de [RequeteService] à injecter. [Spring] injecte à la place un proxy (un intermédiaire) : un objet qui a les mêmes méthodes que [RequeteService], et qui transmet chaque appel à l'instance de la requête en cours, créée au besoin. C'est ce que fait [@RequestScope] ;
  • ligne 25 : un bean prototype injecté dans un singleton. [Spring] crée une instance... une seule, lors de l'injection. Le contrôleur la gardera toute sa vie : ce champ ne change jamais. C'est le piège de la portée prototype : elle ne crée une nouvelle instance qu'à chaque demande au conteneur de [Spring], et un singleton ne demande ses dépendances qu'une fois ;
  • ligne 27 : pour obtenir une nouvelle instance à chaque fois, le contrôleur reçoit un [ObjectProvider<OutilService>] : un objet capable de demander un [OutilService] à [Spring] quand on le veut, par sa méthode [getObject()] (ligne 50) ;
  • ligne 37 : le modèle de la vue, un record (cf. exemple 03) ;
  • ligne 41 : la portée client, un compteur gardé dans un cookie. L'annotation [@CookieValue] lie le paramètre au cookie [mesVisites] envoyé par le navigateur ; [required = false] : le paramètre vaut [null] si le cookie est absent (à la première visite) ;
  • ligne 45 : le compteur du navigateur, augmenté de 1. La méthode [versEntier] (lignes 55-61) convertit le texte du cookie en entier ; [Integer.parseInt] lève une [NumberFormatException] si le texte n'est pas un entier, ou s'il vaut [null] : le compteur repart alors de 0 ;
  • lignes 46-48 : le cookie est redéposé avec sa nouvelle valeur. [ResponseCookie] est une classe de [Spring] qui fabrique la valeur d'un entête [Set-Cookie] : on enchaîne les méthodes qui en fixent les caractéristiques (chemin, [HttpOnly], [SameSite]), puis [build()] fabrique l'objet. L'entête est ajouté à la réponse par [response.addHeader] ;
  • lignes 49-50 : les valeurs sont passées à la vue : le compteur application, le numéro de l'instance de [RequeteService] de la requête en cours, celui du prototype injecté, celui d'un prototype demandé à l'instant, et le compteur client.

6.1.4. La vue

Le fichier [templates/portees/portees.html] :

<!doctype html>
<html lang="fr" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="utf-8">
  <title>Portées</title>
  <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
</head>
<body>
  <div class="container py-3" th:object="${modele}">
    <h1 class="h3">Les portées des données</h1>
    <!--/* @requeteService, @outilService : la vue demande elle aussi les beans à
           Spring, au cours de la MÊME requête */-->
    <ul>
      <li>application : [[*{total}]] visite(s) du site, tous navigateurs confondus</li>
      <li>requête : instance n° [[*{instance}]] de RequeteService (n° [[${@requeteService.numero}]] dans la vue),
        requête reçue à [[${debut}]]</li>
      <li>prototype : instance n° [[*{outilInjecte}]] (injectée dans le contrôleur),
        n° [[*{outilDemande}]] (demandée par le contrôleur), n° [[${@outilService.numero}]] (demandée par la vue)</li>
      <li>client : [[*{mesVisites}]] visite(s) de ce navigateur</li>
    </ul>
  </div>
</body>
</html>
  • ligne 9 : [th:object] (cf. exemples 18 et 19) désigne l'objet [modele] ; les expressions [*{...}] des balises descendantes en désignent les propriétés : [*{total}] vaut [${modele.total}] ;
  • ligne 14 : [[[...]]] insère la valeur d'une expression dans le texte, échappée, comme le ferait [th:text] (cf. exemple 14) ;
  • lignes 15 et 18 : l'expression [${@nom}] désigne le bean de ce nom (cf. exemple 16). La vue demande à son tour les beans [requeteService] et [outilService] à [Spring], au cours de la même requête que l'action : on va pouvoir comparer les instances reçues par le contrôleur et par la vue ;
  • ligne 16 : l'attribut de la requête déposé par le filtre. Pour [Thymeleaf], les attributs de la requête sont des variables comme les autres.

6.1.5. Exécution

Une page, à l'URL http://localhost:5000/ :

Image

Simulons deux navigateurs avec [curl] et deux fichiers de cookies, [a.txt] et [b.txt] : l'option [-b] envoie les cookies du fichier, l'option [-c] y enregistre ceux que dépose le serveur. Le navigateur A fait deux requêtes, puis le navigateur B une, puis A une dernière (le serveur vient d'être relancé) :

1
2
3
4
curl -c a.txt -b a.txt http://localhost:5000/
curl -c a.txt -b a.txt http://localhost:5000/
curl -c b.txt -b b.txt http://localhost:5000/
curl -c a.txt -b a.txt http://localhost:5000/
navigateur A :
<li>application : 1 visite(s) du site, tous navigateurs confondus</li>
<li>requête : instance n° 1 de RequeteService (n° 1 dans la vue),
requête reçue à 18:47:52.627</li>
<li>prototype : instance n° 1 (injectée dans le contrôleur),
n° 2 (demandée par le contrôleur), n° 3 (demandée par la vue)</li>
<li>client : 1 visite(s) de ce navigateur</li>

navigateur A :
<li>application : 2 visite(s) du site, tous navigateurs confondus</li>
<li>requête : instance n° 2 de RequeteService (n° 2 dans la vue),
requête reçue à 18:47:53.015</li>
<li>prototype : instance n° 1 (injectée dans le contrôleur),
n° 4 (demandée par le contrôleur), n° 5 (demandée par la vue)</li>
<li>client : 2 visite(s) de ce navigateur</li>

navigateur B :
<li>application : 3 visite(s) du site, tous navigateurs confondus</li>
<li>requête : instance n° 3 de RequeteService (n° 3 dans la vue),
requête reçue à 18:47:53.099</li>
<li>prototype : instance n° 1 (injectée dans le contrôleur),
n° 6 (demandée par le contrôleur), n° 7 (demandée par la vue)</li>
<li>client : 1 visite(s) de ce navigateur</li>

navigateur A :
<li>application : 4 visite(s) du site, tous navigateurs confondus</li>
<li>requête : instance n° 4 de RequeteService (n° 4 dans la vue),
requête reçue à 18:47:53.133</li>
<li>prototype : instance n° 1 (injectée dans le contrôleur),
n° 8 (demandée par le contrôleur), n° 9 (demandée par la vue)</li>
<li>client : 3 visite(s) de ce navigateur</li>

(seuls les éléments de la liste sont reproduits). On constate que :

  • le compteur application compte toutes les visites : 1, 2, 3, 4 ;
  • chaque requête a sa propre instance de [RequeteService] : 1, 2, 3, 4. Au cours d'une requête, le contrôleur (par son proxy) et la vue atteignent la même instance ;
  • le prototype injecté dans le contrôleur est toujours le n° 1 : créé au démarrage avec le contrôleur, il ne sera jamais remplacé. Les prototypes demandés sont nouveaux à chaque fois : le contrôleur et la vue en obtiennent deux différents, et chaque requête en crée deux nouveaux ;
  • le compteur client est propre à chaque navigateur : A en est à 3 visites, B à 1.

Le fichier [a.txt], tenu par [curl], contient le cookie du navigateur A :

1
2
3
4
5
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

#HttpOnly_localhost    FALSE    /    FALSE    0    mesVisites    3

Ce cookie est sous le contrôle du client, qui peut envoyer la valeur qu'il veut :

curl -i -b "mesVisites=41" http://localhost:5000/
HTTP/1.1 200
Set-Cookie: mesVisites=42; Path=/; HttpOnly; SameSite=Lax
Content-Type: text/html;charset=UTF-8

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Portées</title>
  <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
</head>
<body>
  <div class="container py-3">
    <h1 class="h3">Les portées des données</h1>

    <ul>
      <li>application : 5 visite(s) du site, tous navigateurs confondus</li>
      <li>requête : instance n° 5 de RequeteService (n° 5 dans la vue),
        requête reçue à 18:47:53.178</li>
      <li>prototype : instance n° 1 (injectée dans le contrôleur),
        n° 10 (demandée par le contrôleur), n° 11 (demandée par la vue)</li>
      <li>client : 42 visite(s) de ce navigateur</li>
    </ul>
  </div>
</body>
</html>

Pour un compteur de visites, c'est sans importance. Pour une donnée sensible (l'identité de l'utilisateur, son rôle), le serveur doit pouvoir détecter une falsification : le chapitre 8 montre comment.

Une règle découle de ces portées : un bean ne doit pas garder un bean de portée plus courte. Un singleton qui recevrait directement une instance de portée requête la garderait pour toujours, bien au-delà de la requête pour laquelle elle a été créée. [ASP.NET Core] vérifie cette règle au démarrage, en mode développement, et refuse de démarrer si elle est violée. [Spring] procède autrement : pour la portée requête, [@RequestScope] fait injecter un proxy, qui rend l'injection sans danger ; pour la portée prototype, il ne vérifie rien, et l'on vient de voir le résultat : un « prototype » qui se comporte comme un singleton. Quand on veut réellement une nouvelle instance, on la demande à [Spring] par un [ObjectProvider].

Dans l'étude de cas :

Portée
Données
Où ?
application
listes des médecins et des clients, dictionnaires, captchas en attente, compteurs de tentatives
beans singletons ([ApplicationModel], [Traducteur], [CaptchaService], [LimiteurTentativesService]...) ; la couche métier et l'accès aux données sont eux aussi des singletons, sans état
requête
modèle des vues, utilisateur connecté
le modèle ([Model]), complété par [ModeleCommun] ; le contexte de sécurité de [Spring Security]
client
jeton d'authentification, langue, message flash
cookies