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

Le cours d'origine ([ASP.NET MVC], 2013) utilisait pour cette dernière portée la session : une mémoire, tenue par le serveur, propre à chaque navigateur. [ASP.NET Core] la propose encore ([AddSession]), mais ce document, comme l'étude de cas, s'en passe : 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 cookie de [TempData] (exemple 20) et du jeton d'authentification (chapitre 8).

Côté serveur, les portées application et requête correspondent aux durées de vie des services de l'injection de dépendances (cf. exemple 06). [ASP.NET Core] en connaît trois :

Enregistrement
Durée de vie
Une instance...
AddSingleton
application
pour toute la vie de l'application, partagée par toutes les requêtes
AddScoped
requête
par requête HTTP, partagée par tous ceux qui la demandent pendant cette requête
AddTransient
aucune
à chaque injection : deux demandes, deux instances

6.1. Exemple 23 : trois portées

Le fichier [Services/CompteurService.cs] :

namespace Exemple23.Services;

// portée APPLICATION (AddSingleton) : une seule instance, partagée par
// TOUTES les requêtes de TOUS les utilisateurs
public class CompteurService
{
    private int total;

    // plusieurs requêtes peuvent s'exécuter EN MÊME TEMPS : Interlocked
    // garantit que deux incrémentations simultanées ne se perdent pas
    public int Incrementer() => Interlocked.Increment(ref total);
}
  • ligne 7 : le service sera un singleton (cf. [Program.cs]) : son champ [total] est donc de portée application ;
  • ligne 11 : 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. [Interlocked.Increment] fait la lecture, l'addition et l'écriture en une seule opération indivisible. Un service singleton doit toujours se méfier de ces accès simultanés.

Le fichier [Services/RequeteService.cs] :

namespace Exemple23.Services;

// portée REQUÊTE (AddScoped) : une NOUVELLE instance pour chaque requête HTTP,
// partagée par tous ceux qui la demandent PENDANT cette requête
public class RequeteService
{
    private static int instancesCreees; // static : un compteur commun à toutes les instances

    public int Numero { get; } = Interlocked.Increment(ref instancesCreees);
}
  • ligne 7 : un champ [static] appartient à la classe, et non à une instance : il est commun à toutes les instances. Il compte les instances créées ;
  • ligne 9 : chaque instance reçoit un numéro à sa création. Le service sera de portée requête : le numéro change à chaque requête.

Le fichier [Services/OutilService.cs] :

namespace Exemple23.Services;

// portée TRANSIENT (AddTransient) : une NOUVELLE instance à CHAQUE injection,
// même au cours d'une seule requête
public class OutilService
{
    private static int instancesCreees;

    public int Numero { get; } = Interlocked.Increment(ref instancesCreees);
}

Le même compteur d'instances, pour un service [transient].

Le fichier [Program.cs] :

using System.Text.Encodings.Web;
using System.Text.Unicode;
using Exemple23.Services;
using Microsoft.Extensions.FileProviders;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
// les trois durées de vie d'un service
builder.Services.AddSingleton<CompteurService>(); // une instance pour toute l'application
builder.Services.AddScoped<RequeteService>();     // une instance par requête
builder.Services.AddTransient<OutilService>();    // une instance par injection

var app = builder.Build();

app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, "..", "commun", "bootstrap"))),
    RequestPath = "/bootstrap",
});

// portée REQUÊTE : HttpContext.Items naît et meurt avec la requête
app.Use(async (contexte, suite) =>
{
    contexte.Items["debut"] = DateTime.Now.ToString("HH:mm:ss.fff");
    await suite(contexte);
});

app.MapControllers();
app.Run();
  • lignes 10-12 : les trois durées de vie ;
  • lignes 23-26 : un middleware range l'heure de réception de la requête dans [HttpContext.Items], un dictionnaire qui naît et meurt avec la requête. Tout ce qui traite la requête (filtres, contrôleur, vue) peut y lire et y écrire. C'est l'équivalent de [res.locals] d'[Express].

Le fichier [Controllers/PorteesController.cs] :

using Exemple23.Services;
using Microsoft.AspNetCore.Mvc;

namespace Exemple23.Controllers;

public record PorteesModele(int Total, int Instance, int Outil, int MesVisites);

public class PorteesController(CompteurService compteur, RequeteService requete, OutilService outil) : Controller
{
    [HttpGet("/")]
    public IActionResult Portees()
    {
        // portée CLIENT : une donnée conservée par le NAVIGATEUR (cookie),
        // renvoyée au serveur à chaque requête
        int.TryParse(Request.Cookies["mesVisites"], out var mesVisites);
        mesVisites++;
        Response.Cookies.Append("mesVisites", mesVisites.ToString(),
            new CookieOptions { HttpOnly = true, SameSite = SameSiteMode.Lax });
        return View(new PorteesModele(compteur.Incrementer(), requete.Numero, outil.Numero, mesVisites));
    }
}
  • ligne 8 : les trois services sont injectés ;
  • lignes 15-18 : la portée client : un compteur gardé dans un cookie. [Request.Cookies["mesVisites"]] lit le cookie envoyé par le navigateur ([null] à la première visite). [int.TryParse] le convertit en entier ; si la conversion échoue, [mesVisites] vaut 0. L'action l'incrémente et redépose le cookie ;
  • ligne 19 : les valeurs sont passées à la vue.

Le fichier [Views/Portees/Portees.cshtml] :

@model PorteesModele
@* @inject : la vue demande elle aussi les services, au cours de la MÊME requête *@
@inject RequeteService RequeteDeLaVue
@inject OutilService OutilDeLaVue
<h1 class="h3">Les portées des données</h1>
<ul>
  <li>application : @Model.Total visite(s) du site, tous navigateurs confondus</li>
  <li>requête : instance n° @Model.Instance de RequeteService (n° @RequeteDeLaVue.Numero dans la vue),
    requête reçue à @Context.Items["debut"]</li>
  <li>transient : instance n° @Model.Outil de OutilService (n° @OutilDeLaVue.Numero dans la vue)</li>
  <li>client : @Model.MesVisites visite(s) de ce navigateur</li>
</ul>
  • lignes 3-4 : la directive [@inject Type Nom] demande un service à l'injection de dépendances, et le range dans une propriété de la vue. La vue est exécutée 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 9 : la valeur de portée requête, déposée par le middleware.

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

Image

Simulons deux navigateurs avec [curl] et deux fichiers de cookies, [a.txt] et [b.txt]. Le navigateur A fait deux requêtes, puis le navigateur B une, puis A une dernière :

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 :
application : 1 visite(s) du site, tous navigateurs confondus
requête : instance n° 1 de RequeteService (n° 1 dans la vue), requête reçue à 12:19:46.574
transient : instance n° 1 de OutilService (n° 2 dans la vue)
client : 1 visite(s) de ce navigateur

navigateur A :
application : 2 visite(s) du site, tous navigateurs confondus
requête : instance n° 2 de RequeteService (n° 2 dans la vue), requête reçue à 12:19:46.671
transient : instance n° 3 de OutilService (n° 4 dans la vue)
client : 2 visite(s) de ce navigateur

navigateur B :
application : 3 visite(s) du site, tous navigateurs confondus
requête : instance n° 3 de RequeteService (n° 3 dans la vue), requête reçue à 12:19:46.687
transient : instance n° 5 de OutilService (n° 6 dans la vue)
client : 1 visite(s) de ce navigateur

navigateur A :
application : 4 visite(s) du site, tous navigateurs confondus
requête : instance n° 4 de RequeteService (n° 4 dans la vue), requête reçue à 12:19:46.696
transient : instance n° 7 de OutilService (n° 8 dans la vue)
client : 3 visite(s) de ce navigateur

(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 et la vue reçoivent la même instance ;
  • chaque injection de [OutilService] crée une instance : le contrôleur et la vue en reçoivent deux différentes, et chaque requête en crée deux nouvelles ;
  • le compteur client est propre à chaque navigateur : A en est à 3 visites, B à 1.

Le 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 OK
Content-Type: text/html; charset=utf-8
Set-Cookie: mesVisites=42; path=/; samesite=lax; httponly

<!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 : 6 visite(s) du site, tous navigateurs confondus</li>
  <li>requête : instance n° 6 de RequeteService (n° 6 dans la vue),
    requête reçue à 12:19:47.374</li>
  <li>transient : instance n° 11 de OutilService (n° 12 dans 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 durées de vie : un service ne doit pas dépendre d'un service de durée de vie plus courte. Un singleton qui recevrait un service [Scoped] le garderait pour toujours, bien au-delà de la requête pour laquelle il a été créé. [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. 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
services singletons ([CacheListes], [Traducteur], [CaptchaService]...)
requête
accès à la base ([DbContext]), dépôts, couche métier, modèle commun des vues ; utilisateur connecté
services [Scoped] ([RdvMedecinsDbContext], [MetierService], [ApplicationModel]...) ; [HttpContext.User], [HttpContext.Items]
client
jeton d'authentification, langue, message flash
cookies