This is a viewer only at the moment see the article on how this works.
To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk
This is a preview from the server running through my markdig pipeline
Sunday, 09 November 2025
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.
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.
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
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);
});
app.MapGet("/whoami", (HttpContext ctx) => new { Tenant = ctx.Items["TenantId"] });
public IActionResult WhoAmI() => Json(new { Tenant = HttpContext.Items["TenantId"] });
public IActionResult OnGet() => new JsonResult(new { Tenant = HttpContext.Items["TenantId"] });
Exemples
app.MapGet("/orders/{id:int}", (int id, int? page) => Results.Ok(new { id, page }));
// GET /orders/5?page=2
[HttpGet("/orders/{id:int}")]
public IActionResult Details(int id, int? page)
=> View(new { id, page });
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)
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
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)
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Save(SettingsModel model)
{
// validate & persist
return RedirectToAction(nameof(Summary), new { tab = model.SelectedTab });
}
public IActionResult OnPost(SettingsModel model)
{
return RedirectToPage("/Settings/Summary", new { tab = model.SelectedTab });
}
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.
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]
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;
}
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 :
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 :
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.
Cache-à-l'abri (le plus fréquent):
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.
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.
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);
});
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;
}
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é.
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:
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).
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 :
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.
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:
yyyyMMdd format dans la clé si différentes fois le même jour partager le cacheÉ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é.
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:
Quand je n'utilise pas IMemoryCache:
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) }));
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
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);
});
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");
});
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.
ASP.NET Core a deux systèmes de mise en cache similaires:
Cache-Control, Vary) indiquant aux navigateurs et aux CDN comment mettre en cache
eIls 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.
// 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:
OutputCache vérifie d'abord: Ai-je une réponse en cache pour my-post + en langue ?
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
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.
Cuisson CDN (si vous utilisez Cloudflare/Fastly): caches CDN pendant 5 minutes. Les utilisateurs du monde entier ont frappé le CDN, pas mon serveur.
[ResponseCache(Duration = 300)] // 5 minutes client/CDN cache
[OutputCache(Duration = 3600)] // 1 hour server cache
Motifs:
É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:
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:
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
[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éepage=2&pageSize=20&language=en → Une autre entréepage=1&pageSize=10&language=en → Une autre entréeAtténuation:
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
OutputCache est ignoré pour:
Set-Cookie en-têteCache-Control: no-storeExemple 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
}
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:
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, ...)]
où responseCacheDuration est 0 en développement, 300 en production.
Pour :
Inconvénients:
Quand j'ai sauté la mise en cache :
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.
Ces trois-là sont souvent confus. Voici comment ils diffèrent et ce que j'utilise réellement dans la production.
// 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 |
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:
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.
// 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 :
Title et Category propriétés de chaque modèle de vueAntipattern:
// 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:
ViewBag.Usr échoue à l'exécution)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);
}
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:
[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!"
});
}
Quand TempData a du sens :
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.
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 :
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
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");
});
TempData["Flash"] = "Profile saved";
return RedirectToAction("Index");
Vue sur le rasoir:
@if (TempData["Flash"] is string flash) {
<div class="alert alert-info">@flash</div>
}
flowchart LR U[User] -- cart-id cookie --> S[Server] S --> DB[(Cart Table)] S <--> Cache[Distributed Cache]
Secure, HttpOnly, SameSite, IsEssential (si requis par le consentement/le besoin fonctionnel).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;:
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:
Results.*.Ils partagent tous les mêmes mécanismes étatiques dont il est question ici.
Après vous avoir montré toutes ces options, voici mon évaluation honnête de ce qui fonctionne en production pour ma plateforme de blog.
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)
Ce que je l'utilise pour:
Points positifs dans la pratique:
Points négatifs que j'ai frappés :
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.
Ce que je l'utilise pour:
Points positifs dans la pratique:
Points négatifs que j'ai frappés :
Meilleur pour: Applications lue-lourdes avec principalement du contenu statique. Pas bon pour les données personnalisées ou en temps réel.
Ce que je l'utilise pour:
Points positifs dans la pratique:
Points négatifs que j'ai frappés :
La règle I est la suivante : ViewBag pour des scalars simples seulement. Les objets complexes vont dans ViewModèles.
Ce que je l'utilise pour:
sub réclamation contre config)Points positifs dans la pratique:
User.Claims partoutPoints négatifs que j'ai frappés :
Meilleures pratiques: Gardez les revendications minimales et stables. Ne mettez pas les données qui changent fréquemment dans les revendications.
Séance (jamais utilisée):
TempData (jamais utilisé):
HttpContext.Items (jamais utilisés):
IDistributedCache (jamais utilisé):
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.
Commencez ici :
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:
Enseignements clés:
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.
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.
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.
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();
[ValidateAntiForgeryToken] et utilisation @Html.AntiForgeryToken() sous forme de formulaires.asp-antiforgery="true" si nécessaire.IAntiforgery comme il est indiqué.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.
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
}));
});
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.
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");
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);
});
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");
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.
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.
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;
}
}
// 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) });
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:
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>());
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);
}
});
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.
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.