AGGIORNAMENTO (2025-11-10): Aggiunti esempi più pratici dalla mia base di codice reale che mostrano modelli del mondo reale, trade-off, gotchas, e l'evoluzione da semplici e sofisticati approcci di gestione dello stato. Include dettagliati modelli IMemoryCache, VisualizzaBag utilizzo (buono e cattivo), ResponseCache / OutputCache strategie, e le lezioni apprese dalla produzione.
HTTP è notoriamente apolide. La tua app... non è. Gli utenti accedono, aggiungono elementi ai cestini, saltano tra le pagine, ritornano domani, e si aspettano che tu ricordi. In ASP.NET Core (MVC, Razor Pages, Minimal API), ci sono un sacco di modi per preservare e trasferire lo stato tra le richieste. Alcuni sono solo per-richiesta. Alcuni ultimi per una sessione. Alcuni vivono nel client. Alcuni sono distribuiti e sopravvivere server riavvia. Ogni scelta ha compromessi tra sicurezza, prestazioni, scala, e sviluppatore ergonomia.
Questo post cataloga le opzioni, mostra esempi concreti, copiabili in tutte e tre le pila, e ti fornisce la guida decisionale in modo da poter scegliere lo strumento giusto per il lavoro.
NOTA: Questo fa parte dei miei esperimenti con l'AI (elaborazione assistita) + il mio editing. Stessa voce, stesso pragmatismo; solo dita più veloci.
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"]
Esempio di middleware (tutte le pile):
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"] });
Esempi
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();
Generazione di collegamenti che conservano lo stato:
// 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);
});
Idempotenza (modello):
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
Modello PRG nelle pagine 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 });
}
Esempio minimo di API:
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'uso di MVC/Razor Pages è identico tramite HttpContext.
Per l'integrità/riservatezza, utilizzare il sistema ASP.NET Core Data Protection per proteggere i payload da soli inseriti nei cookie.
Setup (Program.cs):
builder.Services.AddControllersWithViews().AddSessionStateTempDataProvider(); // optional
builder.Services.AddSession();
var app = builder.Build();
app.UseSession();
Nel controller MVC:
TempData["StatusMessage"] = "Saved!";
return RedirectToAction("Index");
In Razor Page handler:
TempData["StatusMessage"] = "Saved!";
return RedirectToPage("/Index");
In vista/pagina:
@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]
Configura:
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();
Uso della sessione (qualsiasi pila):
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);
});
Aiutanti di sessione:
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;
}
Una volta ho lavorato su un enorme progetto IT del governo del Regno Unito in cui l'uso improprio dello stato di sessione (tra molti altri peccati architettonici) è diventato un gioco da ragazzi. tutto in sessione: preferenze dell'utente, dati di forma multi-step, risultati di ricerca, calcoli temporanei, anche ricerche cache che avrebbero dovuto essere in una cache o in un database adeguato.
Il problema: Lo stato di sessione è stato memorizzato in-processo (ASP.NET stato di sessione in web.config, questo è stato pre-Core giorni). Ogni richiesta ha dovuto deserializzare oggetti di sessione di massa. Come il carico aumentato, stato di sessione mongolfizzato a decine di megabyte per utente. Con migliaia di utenti concorrenti, i server hanno esaurito la memoria.
La soluzione disperata: Abbiamo volato nella struttura di HP a Stoccarda per eseguire test di carico sul loro Superdome in quel momento, La macchina Windows più potente d'Europa. Era una bestia: decine di processori Itanium, centinaia di gigabyte di RAM. L'idea era di dimostrare che con abbastanza hardware, il sistema poteva soddisfare i requisiti.
Il risultato: Anche sul Superdome, non siamo riusciti a colpire gli obiettivi utente concorrenti richiesti. L'architettura dello stato di sessione era fondamentalmente rotta. La scalatura verticale non poteva salvare il disegno difettoso. La serializzazione/deserializzazione di sessione in overhead, combinata con la pressione di memoria da oggetti di sessione massicci, significava semplicemente che il sistema non poteva scalare non ad alcun costo ragionevole.
L'incubo della sicurezza: Peggio dei problemi di performance, abbiamo scoperto un errore di codifica che ha causato lo stato della sessione a perdita tra gli utenti. I dati di sessione dell'utente A appaiono occasionalmente nella sessione dell'utente B. Questo non era solo imbarazzante è stato catastrofico. Gli utenti erano Personale dell'NHS Abbiamo creato accidentalmente un meccanismo di violazione della protezione dei dati che potrebbe esporre informazioni mediche sensibili in diverse sessioni di operatori sanitari.
Cosa sarebbe dovuto succedere:
La lezione: Lo stato di sessione non scala verticalmente e appena scala orizzontalmente (anche con sessioni appiccicose o negozi distribuiti, stai ancora serializzando/deserializzando su ogni richiesta).L'esperimento Superdome ha dimostrato che lanciare hardware a problemi architettonici è costoso e spesso inutile.
Ma cosa più importante: bug di stato di sessione diventano vulnerabilità di sicurezza. Problemi di sicurezza del filo, condizioni di gara, gestione errata degli ID di sessione Questi non solo causano problemi di prestazioni, ma possono trapelare dati sensibili tra gli utenti. In un contesto sanitario (o bancario, o qualsiasi settore regolamentato), si tratta di un incubo di conformità e potenziale responsabilità penale.
Consulenza moderna:
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 (ad esempio, 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");
});
Avvertimento anti-pattern Cache-as-state: se deve essere durevole o autorevole, memorizzarlo in un database e opzionalmente cache.
Cache-aside (più comune):
Read-through (tramite una libreria/fornitore): la cache gestisce il caricamento su miss.
Write-through: le scritture vanno alla cache e al backing store in modo sincrono.
Write-behind: scrivi alla cache, ripulisci per memorizzare asincronamente (rischio: perdita/inconsistenza).
Aggiornare: aggiornare i tasti caldi prima della loro scadenza per evitare la mancanza di freddo.
Quando hai bisogno di invalidare molte voci correlate:
Prefisso versione (soft invalidation): bump a global version in a small key and compose keys with it.
// version key: "v:products"; keys like $"{version}:product:{id}"
var version = await cache.GetStringAsync("v:products") ?? "1";
var key = $"{version}:product:{id}";
Invalidare tutti i prodotti: incremento v:prodotti (i clienti perderanno naturalmente le vecchie chiavi prefisse).
Tag set per gruppo (Redis): mantenere un insieme di chiavi per tag; sulla invalidazione, recuperare i membri ed eliminare.
// 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);
Pub/Sub invalidation: pubblica un messaggio "invalidate:key"; ogni nodo rimuove la chiave dalla sua IMemoryCache locale.
Scansione con i modelli: SCAN/KEYS dovrebbe essere evitato in percorsi hot prod; ok per lo strumento di amministrazione su piccoli spazi di tasti.
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;
}
Ecco come in realtà uso IMemoryCache nella mia piattaforma blog, si è evoluto attraverso la prova e l'errore. Vi mostrerò tre modelli reali da semplice a sofisticato.
Questa è stata la mia prima implementazione di caching. Le categorie dei blog non cambiano spesso, quindi cache per 30 minuti:
// 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;
}
Perché funziona così:
Ho capito che ho colpito: Inizialmente ho usato una chiave stringa "Categorie." Funziona bene fino a quando non si dispone di più controller e uno accidentalmente riutilizza la stessa chiave. Ora uso costanti o tasti fortemente digitati (vedere la sezione precedente per evitare collisioni).
Questo stato di traduzione caches per utente. E 'più complesso perché ha bisogno di limiti e dovrebbe rimanere in vita fino a quando l'utente è attivo:
// 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)
});
}
}
Perché questo è diverso:
Errore che ho fatto: Inizialmente non ho limitato il numero di attività. Un utente di alimentazione ha attivato 50 più traduzioni e ho avuto una perdita di memoria. Ora tengo max 5 per utente.
Trade-off: Questo non scala a milioni di utenti. Se questo diventa un problema, mi trasferirei a IDistributedCache (Redis) o archivierei nel database con un indice su userId + startTime.
Questo caches analytics metriche e traccia l'efficacia della cache utilizzando Serilog tracciamento:
// 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;
}
}
Che cosa rende questa produzione-pronta:
yyyyMMdd formattazione in tempi così diversi nello stesso giorno condividere la cacheEvoluzione: Inizialmente ho cached per 10 minuti. Ma le metriche di Umami sono pesanti per recuperare e non cambiare molto, quindi 1 ora va bene. Ho scoperto questo guardando i dati SerilogTracing e vedendo manca cache eccessiva.
Monitoraggio in azione: In Seq (il mio aggregatore di log), posso interrogare:
ActivityName = "GetMetricsWithPrefix" and CacheHit = false
Questo mi dice il mio tasso di mancanza di cache. Se è alto, regolare TTL o strategia chiave.
| Schema | Caso d'uso | Scadenza | Controllo delle dimensioni | Osservabilità |
|---|---|---|---|---|
| Categorie | Global, rare-changing | Assoluto di 30 min | Non necessario (piccolo) | Registrazione di base |
| Attività di traduzione | Per utente, delimitato | Assoluto 6h + 1h scorrevole | Manuale (5 elementi max) | Nessuno (dovrebbe aggiungere!) |
| MetricsCity name (optional, probably does not need a translation) | Chiamate esterne costose | Assoluto 1h | Naturale (finestrato a tempo) | Tracciato completo |
Lezioni chiave:
Quando non uso IMemoryCache:
Imposta l' auth dei cookie:
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();
Iscrizione con crediti (MVC/minimale):
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("/");
});
Leggi i reclami (qualsiasi pila):
[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;
});
Uso:
[Authorize(AuthenticationSchemes = "Bearer")]
app.MapGet("/secure", () => "ok");
Panoramica delle sirene:
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
Schizzo del nucleo dell'impronta ambientale:
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();
Esempio 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");
});
Uso ResponseCache e OutputCache insieme sul mio blog per diversi scopi. Ecco perché si potrebbe desiderare entrambi e come differiscono.
ASP.NET Core ha due sistemi di cache simili:
Cache-Control, Vary) dire ai browser e ai CDN come fare la cache
eSi completano a vicenda. ResponseCache gestisce la cache client/CDN; OutputCache gestisce la cache lato server.
// 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);
}
Cosa succede quando qualcuno chiede /blog/my-post:
Controlla prima OutputCache: Ho una risposta cache per my-post + en Linguaggio?
ResponseCache imposta le intestazioni: Dopo OutputCache genera la risposta, ResponseCache aggiunge:
Cache-Control: public, max-age=300
Vary: hx-request
Caching del browser: Browser caches la risposta per 300 secondi (5 minuti). Le richieste successive da parte dello stesso utente non hanno nemmeno colpito il server.
Caching CDN (se si utilizza Cloudflare/Fastly): cache CDN per 5 minuti. Gli utenti in tutto il mondo hanno colpito il CDN, non il mio server.
[ResponseCache(Duration = 300)] // 5 minutes client/CDN cache
[OutputCache(Duration = 3600)] // 1 hour server cache
Motivazione:
Evoluzione: Inizialmente avevo entrambi a 5 minuti. Ma questo significava che il mio server stava re-rendering ogni 5 minuti, anche se il contenuto cambia raramente. Ora:
VaryByHeader = "hx-request" // ResponseCache
VaryByHeaderNames = new[] { "hx-request" } // OutputCache
Perche' questo e' importante: Le richieste HTMX includono hx-request: true Intestazione. Rendo risposte diverse:
Senza VaryBy, la cache restituisce il formato sbagliato. Con VaryBy, I cache due versioni di ogni pagina.
Esempio:
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) })]
Problema senza questo: /blog/my-post?language=fr servirebbe la versione in cache inglese.
Con VaryByQueryKeys: Voci separate della cache:
/blog/my-post?language=en → La chiave Cache include "en"/blog/my-post?language=fr → La chiave Cache include "fr"Utilizzo reale dalla mia lista di blog:
[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, /* ... */)
Avviso di esplosione della cache: Ogni combinazione unica di parametri = voce separata della cache:
page=1&pageSize=20&language=en → Una vocepage=2&pageSize=20&language=en → Un'altra vocepage=1&pageSize=10&language=en → Un'altra voceMitigazione:
ResponseCache lavora fuori dalla scatola, ma per OutputCache è necessario configurare:
// 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 viene saltato per:
Set-Cookie intestazioneCache-Control: no-storeEsempio in cui non lo uso:
// 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
}
Uso Prometheus metriche (espostato tramite la mia app) per tracciare:
// Pseudo-code for metrics
cache_hits_total{cache="output"} 45230
cache_misses_total{cache="output"} 892
Il mio tasso di cache colpito: ~98% per i post del blog (la maggior parte del traffico colpisce gli stessi post popolari ripetutamente).
Impatto:
A volte sto debug e ho bisogno di risposte nuove ogni volta:
// During development, comment out caching
// [ResponseCache(Duration = 300, ...)]
// [OutputCache(Duration = 3600, ...)]
public async Task<IActionResult> Show(string slug, string language = "en")
O utilizzare impostazioni specifiche per l'ambiente:
#if DEBUG
// No caching in development
#else
[ResponseCache(Duration = 300, ...)]
[OutputCache(Duration = 3600, ...)]
#endif
Approccio migliore: Usa configurazione:
[ResponseCache(Duration = responseCacheDuration, ...)]
dove responseCacheDuration è 0 in sviluppo, 300 in produzione.
Pro:
Punti negativi:
Quando saltavo la cache:
Il mio verdetto: Per un blog con contenuti per lo più statici e alto rapporto lettura/scrittura, la doppia cache è una vittoria enorme. Non lo userei su pannelli di amministrazione o dashboard con dati in rapida evoluzione.
Questi tre sono spesso confusi. Ecco come differiscono e ciò che effettivamente uso nella produzione.
// 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");
| Caratteristica | Visualizzazione dati | Visualizzazione busta | TempData |
|---|---|---|---|
| Tipo | ViewDataDictionary |
dinamica | ITempDataDictionary |
| Durata della vita | Richiesta corrente | Richiesta corrente | Un reindirizzamento |
| Accesso chiave | Chiavi stringa | Sintassi proprietà | Chiavi stringa |
| Sicurezza del tipo | Nessuno (fusione necessaria) | Nessuno (dinamica) | Nessuno (fusione necessaria) |
| Controllo dell'orario di compilazione | No | No | No |
| Sopravvive reindirizzare | No | No | Sì |
Nel mio blog, utilizzo ViewBag esclusivamente per il passaggio dei dati dai controllori al layout condiviso (analisi, categorie, ecc.):
// 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);
}
Poi nel mio layout (_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>
}
Perché questo modello funziona:
Errore che ho fatto in anticipo: Stavo impostando ViewBag.Categories in ogni singolo metodo di azione. DRY violazione e facile da dimenticare. OnActionExecutionAsync nel controller di base risolto questo.
// 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);
}
In considerazione di quanto segue:
@{
ViewData["Title"] = ViewBag.Title; // Standard MVC convention for <title>
}
<h1>Category: @ViewBag.Category</h1>
Va bene perche'
Title e Category proprietà per ogni modello di visualizzazioneAnti-pattern:
// 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 };
Problemi:
ViewBag.Usr fallisce al runtime)Meglio: modelli di visualizzazione fortemente digitati:
// 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);
}
Caso di utilizzo standard di 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();
}
Perché non uso TempData nel mio 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!"
});
}
Quando TempData ha senso:
TempData gotcha: Per impostazione predefinita supportata da cookies (dal ASP.NET Core 2.0). Se metti oggetti di grandi dimensioni in TempData, stai gonfiando il cookie inviato con ogni richiesta. Per un grande stato, usa Sessione con un backup store o 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]
Le mie regole:
Quale detentore dello Stato usare?
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
Esempio (DB + route key):
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");
Vista rasoio:
@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 (se richiesto dal consenso/esigenza funzionale).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
}
Scelte rapide:
Tutti e tre gli stack si trovano sulle stesse primitive (HttpContext, model binding, auth, data protection). Gli esempi di cui sopra mostrano che le API differiscono per lo più nell'ergonomia:
Results.*.Tutti condividono gli stessi meccanismi statali discussi in questa sede.
Dopo avervi mostrato tutte queste opzioni, ecco la mia onesta valutazione di ciò che funziona in produzione per la mia piattaforma blog.
| Meccanismo | Frequenza | Casi d'uso | Soddisfazione |
|---|---|---|---|
| ImemoryCache | Altissimo | Categorie, metriche, attività di traduzione | Essential Hoppenstedt |
| OutputCache | Alta | Rendered blog posts, liste | Vittoria enorme perf |
| ResponseCache | Alta | Intestazioni di cache HTTP | Funziona con OutputCache |
| VisualizzaEtichetta | Medium | Analytics settings, page titles | OK for simple stuff |
| Auth Claims | Medium | User identity, admin flag | Right tool for auth |
| Route/Query | Medium | Pagination, filtration, slugs | Stateless and linkable |
| Banca dati | Medium | Blog post, commenti, stato | Sorgente di verità |
| Cookie | Basso | Preferenze dell'utente (futuro) | Non sono ancora necessari |
| Sessione | Mai | - | • Setup negozio senza distribuzione |
| TempData | Mai | - | • HTMX elimina la necessità |
| HttpContext.Items | Mai | - | Non hanno avuto caso d'uso |
| IDistributedCache | Mai | - | • Single server (per ora) |
Per cosa lo uso:
Professionisti in pratica:
Contro che ho colpito:
Limite di scala: Se arrivassi a più server, avrei bisogno di IDistributedCache (Redis) per lo stato condiviso. Per ora, un singolo server + cache di memoria è perfetto.
Per cosa lo uso:
Professionisti in pratica:
Contro che ho colpito:
Meglio per: Applicazioni leggere con contenuti statici per lo più. Non buone per dati personalizzati o in tempo reale.
Per cosa lo uso:
Professionisti in pratica:
Contro che ho colpito:
Seguo l'articolo: VisualizzaBorsa per scalari semplici. Oggetti complessi vanno in ViewModels.
Per cosa lo uso:
sub reclamo contro la config)Professionisti in pratica:
User.Claims ovunqueContro che ho colpito:
Migliori pratiche: Mantenere i reclami minimi e stabili. Non mettere i dati che cambiano frequentemente nei reclami.
Sessione (mai usata):
TempData (mai usato):
HttpContext.Items (mai usato):
IDistributedCache (mai usato):
Fase 1 (iniziale): Non caching a tutti. Ogni richiesta ha colpito il database e reso Markdown. Ha funzionato bene per il traffico basso.
Fase 2 (prima ottimizzazione): Aggiunto IMemoryCache per le categorie. Sega immediata riduzione del carico DB. Tenerlo semplice: 30 min TTL, nessuna logica fantasia.
Fase 3 (scaling up): Aggiunto OutputCache per i post del blog quando il traffico è aumentato. Miglioramento delle prestazioni massivo. Errore iniziale: cached solo per 5 minuti. Aumento a 1 ora dopo il monitoraggio ha mostrato raramente cambiamenti di contenuto.
Fase 4 (osservabilità): Aggiunto SerilogTracciamento alla cache delle metriche. Le mancate cache scoperte erano alte a causa della formattazione della data nelle chiavi. yyyyMMdd Il tasso di Hit è passato dal 60% al 95%.
Fase 5 (integrazione HTMX): Aggiunto VaryByHeader per hx-request. Inizialmente dimenticato questo e servito pagine complete per le richieste HTMX. Debugging incubo fino a quando non ho capito.
Stato attuale: Happy with the stack. IMemoryCache + OutputCache + ResponseCache gestisce il 98% delle mie esigenze di gestione dello stato. Database per lo stato durevole.
Inizia da qui:
Aggiungere se necessario: 6. Sessione (solo se è necessario avere stato di conversazione lato server) 7. IDistributedCache (solo quando si scala su più server) 8. Cookies (per le preferenze lato client, consenso)
Evitare:
Lezioni chiave:
Stato in applicazioni web non è una taglia adatta a tutti. Scegli l'opzione più leggera che soddisfa le tue esigenze, preferisci modelli apolide quando puoi, ed essere esplicito sulla sicurezza e il ciclo di vita.
Se volete approfondire il flusso di questi pezzi attraverso la pipeline, vedete la mia serie iniziare con Parte 1: Panoramica e Fondazione e soprattutto il middleware e le parti di routing.
Felice edificio.
Questi esempi approfondiscono le sezioni precedenti con dettagli di produzione-grado è possibile incollare in net9 modelli minimi, MVC, o Razor Pages.
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();
});
Suggerimento: Nelle implementazioni multi-node, persistono le chiavi di protezione dei dati (ad esempio, in un file system condiviso, Redis, o Azure Key Vault) in modo che i cookie possano essere letti attraverso le istanze.
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] e uso @Html.AntiForgeryToken() in forme.asp-antiforgery="true" se necessario.IAntiforgery come mostrato.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();
Conservare solo piccoli dati comprimibili. Persistere carrelli/ordini reali a 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);
});
Per il bloccaggio robusto, preferisci Redis primitives (SET NX EX) tramite 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();
});
Ciò dovrebbe coprire le lacune: maggiori inadempimenti in materia di sicurezza, disponibilità multinodo e modelli reali di cache, gettoni e richieste condizionali.
HttpContext.Items è una borsa per-richiesta (IDictionary<object, object?>) che vive solo per la durata di una singola richiesta. È perfetta per passare i valori calcolati dai middleware/filter ai tuoi endpoint, controller e Razor Pages senza toccare lo stato globale o i negozi long-lived.
Poiché Oggetti usa chiavi oggetto, preferiscono chiavi oggetti statici privati o un tipo di chiave dedicata per evitare collisioni di nomi.
public static class ItemKeys
{
public static readonly object TenantId = new();
public static readonly object UserLocale = new();
public static readonly object PerRequestCache = new();
}
Oppure creare un wrapper digitato con le estensioni:
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) });
Utilizzare elementi come una cache piccola così ripetuto legge all'interno della stessa richiesta don't re-hit database / servizi.
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);
});
Note:
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>());
Computa una volta, poi leggi nell'ambito di registrazione o 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);
}
});
Regola veloce: Se si calcola durante questa richiesta e si legge all'interno di questa richiesta dal proprio codice, Articoli è l'ideale.
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.