Garder l'état entre les demandes dans ASP.NET Core: Un guide pratique, sans objet (MVC, Pages de rasoir, API minimales) (Français (French))

Garder l'état entre les demandes dans ASP.NET Core: Un guide pratique, sans objet (MVC, Pages de rasoir, API minimales)

Sunday, 09 November 2025

//

63 minute read

MISE À JOUR (2025-11-10): Ajout d'exemples plus pratiques de ma base de code réelle montrant des modèles du monde réel, des compromis, des gotchas, et l'évolution des approches simples à sophistiquées de gestion d'état. Comprend des modèles IMemoryCache détaillés, l'utilisation de ViewBag (bon et mauvais), ResponseCache / OutputCache stratégies, et les leçons apprises de la production.

Présentation

HTTP est célèbrement apatride. Votre application... n'est pas. Les utilisateurs se connectent, ajoutent des éléments aux paniers, sautent entre les pages, retournent demain, et vous attendez à ce que vous vous en souveniez. Dans ASP.NET Core (MVC, Pages Razor, API Minimal), il y a beaucoup de façons de préserver et de transférer l'état entre les requêtes. Certains sont par demande seulement. Certains sont pour une session. Certains vivent dans le client. Certains sont distribués et survivent redémarrent le serveur. Chaque choix a des compromis sur la sécurité, les performances, l'échelle et l'ergonomie du développeur.

Cet article catalogue les options, montre des exemples concrets et collables dans les trois piles, et vous donne des conseils de décision afin que vous puissiez choisir le bon outil pour le travail.

REMARQUE: Ceci fait partie de mes expériences avec l'IA (couture assistée) + ma propre édition. Même voix, même pragmatisme; juste des doigts plus rapides.

Le paysage en un coup d'oeil

flowchart LR
  subgraph Client
    Q[Query String]
    R[Route Values]
    H[Headers]
    F[Form/Hidden Fields]
    CK[Cookies]
    LS[Local/Session Storage]
  end
  subgraph Server
    I[HttpContext.Items<br/>\nper request only]
    TD[TempData<br/>\none redirect]
    SS[Session]
    MC[IMemoryCache]
    DC[IDistributedCache]
    AU[Auth Cookie / Claims]
    JT[JWT / Bearer]
    DB[Database / Durable Store]
    BUS[Outbox / Queue]
  end
  Q --> Model[Model Binding]
  R --> Model
  F --> Model
  H --> Model
  CK --> App[Your Code]
  Model --> App
  App -->|Set| CK
  App -->|Set| TD
  App -->|Set| SS
  App -->|Set| MC
  App -->|Set| DC
  App -->|Issue| AU
  App -->|Issue| JT
  App -->|Persist| DB
  • Client-transporté: requête, itinéraire, en-têtes, formulaires, cookies, JWT. Échelle horizontalement mais est visible pour le client (doit être validé/signé/crypté le cas échéant).
  • Serveur : TempData, Session, caches, DB. Nécessite une stratégie d'affinité ou de distribution.
  • Par demande : HttpContext.Items - utiles pour transmettre des données en interne lors d'une seule demande.

Règles d'or (avant de plonger dans les API)

  1. La sécurité d'abord : les fuites d'État constituent une menace réelle. L'état côté serveur (Session, caches in-memory) peut s'écouler entre les utilisateurs par des erreurs de codage, des conditions de course ou une mauvaise gestion du cycle de vie.
  2. Préférez les modèles de "serveur sans état" lorsque vous avez besoin d'échelle horizontale (état push aux jetons clients, magasins durables, ou caches distribués).
  3. Ne jamais faire confiance à l'état contrôlé par le client. Valider, signer et/ou chiffrer.
  4. Garder de grandes données hors des cookies et des en-têtes ; elles gonflent chaque demande.
  5. Utilisez TempData uniquement pour les images uniques post-redirect-Get (PRG) comme des messages flash.
  6. Utilisez Session uniquement lorsque vous devez maintenir l'état conversationnel côté serveur et que vous avez planifié la distribution – et soyez paranoïaque sur la sécurité.
  7. Les demandes portent sur l'identité et l'autorisation grossière, et non sur l'état général de l'application.
  8. Cache n'est pas une source de vérité. Remontez-le avec un magasin durable si les données comptent.

Par État requérant: HttpContext.Items

  • Portée : demande actuelle seulement (démarrage à l'extrémité du pipeline)
  • Utilisation pour: en passant des valeurs calculées entre le middleware et les terminaux/contrôleurs
  • Échelle: aucun impact
  • Sécurité : uniquement pour le serveur
sequenceDiagram
  participant M as Middleware
  participant E as Endpoint/Controller
  M->>M: Compute TenantId
  M->>E: HttpContext.Items["TenantId"] = 42
  E->>E: Read Items["TenantId"]

Exemple de Middleware (toutes les piles) :

app.Use(async (context, next) =>
{
    var tenantId = context.Request.Headers["X-TenantId"].FirstOrDefault() ?? "public";
    context.Items["TenantId"] = tenantId;
    await next(context);
});
  • Paramètres minimaux de l'API:
app.MapGet("/whoami", (HttpContext ctx) => new { Tenant = ctx.Items["TenantId"] });
  • Contrôleur MVC :
public IActionResult WhoAmI() => Json(new { Tenant = HttpContext.Items["TenantId"] });
  • Gestionnaire de page de rasoir :
public IActionResult OnGet() => new JsonResult(new { Tenant = HttpContext.Items["TenantId"] });

État transitoire client-porté: Valeurs de l'itinéraire et chaîne de requête

  • Portée : demande actuelle; le client la porte explicitement.
  • Utilisation pour : contexte de navigation, filtrage, pagination, identité des ressources
  • Sécurité: doit valider/autoriser; ne pas intégrer de secrets

Exemples

  • API minimales:
app.MapGet("/orders/{id:int}", (int id, int? page) => Results.Ok(new { id, page }));
// GET /orders/5?page=2
  • MVC:
[HttpGet("/orders/{id:int}")]
public IActionResult Details(int id, int? page)
  => View(new { id, page });
  • Pages de rasoir (Orders/Details.cshtml.cs) :
public IActionResult OnGet(int id, int? page)
  => Page();

Générer des liens qui préservent l'état:

// Razor Pages
<a asp-page="/Orders/Details" asp-route-id="@Model.Id" asp-route-page="@Model.Page">Next</a>

// MVC
@Html.ActionLink("Next", "Details", "Orders", new { id = Model.Id, page = Model.Page }, null)

En-têtes: ID de corrélation, Clés d'Idempotency, Flags de fonctionnalité

  • Portée : demande actuelle, éventuellement reprise dans les réponses
  • Utilisation pour: traçage, sécurité de réessayer, drapeaux A/B
  • Sécurité : traiter comme une entrée non fiable, valider/liste blanche
app.Use(async (ctx, next) =>
{
    var correlationId = ctx.Request.Headers["X-Correlation-Id"].FirstOrDefault()
                      ?? Guid.NewGuid().ToString("n");
    ctx.Response.Headers["X-Correlation-Id"] = correlationId;
    await next(ctx);
});

Idempotency (pattern):

flowchart TD
  C[Client POST /pay\nIdempotency-Key:k] --> S{Seen k?}
  S -- No --> E[Execute charge]
  E --> P[Persist result by k]
  P --> R[Return 200 + result]
  S -- Yes --> L[Load result by k]
  L --> R

Formulaires et champs cachés (PRG)

  • Champ d'application : demande suivante seulement (le client affiche les valeurs en arrière)
  • Utiliser pour: étapes de l'assistant, jetons anti-forgery, garder de petits bits d'état à travers PRG
  • Sécurité: toujours valider; combiner avec l'antiforge

Profil PRG dans les pages MVC/Razor :

sequenceDiagram
  participant U as User
  participant P as POST Action
  participant R as Redirect
  participant G as GET Action
  U->>P: POST form
  P-->>R: 302 Redirect
  U->>G: GET redirected
  G-->>U: Final page (no resubmits)
  • MVC:
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Save(SettingsModel model)
{
    // validate & persist
    return RedirectToAction(nameof(Summary), new { tab = model.SelectedTab });
}
  • Pages de rasoir :
public IActionResult OnPost(SettingsModel model)
{
    return RedirectToPage("/Settings/Summary", new { tab = model.SelectedTab });
}

Cookies : petits, signés, parfois chiffrés

  • Portée : chaque demande du navigateur jusqu'à l'expiration
  • Utilisation pour: préférences, drapeaux non sensibles, consentement; cookies d'auth (section séparée)
  • compromis: limites de taille (~4Ko par cookie), impact de performance, doivent respecter les lois sur le consentement

Exemple d'API minimal :

app.MapPost("/prefs/theme/{value}", (HttpContext ctx, string value) =>
{
    ctx.Response.Cookies.Append("theme", value, new CookieOptions
    {
        HttpOnly = false,
        Secure = true,
        SameSite = SameSiteMode.Lax,
        Expires = DateTimeOffset.UtcNow.AddYears(1)
    });
    return Results.Ok();
});

app.MapGet("/prefs/theme", (HttpContext ctx)
  => Results.Text(ctx.Request.Cookies["theme"] ?? "system"));

L'utilisation de MVC/Razor Pages est identique via HttpContext.

Pour assurer l'intégrité et la confidentialité, utilisez le système ASP.NET Core Data Protection pour protéger les charges utiles que vous mettez vous-même dans les cookies.


TempData: Bus de message unidirectionnel

  • Portée : survit à une redirection
  • Store de sauvegarde: Cookie (par défaut) ou Session
  • Utilisation pour: messages flash, résumés de validation après PRG

Mise en place (programme.cs):

builder.Services.AddControllersWithViews().AddSessionStateTempDataProvider(); // optional
builder.Services.AddSession();
var app = builder.Build();
app.UseSession();

Dans le contrôleur MVC:

TempData["StatusMessage"] = "Saved!";
return RedirectToAction("Index");

Dans le gestionnaire de page Razor :

TempData["StatusMessage"] = "Saved!";
return RedirectToPage("/Index");

En vue/page:

@if (TempData["StatusMessage"] is string msg) {
  <div class="alert alert-success">@msg</div>
}
flowchart LR
  A[POST /save] -->|TempData set| B[302 Redirect]
  B --> C[GET /index]
  C -->|TempData read consumed| D[Render message]

Session: État Conversationnel à l'aide d'un serveur

  • Portée : session de navigateur (cookie key + serveur store)
  • Utilisation pour: Assistants multi-étapes, petites données de chariot, compteurs de throttling
  • compromis: nécessite des sessions collantes ou un magasin de soutien distribué; peut limiter l'échelle

Configurer & #160;:

builder.Services.AddDistributedMemoryCache(); // or AddStackExchangeRedisCache
builder.Services.AddSession(options =>
{
    options.IdleTimeout = TimeSpan.FromMinutes(20);
    options.Cookie.HttpOnly = true;
    options.Cookie.IsEssential = true;
});
var app = builder.Build();
app.UseSession();

Utilisation de Session (toute pile) :

app.MapPost("/cart/add/{id:int}", (HttpContext ctx, int id) =>
{
    var key = "cart";
    var bytes = ctx.Session.Get(key);
    var list = bytes is null ? new List<int>() : System.Text.Json.JsonSerializer.Deserialize<List<int>>(bytes)!;
    list.Add(id);
    ctx.Session.Set(key, System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(list));
    return Results.Ok(list);
});

Aides à la séance :

public static class SessionExtensions
{
    public static void Set<T>(this ISession session, string key, T value)
      => session.SetString(key, System.Text.Json.JsonSerializer.Serialize(value));

    public static T? Get<T>(this ISession session, string key)
      => session.TryGetValue(key, out var data)
         ? System.Text.Json.JsonSerializer.Deserialize<T>(data)
         : default;
}

Une mise en garde : quand l'état de session devient le goulot d'étranglement

J'ai travaillé une fois sur un énorme projet informatique du gouvernement du Royaume-Uni où l'abus d'état de session (entre autres péchés architecturaux) est devenu un goulot d'étranglement de performance. L'équipe s'était farcie Tout ce qu'il y a dans la session: préférences de l'utilisateur, données de formulaire en plusieurs étapes, résultats de recherche, calculs temporaires, même recherches mises en cache qui auraient dû être dans un cache ou une base de données approprié.

Le problème: L'état de session a été stocké dans le processus (l'état de session ASP.NET dans web.config, c'était les jours pré-Core). Chaque requête a dû désérialiser des objets de session massifs. Comme la charge a augmenté, l'état de session a explosé à des dizaines de mégaoctets par utilisateur. Avec des milliers d'utilisateurs simultanés, les serveurs ont manqué de mémoire.

La solution désespérée : Nous nous sommes envolés vers les installations de HP à Stuttgart pour effectuer des tests de charge sur leur Superdome – à l'époque, La machine Windows la plus puissante d'Europe. C'était une bête : des dizaines de processeurs Itanium, des centaines de gigaoctets de RAM. L'idée était de prouver qu'avec assez de matériel, le système pouvait répondre aux exigences.

Résultat: Même sur le Superdome, nous ne pouvions pas atteindre les cibles utilisateur simultanées requises. L'architecture de l'état de session était fondamentalement rompue. L'échelle verticale ne pouvait pas sauver la mauvaise conception. La sérialisation/désérialisation de session, combinée à la pression de mémoire des objets de session massifs, signifiait simplement que le système ne pouvait pas évoluer à un coût raisonnable.

Le cauchemar de la sécurité : Pire que les problèmes de performance, nous avons découvert une erreur de codage qui a causé l'état de session à fuite entre les utilisateurs. Les données de session de l'utilisateur A apparaissent occasionnellement dans la session de l'utilisateur B. Ce n'était pas seulement embarrassant – c'était catastrophique. Personnel du NHS Nous avions accidentellement créé un mécanisme de violation de la protection des données qui pourrait exposer des informations médicales sensibles au cours de différentes sessions de professionnels de la santé.

Qu'est-ce qui aurait dû se passer :

  1. Apatrides par défaut: La plupart de ces données de session n'auraient jamais dû exister
  2. Base de données pour l'état durable: Les progrès du formulaire en plusieurs étapes auraient dû être dans la base de données avec un identifiant de flux de travail
  3. Cache pour les recherches: Les recherches partagées appartenaient à IMemoryCache ou à un cache distribué
  4. Côté client pour les préférences: Les préférences de l'utilisateur auraient pu être dans les cookies ou le stockage local
  5. Session distribuée si nécessaire: Si la session était vraiment nécessaire, la session Redis-backed aurait partagé la charge

La leçon : L'état de session n'écaille pas verticalement et à peine horizontalement (même avec des sessions collantes ou des magasins distribués, vous êtes toujours sérialisation/désérialisation sur chaque demande).L'expérience de Superdome a prouvé que lancer du matériel à des problèmes architecturaux est coûteux et souvent futile.

Mais ce qui est plus important, c'est que : les bogues d'état de session deviennent des vulnérabilités de sécurité. Problèmes de sécurité des fils, conditions de course, manipulation incorrecte des ID de session – ceux-ci ne causent pas seulement des problèmes de performance, ils peuvent fuiter des données sensibles entre les utilisateurs. Dans un contexte de santé (ou bancaire, ou toute industrie réglementée), il s'agit d'un cauchemar de conformité et de responsabilité pénale potentielle.

Conseils modernes :

  • Si vous avez besoin de plus de quelques KB de données de session, vous avez probablement un problème de conception
  • Si vous stockez des données sensibles en session, vous créez une surface d'attaque de sécurité
  • Les architectures apatrides n'évoluent pas mieux – elles sont intrinsèquement plus sécurisées parce qu'il n'y a pas d'état côté serveur à fuiter
  • Repensez votre stratégie de gestion de l'État avant de vous rendre à Stuttgart (ou expliquez une violation de données au Commissaire à l'information)

Cache : IMémoryCache et IDistributedCache

  • Portée : processus serveur (IMemoryCache) ou distribué (IDdistributedCache)
  • Utilisation pour : données dérivées/calculées, recherche, état de courte durée
  • compromis: invalidation du cache, sérialisation pour distribution

IMEmoryCache:

builder.Services.AddMemoryCache();

app.MapGet("/rates", (IMemoryCache cache) =>
{
    var key = "fx:usd:eur";
    if (!cache.TryGetValue(key, out decimal rate))
    {
        rate = 0.92m; // pretend fetch
        cache.Set(key, rate, TimeSpan.FromMinutes(5));
    }
    return Results.Ok(rate);
});

IDistributedCache (par exemple, Redis):

builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379");

app.MapGet("/feature/{name}", async (IDistributedCache cache, string name) =>
{
    var val = await cache.GetStringAsync($"feat:{name}");
    return Results.Text(val ?? "off");
});

Avertissement antipattern de type Cache-as-state : s'il doit être durable ou faire autorité, le stocker dans une base de données et le mettre en cache en option.

Choix entre IMemoryCache et IDistributedCache

  • IMEmoryCache:
    • Brillant rapidement, en cours de traitement, les objets restent comme des objets (pas de sérialisation).
    • Éviction par pression de mémoire, limite de taille, expiration absolue/glissante, et priorité.
    • Non partagé entre les nœuds; nettoyé sur l'application recycl/déploiement.
    • Idéal pour les hotsets par nœud, les recherches calculées, les TTL courts.
  • IDistributedCache (Redis/SQL/etc.):
    • Partage à travers une ferme; survit le redémarrage de l'application; nécessite la sérialisation (chaînes/octets).
    • Latence légèrement plus élevée; le débit dépend du réseau et du moteur.
    • Prend en charge l'expiration absolue / glissante (dépendant du fournisseur; le fournisseur de Redis met à jour TTL sur l'accès pour glisser).
    • Idéal pour la consistance des nœuds croisés, les grandes lectures fan-out, les drapeaux et la session.

Stratégies communes de cache

  • Cache-à-l'abri (le plus fréquent):

    1. Essayez cache; 2) si vous manquez, chargez à partir de la source; 3) écrivez au cache; 4) retournez.
    • Points positifs: simple; la source de la vérité reste la base de données.
    • Points négatifs: la première demande après expiration est lente; peut-être estampillés.
  • Read-through (via une bibliothèque/fournisseur): cache gère le chargement sur les ratés.

  • Écrire : les écritures vont au cache et au magasin de sauvegarde de manière synchrone.

  • Write-behind: écrire au cache, rincer pour stocker asynchrone (risque: perte/incohérence).

  • Rafraîchir à l'avance: rafraîchir les clés chaudes avant qu'elles expirent pour éviter les erreurs froides.

Expiration, expulsion et calibrage

  • Expiration absolue : expire toujours après une durée déterminée (bonne pour la fraîcheur externe des données).
  • Expiration glissante: étend TTL sur l'accès (bon pour les sessions/données spécifiques à l'utilisateur).
  • Eviction basée sur la taille (IMemoryCache): set entry.Size et configure SizeLimit à la mémoire liée.
  • Priorité (IMemoryCache): CacheItemPriorité.High/Normal/Low/NeverRemove affecte l'expulsion sous pression.
  • Jitter: ajouter de petits décalages aléatoires aux TTLs pour éviter l'expiration synchronisée (stampedes).

Prévenir les estampilles de cache (déchire de troupeau)

  • Utilisez GetOrCreate/GetOrCreateAsync (IMemoryCache) pour assurer une population de fil unique par noeud.
  • Distribué : utiliser une clé de verrouillage à courte durée de vie (SET NX EX) ou un support de bibliothèque; ajouter un jitter TTL; envisager de rafraîchir l'arrière-plan.
  • Servir stale-wilen-revalidate: conserver une clé secondaire avec une valeur stale et une courte extension pendant que la nouvelle valeur est calculée.

Conception des clés et rythme des noms

  • Préférez les clés minuscules, délimitées par le côlon: app:entité:123 ou locataire:us:utilisateurs:42.
  • Inclure le segment de version pour invalider des classes entières de clés sans supprimer: v2:products:123.
  • Tenant-aware: préfixez les clés avec le locataire ou l'organisation id pour éviter les collisions et faciliter les purges.
  • Gardez les clés petites mais descriptives; évitez les entrées brutes contrôlées par l'utilisateur sans normalisation.

Ensembles de touches expirantes (étiquettes/groupes)

Lorsque vous avez besoin d'invalider de nombreuses entrées connexes:

  • Préfixes en version (invalidation soft) : bloquez une version globale dans une petite clé et composez les clés avec elle.

    // version key: "v:products"; keys like $"{version}:product:{id}"
    var version = await cache.GetStringAsync("v:products") ?? "1";
    var key = $"{version}:product:{id}";
    

    Pour invalider tous les produits: incrément v:products (les clients vont naturellement manquer les anciennes clés préfixées).

  • Jeu d'étiquettes par groupe (Redis): conservez un jeu de clés par tag; sur l'invalidation, récupérer les membres et supprimer.

    // using StackExchange.Redis directly for sets + efficient deletes
    var mux = await ConnectionMultiplexer.ConnectAsync("localhost:6379");
    var db = mux.GetDatabase();
    var tag = "tag:category:42";
    var key = $"prod:{prodId}";
    await db.StringSetAsync(key, serialized, expiry: TimeSpan.FromMinutes(30));
    await db.SetAddAsync(tag, key); // remember membership
    
    // later, invalidate the whole tag
    var members = await db.SetMembersAsync(tag);
    if (members.Length > 0)
    {
        var keys = Array.ConvertAll(members, m => (RedisKey)m);
        await db.KeyDeleteAsync(keys);
    }
    await db.KeyDeleteAsync(tag);
    
  • Invalidation Pub/Sub : publiez un message "invalidate:key" ; chaque noeud supprime la clé de son IMemoryCache local.

  • Scanner avec les motifs: SCAN/KEYS devrait être évité dans les chemins chauds de prod; ok pour l'outil d'administration sur les petits espaces de clés.

Aides pratiques

  • IMemoryCache set-or-set avec des options:
    T GetOrAdd<T>(IMemoryCache cache, string key, Func<ICacheEntry, T> factory)
      => cache.GetOrCreate(key, e =>
      {
          e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
          e.SlidingExpiration = TimeSpan.FromMinutes(2);
          e.Priority = CacheItemPriority.Normal;
          e.Size = 1;
          return factory(e);
      });
    
  • IDistributedCache avec JSON et expiration:
    static async Task<T?> GetOrSetJsonAsync<T>(IDistributedCache cache, string key, Func<Task<T>> factory, TimeSpan ttl)
    {
        var json = await cache.GetStringAsync(key);
        if (json is not null)
            return System.Text.Json.JsonSerializer.Deserialize<T>(json);
    
        var value = await factory();
        var opts = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl };
        await cache.SetStringAsync(key,
            System.Text.Json.JsonSerializer.Serialize(value),
            opts);
        return value;
    }
    

Surveillance et visibilité

  • Taux de succès/dépassement de la piste et temps de charge moyen; exposer les mesures (compteurs Prométhée) par groupe clé.
  • Ajouter l'enregistrement autour de la population de cache et les rappels d'expulsion pour IMemoryCache.
  • Pour Redis, regardez les hits/misses de l'espace clé, latence et fragmentation de la mémoire; définissez les politiques maxmemory selon les besoins.

Motifs IMémoryCache du monde réel de la production

Voici comment j'utilise réellement IMemoryCache dans ma plate-forme de blog, évolué à travers l'essai et l'erreur. Je vais vous montrer trois modèles réels de simple à sophistiqué.

Modèle 1 : cache-à côté simple pour les données qui changent rarement (Catégories)

C'était ma première mise en place en cache. Les catégories de blog ne changent pas souvent, alors cachez-les pendant 30 minutes:

// From BaseController.cs
private const string CacheKey = "Categories";

private async Task<List<string>> GetCategories()
{
    baseControllerService.MemoryCache.TryGetValue(CacheKey, out var value);

    if (value is List<string> categories) return categories;

    logger.LogInformation("Fetching categories from BlogService");
    categories = (await BlogViewService.GetCategories(true)).OrderBy(x => x).ToList();
    baseControllerService.MemoryCache.Set(CacheKey, categories, TimeSpan.FromMinutes(30));
    return categories;
}

Pourquoi cela fonctionne:

  • Les catégories sont lues sur chaque page (présentées dans la navigation)
  • Ils changent rarement (seulement lorsque j'ajoute de nouveaux billets de blog avec de nouvelles catégories)
  • 30 minutes TTL est très bien; si de nouvelles catégories apparaissent, les utilisateurs les voient dans les 30 minutes
  • Expiration absolue seulement (pas de glissement) parce que nous ne nous soucions pas de la fréquence de son accès

J'ai touché : Initialement, j'ai utilisé une clé de chaîne "Catégories". Fonctionne bien jusqu'à ce que vous ayez plusieurs contrôleurs et qu'on réutilise accidentellement la même clé. Maintenant, j'utilise des constantes ou des clés fortement tapées (voir la section précédente sur l'éviter les collisions).

Motif 2: État par utilisateur avec des limites de taille et d'expiration coulissante (Tâches de traduction)

Ce statut de traduction cache par utilisateur. Il est plus complexe parce qu'il a besoin de limites et devrait rester en vie aussi longtemps que l'utilisateur est actif:

// From TranslateCacheService.cs - tracks translation tasks per user
public void AddTask(string userId, TranslateTask task)
{
    CachedTasks CachedTasks() => new()
    {
        Tasks = new List<TranslateTask> { task },
        AbsoluteExpiration = DateTime.Now.AddHours(6)
    };

    if (memoryCache.TryGetValue(userId, out CachedTasks? tasks))
    {
        tasks ??= CachedTasks();
        var currentTasks = tasks.Tasks;

        // Keep only the 5 most recent tasks
        currentTasks = currentTasks.OrderByDescending(x => x.StartTime).ToList();
        if (currentTasks.Count >= 5)
        {
            var lastTask = currentTasks.Last();
            currentTasks.Remove(lastTask);
        }

        currentTasks.Add(task);
        currentTasks = currentTasks.OrderByDescending(x => x.StartTime).ToList();
        tasks.Tasks = currentTasks;

        memoryCache.Set(userId, tasks, new MemoryCacheEntryOptions
        {
            AbsoluteExpiration = tasks.AbsoluteExpiration,
            SlidingExpiration = TimeSpan.FromHours(1) // Extends on access
        });
    }
    else
    {
        var absoluteExpiration = DateTime.Now.AddHours(6);
        var cachedTasks = CachedTasks();
        memoryCache.Set(userId, cachedTasks, new MemoryCacheEntryOptions
        {
            AbsoluteExpiration = absoluteExpiration,
            SlidingExpiration = TimeSpan.FromHours(1)
        });
    }
}

Pourquoi c'est différent :

  • Expiration glissante: Si l'utilisateur continue de vérifier l'état de traduction, gardez le cache en vie jusqu'à 6 heures maximum
  • Limites de calibre: Ne gardez que 5 tâches les plus récentes par utilisateur pour éviter le bloat de mémoire
  • Gestion manuelle: Je limite explicitement la taille parce que MemoryCache n'applique pas les limites de nombre d'éléments d'entrée (seulement la taille globale du cache)

Erreur que j'ai commise : Au début, je n'ai pas limité le nombre de tâches. Un utilisateur de puissance a déclenché 50 traductions et j'ai eu une fuite de mémoire. Maintenant, je garde max 5 par utilisateur.

Échanges: Si cela devient un problème, je passerais à IDistributedCache (Redis) ou je stockerais dans la base de données avec un index sur userId + startTime.

Pattern 3: Cache avec observabilité (Cache en métrique)

Ceci cache les métriques analytiques et le suivi de l'efficacité du cache en utilisant Serilog tracing:

// From UmamiDataSortService.cs - caches Umami analytics metrics
public async Task<List<MetricsResponseModels>?> GetMetrics(DateTime startAt, DateTime endAt, string prefix = "")
{
    using var activity = Log.Logger.StartActivity("GetMetricsWithPrefix");
    try
    {
        var cacheKey = $"Metrics_{startAt:yyyyMMdd}_{endAt:yyyyMMdd}_{prefix}";

        if (cache.TryGetValue(cacheKey, out List<MetricsResponseModels>? metrics))
        {
            activity?.AddProperty("CacheHit", true);
            return metrics;
        }

        activity?.AddProperty("CacheHit", false);
        var metricsRequest = new MetricsRequest
        {
            StartAtDate = startAt,
            EndAtDate = endAt,
            Type = MetricType.url,
            Limit = 500
        };

        var metricRequest = await dataService.GetMetrics(metricsRequest);
        if (metricRequest.Status != HttpStatusCode.OK) return null;

        var filteredMetrics = metricRequest.Data
            .Where(x => x.x.StartsWith(prefix))
            .ToList();

        cache.Set(cacheKey, filteredMetrics, TimeSpan.FromHours(1));

        activity?.AddProperty("MetricsCount", filteredMetrics?.Count() ?? 0);
        activity?.Complete();
        return filteredMetrics;
    }
    catch (Exception e)
    {
        activity?.Complete(LogEventLevel.Error, e);
        return null;
    }
}

Ce qui rend cette production prête:

  • Observabilité: SerilogTracking activité suit cache hit/miss rate et records métriques count
  • Clé composite: Comprend la plage de date et le préfixe pour éviter les collisions clés
  • Formatage des dates: Utilisations yyyyMMdd format dans la clé si différentes fois le même jour partager le cache
  • Manipulation des nulls: Renvoie null si le service externe échoue ; ne cache pas les pannes
  • Cuisson filtrée: Cache le résultat filtré, pas la réponse brute

Évolution : Au début, j'ai mis en cache pendant 10 minutes. Mais les métriques Umami sont lourdes à récupérer et ne changent pas beaucoup, donc 1 heure est bien. J'ai découvert cela en regardant les données SerilogTracing et en voyant des caches démesurés.

Suivi en action: Dans Seq (mon agrégateur de log), je peux demander :

ActivityName = "GetMetricsWithPrefix" and CacheHit = false

Cela me dit mon taux de perte de cache. Si c'est élevé, j'ajuste TTL ou stratégie clé.

Comparaison des trois approches

Motif de l'utilisation Cas d'expiration Contrôle de la taille Observabilité |---------|----------|------------|--------------|---------------| | Catégories Global, rarement changeant. 30 min d'absolu. Pas besoin (petit) | Tâches de traduction Par utilisateur, limité 6h absolue + 1h coulissante Manuel (5 articles max) Aucun (devrait ajouter!) | métriques Des appels externes coûteux 1h absolus Naturels (fenêtre du temps)

Enseignements clés:

  1. Démarrer simple (modèle 1), ajouter la complexité seulement lorsque nécessaire
  2. Toujours penser à l'utilisation de la mémoire cache - ajouter des limites de taille pour les caches par utilisateur
  3. Pour les opérations coûteuses, ajouter l'observabilité dès le premier jour
  4. Ajuster le TTL en fonction de la fréquence réelle des changements de données, et non des hypothèses.

Quand je n'utilise pas IMemoryCache:

  • Pour l'état d'authentification de l'utilisateur (demandes dans le cookie d'auth à la place)
  • Pour le panier (utiliserait DB + cache distribué dans la production)
  • Pour le contenu du blog (déjà dans DB, chargé une fois par demande)
  • État cross-server (il faudrait IDistributedCache/Redis)

Cookies et demandes d'authentification

  • Portée : entre les demandes jusqu'à l'expiration/la signature
  • Utilisation pour: identité, rôles grossiers/permissions, une petite quantité de données de profil
  • Oppositions : taille des cookies; ne pas suralimenter. Les allégations doivent être stables.

Configuration du cookie auth:

builder.Services.AddAuthentication("Cookies")
    .AddCookie("Cookies", o =>
    {
        o.LoginPath = "/login";
        o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        o.SlidingExpiration = true;
    });
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

Sign-in avec les revendications (CVM/minimum):

app.MapPost("/login", async (HttpContext ctx) =>
{
    var claims = new[]
    {
        new Claim(ClaimTypes.NameIdentifier, "123"),
        new Claim(ClaimTypes.Name, "Alice"),
        new Claim(ClaimTypes.Role, "Admin")
    };
    var identity = new ClaimsIdentity(claims, "Cookies");
    await ctx.SignInAsync("Cookies", new ClaimsPrincipal(identity));
    return Results.Redirect("/");
});

Lire les revendications (toute pile) :

[Authorize]
app.MapGet("/me", (ClaimsPrincipal user)
  => Results.Ok(new { user.Identity!.Name, Roles = user.Claims.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value) }));

JWT / Jetons porteurs

  • Portée: client porte jeton; serveur apatride
  • Utilisation pour: SPA/mobile/API, multidomaine, microservices
  • Échanges : taille du jeton; rotation/réfraîchissement; conservation des allégations minimales, utilisation de l'introspection si nécessaire
builder.Services.AddAuthentication("Bearer")
   .AddJwtBearer("Bearer", o =>
   {
       o.Authority = "https://demo.identityserver.io"; // example
       o.Audience = "api";
       o.RequireHttpsMetadata = true;
   });

Utilisation:

[Authorize(AuthenticationSchemes = "Bearer")]
app.MapGet("/secure", () => "ok");

Vue d'ensemble de la sirène :

sequenceDiagram
  participant C as Client
  participant STS as Token Service
  participant API as API
  C->>STS: Authenticate (username/password)
  STS-->>C: JWT (signed)
  C->>API: GET /secure (Authorization: Bearer <jwt>)
  API->>API: Validate signature, expiry, audience
  API-->>C: 200

État durable : base de données et amis

  • Portée : pour toujours (jusqu'à ce que vous l'effaciez)
  • Utilisation pour : tout ce qui ne doit pas être perdu : chariots, commandes, profils, workflows à long terme
  • Modèles: CRUD standard avec EF Core; CQRS; sourcing d'événements; modèle de boîte de réception pour la fiabilité

Esquisse EF Core:

builder.Services.AddDbContext<AppDb>(o => o.UseSqlServer(cs));

app.MapPost("/cart/items", async (AppDb db, AddItem cmd) =>
{
    var cart = await db.Carts.FindAsync(cmd.CartId) ?? new Cart(cmd.CartId);
    cart.Add(cmd.ProductId, cmd.Qty);
    await db.SaveChangesAsync();
    return Results.Created($"/cart/{cart.Id}", cart);
});

Réponse Caching, ETags et demandes conditionnelles

  • Pas strictement l'état de transport, mais réduit le travail répété en laissant le client/proxies réutiliser les réponses antérieures. Souvent jumelé avec l'état de requête/route.
builder.Services.AddResponseCaching();
var app = builder.Build();
app.UseResponseCaching();

app.MapGet("/products", (HttpContext ctx) =>
{
    ctx.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue { Public = true, MaxAge = TimeSpan.FromSeconds(30) };
    return Results.Ok(new[] { new { Id = 1, Name = "Widget" } });
}).CacheOutput();

Exemple ETag:

app.MapGet("/resource", (HttpContext ctx) =>
{
    var version = "W/\"abc123\""; // compute based on data hash
    ctx.Response.Headers.ETag = version;
    if (ctx.Request.Headers.IfNoneMatch == version)
        return Results.StatusCode(StatusCodes.Status304NotModified);
    return Results.Text("payload");
});

ResponseCache vs OutputCache: Utilisation du monde réel dans la production

J'utilise à la fois ResponseCache et OutputCache ensemble sur mon blog à des fins différentes. Voici pourquoi vous pouvez vouloir les deux et comment ils diffèrent.

La confusion : Deux attributs de mise en cache ?

ASP.NET Core a deux systèmes de mise en cache similaires:

  1. Cache-réponse (encastrement HTTP): Définit les en-têtes HTTP (Cache-Control, Vary) indiquant aux navigateurs et aux CDN comment mettre en cache e
  2. OutputCache (côté serveur): Cache la sortie rendue sur le serveur pour sauter complètement l'exécution de l'action

Ils se complètent les uns les autres. ResponseCache gère la mise en cache client/CDN; OutputCache gère la mise en cache côté serveur.

Mon blog post action (les deux caches appliquées)

// From BlogController.cs
[Route("{slug}")]
[HttpGet]
[ResponseCache(Duration = 300, VaryByHeader = "hx-request",
    VaryByQueryKeys = new[] { nameof(slug), nameof(language) },
    Location = ResponseCacheLocation.Any)]
[OutputCache(Duration = 3600, VaryByHeaderNames = new[] { "hx-request" },
    VaryByQueryKeys = new[] { nameof(slug), nameof(language) })]
public async Task<IActionResult> Show(string slug, string language = "en")
{
    var post = await blogViewService.GetPost(slug, language);
    if (post == null) return NotFound();

    // ... populate user info, comments, etc ...

    if (Request.IsHtmx()) return PartialView("_PostPartial", post);
    return View("Post", post);
}

Que se passe-t-il quand quelqu'un demande /blog/my-post:

  1. OutputCache vérifie d'abord: Ai-je une réponse en cache pour my-post + en langue ?

    • Affichage: Retour en cache HTML, la méthode d'action ne fonctionne jamais (fast! ~1ms)
    • Mlle: Exécuter l'action, afficher le rendu, résultat cache pendant 3600 secondes (1 heure)
  2. ResponseCache définit les en-têtes: Après OutputCache génère la réponse, ResponseCache ajoute :

    Cache-Control: public, max-age=300
    Vary: hx-request
    
  3. Cache du navigateur: Le navigateur cache la réponse pendant 300 secondes (5 minutes). Les requêtes subséquentes du même utilisateur ne touchent même pas le serveur.

  4. Cuisson CDN (si vous utilisez Cloudflare/Fastly): caches CDN pendant 5 minutes. Les utilisateurs du monde entier ont frappé le CDN, pas mon serveur.

Pourquoi des durées différentes? (300 vs 3600)

[ResponseCache(Duration = 300)]    // 5 minutes client/CDN cache
[OutputCache(Duration = 3600)]     // 1 hour server cache

Motifs:

  • Le cache du serveur est plus long (1 heure): Je contrôle mon serveur ; je peux purger le cache si je met à jour un message
  • Le cache client est plus court (5 minutes): Je ne peux pas purger facilement les navigateurs utilisateurs ou les CDN ; 5 minutes est une fenêtre d'impasse raisonnable
  • Échanges: Si j'édite un message, un nouveau contenu apparaît :
    • Côté serveur : Immédiatement (je peux invalider le cache)
    • CDN/navigateurs: dans les 5 minutes (ou je purge manuellement CDN)

Évolution : Au début, j'avais les deux à 5 minutes. Mais cela signifiait que mon serveur re-rendait toutes les 5 minutes même si le contenu change rarement. Maintenant:

  • Serveur sert heureusement en cache HTML pendant 1 heure
  • Les clients obtiennent du contenu frais-assez toutes les 5 minutes

VaryByHeader pour HTMX

VaryByHeader = "hx-request"  // ResponseCache
VaryByHeaderNames = new[] { "hx-request" }  // OutputCache

Pourquoi cela importe-t-il : Les demandes HTMX comprennent : hx-request: true header. Je retourne différentes réponses:

  • Demande complète: Page HTML complète avec mise en page
  • Demande HTMX: Vue partielle sans mise en page

Sans VaryBy, le cache retournerait le mauvais format. VaryBy, je cache deux versions de chaque page.

Exemple :

User requests /blog/my-post → Cache key: "blog/my-post:en:hx=false" → Full HTML cached
HTMX requests /blog/my-post → Cache key: "blog/my-post:en:hx=true" → Partial HTML cached

VaryByQueryKeys pour la langue et la pagination

[ResponseCache(VaryByQueryKeys = new[] { nameof(slug), nameof(language) })]
[OutputCache(VaryByQueryKeys = new[] { nameof(slug), nameof(language) })]

Problème sans cela: /blog/my-post?language=fr servirait la version anglaise mise en cache.

Avec VaryByQueryKeys : Entrées de cache séparées & #160;:

  • /blog/my-post?language=en → Cache clé inclut "en"
  • /blog/my-post?language=fr → Clé de cache inclut "en"

Utilisation réelle de ma liste de blogs:

[Route("blog")]
[ResponseCache(Duration = 300, VaryByHeader = "hx-request",
    VaryByQueryKeys = new[] { "page", "pageSize", "startDate", "endDate", "language", "orderBy", "orderDir" })]
[OutputCache(Duration = 3600, VaryByHeaderNames = new[] { "hx-request" },
    VaryByQueryKeys = new[] { "page", "pageSize", "startDate", "endDate", "language", "orderBy", "orderDir" })]
public async Task<IActionResult> Index(int page = 1, int pageSize = 20, /* ... */)

Avertissement d'explosion de cache: Chaque combinaison unique de paramètres = entrée de cache séparée:

  • page=1&pageSize=20&language=en → Une entrée
  • page=2&pageSize=20&language=en → Une autre entrée
  • page=1&pageSize=10&language=en → Une autre entrée

Atténuation:

  • Taille de cache maximale raisonnable (OutputCache auto-évite les moins utilisés récemment)
  • Paramètres communs mis en cache (page 1, page par défautTaille)
  • Des combinaisons peu fréquentes peuvent manquer de cache (acceptable)

Configuration requise

Cache-réponse fonctionne hors de la boîte, mais pour Cache de sortie Vous avez besoin d'une configuration :

// Program.cs
builder.Services.AddOutputCache(options =>
{
    options.MaximumBodySize = 64 * 1024 * 1024; // 64 MB max response size
    options.SizeLimit = 100 * 1024 * 1024; // 100 MB total cache size
});

var app = builder.Build();
app.UseOutputCache(); // Must be in middleware pipeline

Quand OutputCache n'aide pas

OutputCache est ignoré pour:

  • Demandes authentifiées (différents utilisateurs voient différentes données)
  • POST/PUT/DELETE (en cache uniquement GET/HEAD)
  • Réponses avec Set-Cookie en-tête
  • Réponses qui définissent explicitement Cache-Control: no-store

Exemple où je ne l'utilise pas :

// Comment submission - authenticated, POST, and per-user
[HttpPost]
[Authorize]
public async Task<IActionResult> AddComment(CommentModel model)
{
    // No caching attributes - this is user-specific and changes state
}

Mesurer l'efficacité

J'utilise les métriques Prométheus (exposées via mon application) pour suivre :

// Pseudo-code for metrics
cache_hits_total{cache="output"} 45230
cache_misses_total{cache="output"} 892

Mon taux de succès cache: ~98% pour les billets de blog (la plupart du trafic frappe les mêmes billets populaires à plusieurs reprises).

Répercussions:

  • Sans mise en cache : ~50ms temps de réponse moyen (requête BD + rendu Markdown)
  • Avec OutputCache: ~1-2ms pour les réponses en cache
  • 25x Accélération

Lorsque je désactive temporairement le cache

Parfois, je débogue et j'ai besoin de nouvelles réponses à chaque fois :

// During development, comment out caching
// [ResponseCache(Duration = 300, ...)]
// [OutputCache(Duration = 3600, ...)]
public async Task<IActionResult> Show(string slug, string language = "en")

Ou utilisez des paramètres spécifiques à l'environnement:

#if DEBUG
    // No caching in development
#else
    [ResponseCache(Duration = 300, ...)]
    [OutputCache(Duration = 3600, ...)]
#endif

Meilleure approche: Utiliser la configuration & #160;:

[ResponseCache(Duration = responseCacheDuration, ...)]

responseCacheDuration est 0 en développement, 300 en production.

Pour et contre de cette stratégie dual-cache

Pour :

  • Efficacité du serveur: OutputCache réduit la charge CPU/DB de 98%
  • Avantages pour le client et le CDN: ResponseCache réduit ma bande passante et améliore la latence mondiale
  • Flexibilité: Différents TTL pour le serveur et le client
  • Économies: Moins de requêtes DB, moins de bande passante

Inconvénients:

  • Staleness: Les mises à jour de contenu prennent jusqu'à 5 minutes pour se propager aux clients
  • Cache complexité d'invalidation: La mise à jour d'un message nécessite l'invalidation des caches serveur et CDN
  • Utilisation de la mémoire: OutputCache détient le HTML rendu dans la mémoire du serveur
  • Déboguer la confusion: Oubliez parfois le cache et demandez-vous pourquoi les changements n'apparaissent pas

Quand j'ai sauté la mise en cache :

  • Données en temps réel (prix des stocks, sports en direct)
  • Contenu personnalisé (recommandations spécifiques à l'utilisateur)
  • Pages à faible trafic (couverture des frais généraux > prestation)
  • Pages qui changent très fréquemment

Mon verdict : Pour un blog avec principalement du contenu statique et un rapport lecture/écriture élevé, le double cache est une énorme victoire. Je ne l'utiliserais pas sur les panneaux d'administration ou les tableaux de bord avec des données en évolution rapide.


ViewBag, ViewData et TempData: Controller-to-View State (et pourquoi j'en évite principalement deux)

Ces trois-là sont souvent confus. Voici comment ils diffèrent et ce que j'utilise réellement dans la production.

Les trois amigos comparés

// ViewData: string-keyed dictionary
ViewData["Title"] = "Blog";
ViewData["Categories"] = new List<string> { "ASP.NET", "C#" };

// ViewBag: dynamic wrapper around ViewData
ViewBag.Title = "Blog";
ViewBag.Categories = new List<string> { "ASP.NET", "C#" };

// TempData: survives one redirect (backed by session or cookie)
TempData["Message"] = "Post saved!";
return RedirectToAction("Index");

Caractéristiques Afficher les données Afficher les donnéesTempDataTempDataTempDataTempDataTempDataTempDataTempDataTempDataTempDataTempData

--------- ---------- --------- ----------
Durée de vie Requête actuelle Requête actuelle Redirection unique
Accès aux clés Clés à cordes Syntaxe de propriété Clés à cordes
Sécurité du type Aucun (casts nécessaires) Aucun (dynamique) Aucun (casts nécessaires)
Contrôle du temps de compilation Numéro de code de code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du code du
Survive redirection Non Non Oui

Ce que j'utilise réellement: ViewBag pour les données de mise en page globales

Dans mon blog, j'utilise ViewBag exclusivement pour transmettre les données des contrôleurs à la mise en page partagée (analyse, catégories, etc.):

// From BaseController.cs - runs before every action
public override async Task OnActionExecutionAsync(ActionExecutingContext filterContext,
    ActionExecutionDelegate next)
{
    logger.LogInformation("OnActionExecutionAsync");

    if (!Request.IsHtmx())
    {
        // Analytics settings for layout
        ViewBag.UmamiPath = AnalyticsSettings.UmamiPath;
        ViewBag.UmamiWebsiteId = AnalyticsSettings.WebsiteId;
        ViewBag.UmamiScript = AnalyticsSettings.UmamiScript;
    }

    logger.LogInformation("Adding categories to viewbag");
    ViewBag.Categories = await GetCategories(); // Cached list

    await base.OnActionExecutionAsync(filterContext, next);
}

Puis dans ma mise en page (_Layout.cshtml):

@if (ViewBag.Categories is List<string> categories)
{
    <nav>
        @foreach (var cat in categories)
        {
            <a asp-controller="Blog" asp-action="Category" asp-route-category="@cat">@cat</a>
        }
    </nav>
}

@if (!string.IsNullOrEmpty(ViewBag.UmamiPath))
{
    <script async src="@ViewBag.UmamiScript"
            data-website-id="@ViewBag.UmamiWebsiteId"></script>
}

Pourquoi ce modèle fonctionne:

  • À l ' échelle mondiale: Chaque page a besoin de catégories de nav et d'analyse
  • Calculé une fois: BaseController fonctionne avant chaque action
  • Optimisation HTMX: Sauter le script analytique sur les requêtes partielles (HTMX n'a pas besoin qu'il soit ré-injecté)
  • Cuché: Les catégories sont mises en cache (voir mon modèle IMemoryCache ci-dessus), donc ne pas frapper DB chaque demande

Erreur que j'ai commise tôt : J'étais en train de poser ViewBag.Categories dans chaque méthode d'action unique. violation DRY et facile à oublier. OnActionExecutionAsync dans le contrôleur de base a résolu cela.

ViewBag pour les données spécifiques à la page (modèle acceptable)

// From BlogController.cs
[Route("category/{category}")]
public async Task<IActionResult> Category(string category, int page = 1, int pageSize = 10)
{
    ViewBag.Category = category; // Used in view for heading
    ViewBag.Title = category + " - Blog"; // Used in layout <title>

    var posts = await blogViewService.GetPostsByCategory(category, page, pageSize);
    // ... populate posts model ...

    if (Request.IsHtmx()) return PartialView("_BlogSummaryList", posts);
    return View("Index", posts);
}

En vue:

@{
    ViewData["Title"] = ViewBag.Title; // Standard MVC convention for <title>
}

<h1>Category: @ViewBag.Category</h1>

Ce n'est pas grave parce que :

  • Valeurs scalaires simples (chaîne, int)
  • Utilisé seulement dans la vue, pas passé autour
  • Une autre solution serait d'ajouter Title et Category propriétés de chaque modèle de vue

Ce que j'évite: Objets complexes dans ViewBag

Antipattern:

// DON'T DO THIS
ViewBag.User = new UserViewModel { Name = "Scott", IsAdmin = true };
ViewBag.Posts = new List<Post> { ... };
ViewBag.Metadata = new { Tags = new[] { "a", "b" }, Date = DateTime.Now };

Problèmes:

  • Pas de sécurité du temps de compilation (typo ViewBag.Usr échoue à l'exécution)
  • Difficile de suivre les données disponibles en vue
  • Rendre les tests plus difficiles (nécessite d'inspecter le dictionnaire ViewBag)
  • Pas d'IntelliSense

Mieux : Modèles de vue fortement typés :

// DO THIS instead
public class BlogIndexViewModel : BaseViewModel
{
    public string Category { get; set; }
    public List<PostSummary> Posts { get; set; }
    public PaginationInfo Pagination { get; set; }
}

public IActionResult Category(string category, int page = 1)
{
    var model = new BlogIndexViewModel
    {
        Category = category,
        Posts = await GetPosts(category, page),
        // Inherited from BaseViewModel:
        Authenticated = user.LoggedIn,
        Name = user.Name,
        AvatarUrl = user.AvatarUrl
    };
    return View("Index", model);
}

TempData: Je ne l'utilise pas (et voici pourquoi)

Cas d'utilisation standard de TempData :

[HttpPost]
public IActionResult SavePost(PostModel model)
{
    // Save post...
    TempData["SuccessMessage"] = "Post saved successfully!";
    return RedirectToAction("Index");
}

public IActionResult Index()
{
    // TempData["SuccessMessage"] available here (consumed on read)
    return View();
}

Pourquoi je n'utilise pas TempData dans mon blog:

  1. J'utilise HTMX au lieu de redirections: Mes formulaires se soumettent via HTMX et retournent des vues partielles avec des messages de succès/erreur en ligne. Pas de redirection = pas besoin de TempData.
[HttpPost]
public async Task<IActionResult> Submit(ContactViewModel model)
{
    if (!ModelState.IsValid)
        return PartialView("_ContactForm", model); // Show errors inline

    await sender.SendEmailAsync(contactModel);

    // Return success view directly (no redirect)
    return PartialView("_Response", new ContactViewModel
    {
        Email = model.Email,
        Name = model.Name,
        Comment = "Message sent!"
    });
}
  1. Pour PRG traditionnel (post-redirect-Get), J'utiliserais TempData. Mais je préfère éviter les redirections lorsque c'est possible pour une meilleure UX.

Quand TempData a du sens :

  • Applications MVC traditionnelles avec redirections de page complète après POST
  • Assistants multi-étapes où vous rediriger entre les étapes
  • Messages Flash après les redirections d'authentification

TempData getcha : Par défaut soutenu par les cookies (depuis ASP.NET Core 2.0). Si vous mettez de gros objets dans TempData, vous gonflez le cookie envoyé avec chaque demande. Pour un grand état, utilisez Session avec un backup store ou DB.

Arbre de décision rapide

flowchart TD
    A[Need to pass data to view?] --> B{What kind?}
    B -->|Global layout data| C[ViewBag in BaseController]
    B -->|Simple page-specific| D[ViewBag in action]
    B -->|Complex model| E[Strongly-typed ViewModel]
    B -->|Survive redirect| F{Using HTMX?}
    F -->|Yes| G[Return partial with message]
    F -->|No| H[TempData for flash message]

Mes règles :

  1. ViewBag pour la mise en page globale seulement (analytique, nav, chapelure)
  2. ViewBag pour les titres/en-têtes de page simples (facultatif; pourrait utiliser ViewModel)
  3. Never ViewBag pour des objets complexes (utilisez les modèles de vue)
  4. Ne jamais afficher les données (ViewBag a une syntaxe plus agréable)
  5. TempData seulement si vous avez vraiment besoin de PRG (Je ne le fais pas, grâce à HTMX)

Motif: Wizards et flux multi-étapes

Quel État utiliser ?

flowchart TD
  A[Start Wizard] --> B{Short-lived?\nSingle browser?}
  B -- Yes --> S[Session/TempData]
  B -- No/Complex --> D[DB + key in route]
  S --> PRG[Use PRG between steps]
  D --> PRG
  • Petite session unique: Session ou TempData entre les étapes.
  • Cross-device/long-running: persister à DB, porter une clé dans l'URL.

Exemple (DB + touche de route) :

app.MapPost("/wizard/{id}", async (AppDb db, Guid id, StepInput input) =>
{
    var flow = await db.Flows.FindAsync(id) ?? new Flow(id);
    flow.Apply(input);
    await db.SaveChangesAsync();
    return Results.Redirect($"/wizard/{id}/next");
});

Motif : Messages Flash avec TempData

  • Réglez POST; lisez une fois après redirection.
TempData["Flash"] = "Profile saved";
return RedirectToAction("Index");

Vue sur le rasoir:

@if (TempData["Flash"] is string flash) {
  <div class="alert alert-info">@flash</div>
}

Motif: Panier d'achat

  • Petits chariots: Session (si votre échelle est modeste et que vous avez une session collante/distribuée).
  • Chariots plus grands/multi-dispositifs: DB + cart-id en cookie ou URL. Cache pour la vitesse.
flowchart LR
  U[User] -- cart-id cookie --> S[Server]
  S --> DB[(Cart Table)]
  S <--> Cache[Distributed Cache]

Liste de contrôle de la sécurité, de la protection des renseignements personnels et de la conformité

  • Valider tous les états fournis par le client : requête, en-têtes, formulaires, cookies, réclamations JWT.
  • Protégez l'état client sensible : utilisez la protection des données pour les cookies que vous délivrez ; ne gardez jamais de secrets dans les chaînes de requêtes.
  • Définir les drapeaux des cookies & #160;: Secure, HttpOnly, SameSite, IsEssential (si requis par le consentement/le besoin fonctionnel).
  • Régénérer les cookies d'authentification sur les changements de privilèges; garder les revendications minimales.
  • Chiffrer au repos pour les magasins côté serveur au besoin; assurer la rotation des clés (clés de protection des données, clés de signature JWT).
  • RGPD/CCPA: fournir des voies d'exportation et de suppression des données des utilisateurs; réduire au minimum la rétention.

Matrice de décision (feuille de chaleur)

classDiagram
  class Items {
    +Per-request only
    +Great for middleware->endpoint handoff
  }
  class QueryRoute {
    +Explicit, linkable
    -User-controlled
  }
  class Headers {
    +Tracing, idempotency
    -Noisy, untrusted
  }
  class Cookies {
    +Persist small prefs
    -Size, perf, consent
  }
  class TempData {
    +One-redirect messages
    -Ephemeral
  }
  class Session {
    +Conversational state
    -Scaling complexity
  }
  class MemoryCache {
    +Fast, in-proc
    -Not shared across instances
  }
  class DistributedCache {
    +Shared across servers
    -Serialization, ops
  }
  class AuthCookieClaims {
    +Identity, roles
    -Don’t overstuff
  }
  class JWT {
    +Stateless, cross-domain
    -Revocation/rotation
  }
  class DB {
    +Durable, authoritative
    -Latency, complexity
  }

Picks rapides & #160;:

  • Besoin d'un flash redirect? TempData.
  • Besoin d'assistant pour plusieurs requêtes dans une session? Session (ou DB + clé si long-running/multi-dispositif).
  • Besoin d'évolutivité et API apatrides? JWT pour l'identité, DB/DistributedCache pour l'état.
  • Besoin de passer les données à l'intérieur du pipeline seulement? HttpContext.Items.
  • Besoin de cache pour les recherches calculées? IMemoryCache localement; IDistributedCache à travers une ferme.

Pages MVC vs Razor vs API minimales : mêmes fondations, différentes formes

Les trois piles s'assoient sur les mêmes primitives (HttpContext, model reliure, auth, data protection). Les exemples ci-dessus montrent que les API diffèrent principalement en ergonomie:

  • APIs minimales: liaison des paramètres à partir de route/query/corps/claims; retour Results.*.
  • MVC: attributs, filtres, reliure du modèle dans les paramètres d'action/modèles de vision.
  • Pages de rasoir : gestionnaires de page avec propriétés liées et helpers de tag pour générer des liens/forms.

Ils partagent tous les mêmes mécanismes étatiques dont il est question ici.


Pièges et anti-patterns

  • Stockage de données importantes ou sensibles dans des cookies ou TempData.
  • Selon le cache en mémoire pour l'exactitude (c'est un cache, pas la vérité).
  • Autorisation de construire sur la base de la route client-sent / drapeaux de requête sans vérification du serveur.
  • Suralimenter les cookies d'auth ou les JWT avec des allégations volatiles.
  • Session sans stratégie de distribution (travaille localement, casse à l'échelle).

Real-World State Management: Ce que j'utilise réellement (et ne pas utiliser)

Après vous avoir montré toutes ces options, voici mon évaluation honnête de ce qui fonctionne en production pour ma plateforme de blog.

Ma pile de gestion d'état (par ordre de fréquence)

Mécanisme Fréquence Cas d'utilisation Satisfaction |-----------|-----------|-----------|--------------| | IMEmoryCache Catégories, métriques, tâches de traduction | Cache de sortie Haut de la page Articles de blog rendus, listes | Cache-réponse En-têtes HTTP haute encastrement Fonctionne avec OutputCache | AffichageBag Paramètres de l'analytique, titres de la page | Demandes d'indemnisation Mode d'emploi Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur Utilisateur | Route/Query Pagination, filtrage, limaces | Base de données Les articles de blog, les commentaires, l'état Source de la vérité | Cookies Préférences de l'utilisateur (futur) | Séance Aucune configuration d'un magasin distribué | TempData Jamais ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | HttpContext.Items Aucun cas d'utilisation | IDistributedCache Aucun serveur (pour l'instant)

Avantages et inconvénients détaillés de l'expérience de production

IMEmoryCache

Ce que je l'utilise pour:

  • Liste des catégories (globale, 30 min TTL)
  • Suivi des tâches de traduction par utilisateur (6h absolue + 1h coulissante)
  • Réponses externes aux API (métriques Umami, 1h TTL)

Points positifs dans la pratique:

  • Rapidité du feu (en cours de fabrication)
  • Pas de frais généraux de sérialisation
  • Diminution de la charge DB/API de ~95%
  • Facile à mettre en œuvre et à comprendre
  • Observabilité via SerilogTracking

Points négatifs que j'ai frappés :

  • La mémoire fuit si vous ne limitez pas les caches par utilisateur
  • Effacé sur le redémarrage de l'application (acceptable pour mon cas d'utilisation)
  • Non partagé entre les serveurs (fin pour un seul exemple)
  • L'invalidation de cache est manuelle (nécessite de supprimer() explicitement)

Limite de calibrage: Si j'arrive à plusieurs serveurs, j'aurais besoin d'IDistributedCache (Redis) pour l'état partagé. Pour l'instant, un seul serveur + cache mémoire est parfait.

SortieCache + RéponseCache

Ce que je l'utilise pour:

  • rendu des messages de blog (1 heure serveur, 5 min client/CDN)
  • Liste des blogs/pages de catégories
  • Données du calendrier

Points positifs dans la pratique:

  • 25x Accélération (50ms → 2ms)
  • Échelles pour un trafic élevé sans briser une sueur
  • Séparer les TTL pour le serveur par rapport au client
  • Fonctionne parfaitement avec HTMX (VaryByHeader)
  • Impact mesurable via les mesures de Prométhée

Points négatifs que j'ai frappés :

  • Déboguer la confusion (oublier le cache)
  • Explosion de cache avec de nombreuses combinaisons de paramètres de requête
  • Fenêtre Staleness (5 min pour les clients)
  • Nécessité d'invalider manuellement les mises à jour de contenu

Meilleur pour: Applications lue-lourdes avec principalement du contenu statique. Pas bon pour les données personnalisées ou en temps réel.

ViewBag

Ce que je l'utilise pour:

  • Données de mise en page globale (analyse, catégories)
  • Titres des pages

Points positifs dans la pratique:

  • Simple et rapide pour les données au niveau de la mise en page
  • Set une fois dans BaseController, disponible partout
  • Fonctionne bien avec la liste des catégories mises en cache

Points négatifs que j'ai frappés :

  • Pas de sécurité de type (défaut de typos au moment de l'exécution)
  • Tenter de surutiliser des données complexes
  • Difficile à tester

La règle I est la suivante : ViewBag pour des scalars simples seulement. Les objets complexes vont dans ViewModèles.

Revendications au titre de l'aide d'État

Ce que je l'utilise pour:

  • ID utilisateur, nom, courriel, URL d'avatar
  • Drapeau d'administration (vérification sub réclamation contre config)

Points positifs dans la pratique:

  • Sécurisation (cookie signé, Protection des données)
  • Automatique avec ASP.NET Core Identity/OAuth
  • Disponible via User.Claims partout
  • L'expiration coulissante maintient les utilisateurs connectés

Points négatifs que j'ai frappés :

  • Limite de la taille des cookies (ne pas surestimer les allégations)
  • Les réclamations sont statiques jusqu'à la réouverture de la session
  • Si j'ajoute une revendication (comme "IsEditor"), besoin de ré-émettre un cookie d'auth

Meilleures pratiques: Gardez les revendications minimales et stables. Ne mettez pas les données qui changent fréquemment dans les revendications.

Des choses que je n'utilise pas (et pourquoi)

Séance (jamais utilisée):

  • Requerrait un magasin distribué (Redis)
  • Ajoute de la complexité pour un bénéfice minimal
  • Mes cas d'utilisation sont mieux servis par:
    • Créances (identité)
    • IMémoryCache (état de courte durée)
    • Base de données (état durable)

TempData (jamais utilisé):

  • HTMX éliminé le modèle post-redirect-Get
  • Les formulaires retournent des vues partielles avec des messages en ligne
  • Pas besoin de survivre aux redirections

HttpContext.Items (jamais utilisés):

  • Je n'ai pas eu de cas d'utilisation pour l'état par demande
  • Mon intergiciel ne calcule pas les valeurs des contrôleurs
  • Si j'avais besoin de la détection des locataires, je l'utiliserais.

IDistributedCache (jamais utilisé):

  • Déploiement d'un serveur unique
  • IMemoryCache répond à tous les besoins
  • Utiliserait Redis si j'échelle vers plusieurs serveurs

Évolution de mon approche

Phase 1 (initiale): Aucune mise en cache du tout. Chaque requête a frappé la base de données et rendu Markdown.

Phase 2 (première optimisation) : Ajouté IMemoryCache pour les catégories. Voir réduction immédiate de la charge DB. Conservé simple: 30 min TTL, pas de logique fantaisie.

Phase 3 (modification) : Ajout de OutputCache pour les billets de blog lorsque le trafic a augmenté. Amélioration massive des performances. Erreur initiale : mise en cache pendant 5 minutes seulement.

Phase 4 (observation): Ajout de SerilogTraçage vers le cache métriques. Les erreurs de cache découvertes étaient élevées en raison du formatage de la date dans les clés. yyyyMMdd Le taux de succès est passé de 60 % à 95 %.

Phase 5 (intégration HTMX): Ajouté VaryByHeader pour hx-request. Initialement oublié ceci et servi des pages complètes aux demandes HTMX. Debugging cauchemar jusqu'à ce que je l'ai compris.

État actuel : Heureux avec la pile. IMémoryCache + OutputCache + ResponseCache gère 98% de mes besoins de gestion d'état. Base de données pour l'état durable. Auth revendique l'identité. C'est tout.

Conseils pour votre application

Commencez ici :

  1. Params pour la navigation/filtrage (toujours apatrides d'abord)
  2. Demandes d'identité
  3. Base de données pour tout ce qui doit persister
  4. IMemoryCache pour les données calculées par lecture lourde
  5. ExtrantCache pour les pages coûteuses à livrer

Ajouter si nécessaire: 6. Session (uniquement si vous devez avoir un état conversationnel côté serveur) 7. IDistributedCache (uniquement lorsque vous passez à plusieurs serveurs) 8. Cookies (pour les préférences côté client, consentement)

Éviter:

  • Objets complexes dans ViewBag/TempData
  • Session sans magasin de soutien distribué
  • Cacher des données spécifiques à l'utilisateur ou changeant fréquemment
  • Optimisation prématurée (mesure d'abord!)

Enseignements clés:

  • Commencer simple, ajouter la complexité seulement lorsque les mesures montrent que vous en avez besoin
  • L'observabilité est critique (connaître les taux de succès du cache !)
  • Pensez aux limites d'échelle tôt (les caches par utilisateur ont besoin de limites)
  • Invalidation du cache d'essai à fond (l'impasse est un vrai problème)
  • Documentez vos choix de TTL (futur vous demanderez "pourquoi 30 minutes?")

Enveloppe

Choisissez l'option la plus légère qui répond à vos besoins, préférez les modèles apatrides quand vous le pouvez, et soyez explicite sur la sécurité et le cycle de vie.

Si vous voulez aller plus loin sur la façon dont ces morceaux coulent à travers le pipeline, voyez ma série commençant par Première partie : Aperçu général et Fondation et surtout le middleware et les parties routage.

Bon bâtiment.


Annexe: Autres exemples d'exemplaires-pastables (renvois)

Ces exemples approfondissent les sections précédentes avec des détails de qualité de production que vous pouvez coller dans net9 modèles minimums, MVC, ou Pages Razor.

Cookies : Protégez les valeurs avec la protection des données

using Microsoft.AspNetCore.DataProtection;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDataProtection();
var app = builder.Build();

app.MapPost("/prefs/secure/{value}", (HttpContext ctx, string value, IDataProtectionProvider dp) =>
{
    var protector = dp.CreateProtector("prefs.theme");
    var protectedValue = protector.Protect(value);
    ctx.Response.Cookies.Append("pref.theme.p", protectedValue, new CookieOptions
    {
        HttpOnly = true,
        Secure = true,
        SameSite = SameSiteMode.Lax,
        Expires = DateTimeOffset.UtcNow.AddYears(1)
    });
    return Results.Ok();
});

app.MapGet("/prefs/secure", (HttpContext ctx, IDataProtectionProvider dp) =>
{
    if (ctx.Request.Cookies.TryGetValue("pref.theme.p", out var v))
    {
        var protector = dp.CreateProtector("prefs.theme");
        return Results.Text(protector.Unprotect(v));
    }
    return Results.NotFound();
});

Astuce : Dans les déploiements multinoeuds, persistez les clés de protection des données (p. ex. vers un système de fichiers partagé, Redis ou Azure Key Vault) afin que les cookies puissent être lus dans toutes les instances.

Antiforgery dans les API MVC, Pages Razor et Minimal

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();
builder.Services.AddAntiforgery(o => o.HeaderName = "X-CSRF-TOKEN");
var app = builder.Build();

app.MapGet("/antiforgery/token", (IAntiforgery af, HttpContext ctx) =>
{
    var tokens = af.GetAndStoreTokens(ctx);
    return Results.Json(new { token = tokens.RequestToken });
});

app.MapPost("/submit", (HttpContext ctx) => Results.Ok("posted"))
   .AddEndpointFilter(async (efiContext, next) =>
   {
       var af = efiContext.HttpContext.RequestServices.GetRequiredService<IAntiforgery>();
       await af.ValidateRequestAsync(efiContext.HttpContext);
       return await next(efiContext);
   });

app.MapControllers();
app.MapRazorPages();
  • MVC: décorer les actions avec [ValidateAntiForgeryToken] et utilisation @Html.AntiForgeryToken() sous forme de formulaires.
  • Pages Razor : activées par défaut sur les messages de formulaire; utiliser asp-antiforgery="true" si nécessaire.
  • Minimal: valider via IAntiforgery comme il est indiqué.

Session avec Redis et l'expiration coulissante vs l'expiration absolue

builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379");
builder.Services.AddSession(o =>
{
    o.IdleTimeout = TimeSpan.FromMinutes(20); // sliding
    o.IOTimeout = TimeSpan.FromSeconds(2);
    o.Cookie.HttpOnly = true;
    o.Cookie.IsEssential = true;
});
var app = builder.Build();
app.UseSession();

Conservez des données petites et compressibles seulement. Persistez les vraies chariots/commandes à un DB.

IMemoryCache avec options d'entrée et callback d'expulsion

builder.Services.AddMemoryCache();

app.MapGet("/fx/{pair}", (IMemoryCache cache, string pair) =>
{
    var key = $"fx:{pair.ToLowerInvariant()}";
    return Results.Ok(cache.GetOrCreate(key, entry =>
    {
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
        entry.SlidingExpiration = TimeSpan.FromMinutes(2);
        entry.Size = 1; // enable size-based eviction if configured
        entry.RegisterPostEvictionCallback((k, v, reason, state) =>
        {
            Console.WriteLine($"Evicted {k} because {reason}");
        });
        return 0.92m; // fetch from external service in real life
    }));
});

IDistributedCache get‐or‐set avec jitter pour éviter les tampons

builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379");

app.MapGet("/feature/{name}", async (IDistributedCache cache, string name) =>
{
    var key = $"feat:{name}";
    var cached = await cache.GetStringAsync(key);
    if (cached is not null) return Results.Text(cached);

    // Lock key to prevent thundering herd (very simple approach)
    var lockKey = key + ":lock";
    var gotLock = await cache.SetStringAsync(lockKey, "1", new DistributedCacheEntryOptions
    {
        AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(5)
    });

    try
    {
        cached = await cache.GetStringAsync(key);
        if (cached is null)
        {
            var computed = "on"; // expensive work
            var rnd = Random.Shared.Next(0, 15); // jitter
            await cache.SetStringAsync(key, computed, new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5).Add(TimeSpan.FromSeconds(rnd))
            });
            cached = computed;
        }
    }
    finally
    {
        await cache.RemoveAsync(lockKey);
    }

    return Results.Text(cached);
});

Pour le verrouillage robuste, préférez les primitives Redis (SET NX EX) via StackExchange.Redis.

Délivrance et validation des JWT localement (demo)

using System.IdentityModel.Tokens.Jwt;
using Microsoft.IdentityModel.Tokens;
using System.Security.Claims;

var key = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes("super-secret-key-please-rotate"));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer("Bearer", o =>
    {
        o.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = false,
            ValidateAudience = false,
            IssuerSigningKey = key,
            ValidateIssuerSigningKey = true,
            ValidateLifetime = true
        };
    });
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

app.MapPost("/token", () =>
{
    var claims = new[] { new Claim(ClaimTypes.Name, "alice") };
    var jwt = new JwtSecurityToken(claims: claims, expires: DateTime.UtcNow.AddMinutes(30), signingCredentials: creds);
    var token = new JwtSecurityTokenHandler().WriteToken(jwt);
    return Results.Json(new { access_token = token });
});

app.MapGet("/who", [Microsoft.AspNetCore.Authorization.Authorize] () => "ok");

Mises à jour conditionnelles avec ETags (If-Match)

record Todo(int Id, string Title, string Version);
var store = new Dictionary<int, Todo> { [1] = new(1, "Ship", "v1") };

app.MapGet("/todo/{id:int}", (int id, HttpContext ctx) =>
{
    if (!store.TryGetValue(id, out var t)) return Results.NotFound();
    ctx.Response.Headers.ETag = t.Version;
    return Results.Json(t);
});

app.MapPut("/todo/{id:int}", (int id, HttpContext ctx, Todo input) =>
{
    if (!store.TryGetValue(id, out var current)) return Results.NotFound();
    var ifMatch = ctx.Request.Headers["If-Match"].ToString();
    if (string.IsNullOrEmpty(ifMatch) || ifMatch != current.Version)
        return Results.StatusCode(StatusCodes.Status412PreconditionFailed);

    var next = current with { Title = input.Title, Version = $"v{DateTime.UtcNow.Ticks}" };
    store[id] = next;
    ctx.Response.Headers.ETag = next.Version;
    return Results.Ok(next);
});

TempData : objets complexes via JSON

public static class TempDataJsonExtensions
{
    public static void Put<T>(this ITempDataDictionary tempData, string key, T value)
        => tempData[key] = System.Text.Json.JsonSerializer.Serialize(value);

    public static T? Get<T>(this ITempDataDictionary tempData, string key)
        => tempData.TryGetValue(key, out var o) && o is string s
           ? System.Text.Json.JsonSerializer.Deserialize<T>(s)
           : default;
}

// Usage in MVC action
TempData.Put("WizardState", new { Step = 2, Name = "Alice" });
var state = TempData.Get<dynamic>("WizardState");

Jeton EF Cohérence de base

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    [Timestamp] public byte[] RowVersion { get; set; } = default!;
}

// On update
try
{
    await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
    return Results.StatusCode(StatusCodes.Status412PreconditionFailed);
}
app.MapPost("/promote", async (HttpContext ctx) =>
{
    var u = ctx.User;
    var claims = u.Claims.ToList();
    claims.Add(new Claim(ClaimTypes.Role, "Editor"));
    var id = new ClaimsIdentity(claims, "Cookies");
    await ctx.SignInAsync("Cookies", new ClaimsPrincipal(id));
    return Results.Ok();
});

Cela devrait couvrir les lacunes : des défauts de sécurité plus forts, une préparation multinoeud et des modèles du monde réel pour les caches, les jetons et les requêtes conditionnelles.


Plongée profonde : HttpContext.Items (Modèles pratiques et aides)

HttpContext.Items est un sac par demande (IDictionnaire<objet, objet?>) qui ne vit que pour la durée d'une seule demande. Il est parfait pour passer des valeurs calculées du middleware/filters à vos terminaux, contrôleurs et gestionnaires de Pages Razor sans toucher l'état global ou les magasins de longue durée.

  • Cycle de vie : créé au début de la demande; rejeté lorsque la réponse est terminée.
  • Champ d'application: demande actuelle seulement — ne pas croiser les redirections ou le travail de fond.
  • Performance: O(1) recherche; idéal pour la mise en cache par demande.
  • Sécurité: côté serveur seulement; non visible pour le client.

Pourquoi les items au lieu de

  • Session/TempData: Ces demandes croisées et introduire des préoccupations de distribution.
  • DI Services étendus: Utilisez-les pour le comportement et les dépendances partagées. Les articles sont mieux pour les valeurs ad-hoc, calculées (tenant, local utilisateur, drapeaux de fonctionnalités) et les caches par demande.
  • HttpContext.Caractéristiques: Pour les fonctions framework/transport-level (IEndpointFeature, IHttpUpgradeFeature).

Éviter les collisions clés : clés fortement typées

Parce que les items utilisent des touches d'objet, préfèrent les touches d'objet statique privé ou un type de clé dédié pour éviter les collisions de nom.

public static class ItemKeys
{
    public static readonly object TenantId = new();
    public static readonly object UserLocale = new();
    public static readonly object PerRequestCache = new();
}

Ou créez un wrapper dactylographié avec des extensions :

public static class HttpContextItemsExtensions
{
    public static void Set<T>(this HttpContext ctx, object key, T value)
        => ctx.Items[key] = value!;

    public static T? Get<T>(this HttpContext ctx, object key)
        => ctx.Items.TryGetValue(key, out var v) ? (T?)v : default;

    public static T GetOrCreate<T>(this HttpContext ctx, object key, Func<T> factory)
    {
        if (ctx.Items.TryGetValue(key, out var existing) && existing is T typed)
            return typed;
        var created = factory();
        ctx.Items[key] = created!;
        return created;
    }
}

Motif: Calculer dans le middleware, consommer dans les paramètres/contrôleurs/pages

// Program.cs
app.Use(async (ctx, next) =>
{
    var tenant = ctx.Request.Headers["X-TenantId"].FirstOrDefault() ?? "public";
    ctx.Set(ItemKeys.TenantId, tenant); // using extension above

    // Per-request cache holder (optional)
    ctx.Set(ItemKeys.PerRequestCache, new Dictionary<string, object?>());

    await next(ctx);
});

// Minimal API
app.MapGet("/whoami", (HttpContext ctx) => new
{
    Tenant = ctx.Get<string>(ItemKeys.TenantId),
});

// MVC Controller
public IActionResult WhoAmI()
    => Json(new { Tenant = HttpContext.Get<string>(ItemKeys.TenantId) });

// Razor Page handler
public IActionResult OnGet()
    => new JsonResult(new { Tenant = HttpContext.Get<string>(ItemKeys.TenantId) });

Motif: cache par demande pour éviter le travail répété

Utilisez les items comme un minuscule cache ainsi répété lit dans la même requête ne re-hit bases de données / services.

public static class PerRequestCacheExtensions
{
    public static async Task<T> GetOrAddAsync<T>(this HttpContext ctx, string key, Func<Task<T>> factory)
    {
        var bag = ctx.Get<Dictionary<string, object?>>(ItemKeys.PerRequestCache)
                  ?? ctx.GetOrCreate(ItemKeys.PerRequestCache, () => new Dictionary<string, object?>());

        if (bag.TryGetValue(key, out var val) && val is T hit)
            return hit;

        var created = await factory();
        bag[key] = created!;
        return created;
    }
}

// Usage in endpoint
app.MapGet("/profile", async (HttpContext ctx, IUserRepo repo) =>
{
    var userId = ctx.User.Identity?.Name ?? "anon";
    var profile = await ctx.GetOrAddAsync($"profile:{userId}", () => repo.LoadAsync(userId));
    return Results.Json(profile);
});

Remarques:

  • Threading: Une requête unique s'exécute généralement sur un chemin logique; Items is="t thread-safe for parallel writings. Si vous commencez des tâches parallèles qui partagent Items, ajoutez votre propre synchronisation.
  • Taille: Garder des valeurs petites et bon marché pour calculer/sérialiser. Il est en mémoire par demande.

Motif : Filtres qui peuplent des éléments (Pages VMC/Razor)

public class TenantFilter : IAsyncResourceFilter
{
    public async Task OnResourceExecutionAsync(ResourceExecutingContext context, ResourceExecutionDelegate next)
    {
        var tenant = context.HttpContext.Request.Headers["X-TenantId"].FirstOrDefault() ?? "public";
        context.HttpContext.Set(ItemKeys.TenantId, tenant);
        await next();
    }
}

// Register filter globally
services.AddControllersWithViews(o => o.Filters.Add<TenantFilter>());

Motif: Enrichir les bûches sans allocation partout

Calculez une fois, puis lisez dans la loging scopes ou middleware.

app.Use(async (ctx, next) =>
{
    var correlationId = ctx.Request.Headers["X-Correlation-Id"].FirstOrDefault() ?? Guid.NewGuid().ToString("n");
    ctx.Items["CorrelationId"] = correlationId; // string key acceptable for app-local use

    using (logger.BeginScope(new { CorrelationId = correlationId }))
    {
        await next(ctx);
    }
});

Quand ne pas utiliser d'éléments

  • Données nécessaires après redirection ou entre les requêtes (utiliser TempData/Session/DB à la place).
  • Les monotons ou caches de demandes croisées (utiliser IMemoryCache/IDdistributedCache).
  • Valeurs qui appartiennent à l'identité/autorisation (utiliser les revendications/politiques).

Règle rapide: Si elle est calculée au cours de cette requête et lue dans cette requête par votre propre code, Items est idéal.

Finding related posts...
logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.