Back to "在ASP.NET核心部分中保留请求之间的状态:实用的、无关系的指南(MVC, Razor Pages, Minimal APIs)"

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

AI-Article ASP.NET ASP.NET Core State Web Development

在ASP.NET核心部分中保留请求之间的状态:实用的、无关系的指南(MVC, Razor Pages, Minimal APIs)

Sunday, 09 November 2025

更新(2025-11-10): 从我的实际代码库中添加了更多实际例子,显示真实世界模式、权衡取舍、平衡取舍以及从简单国家管理方法向复杂国家管理方法的演变。 包括详细的IMemoryCache模式、ViewBag(好坏)使用情况、反应Cache/OutputCache战略以及从生产中汲取的教训。

一. 导言 导言 导言 导言 导言 导言 一,导言 导言 导言 导言 导言 导言

HTTP 是著名的无国籍。 您的应用程序... 并不是 。 用户签名, 将项目添加到篮子里, 在页面间跳动, 明天返回, 并期望您记住。 在 ASP. NET Core ( MVC, Razor Pages, Minimal APIs) 中, 在请求之间有很多保存和传输状态的方法。 有些是按请求来保存和传输的 。 有些是会话 。 有些是最后的会话 。 有些在客户中生活 。 有些是分布的, 有些在服务器上生存下来 。 每种选择都有在安全、 性能、 规模 和 开发者 人类工程学 之间的权衡 。

此日志将选项分类, 在所有三个堆叠中显示具体、 可复制的示例, 并为您提供决定指导, 以便您选择正确的工具 。

注:这是我在AI(协助起草)+我自己编辑的实验的一部分。同一声音,同样的务实;只是更快的手指。

风景一一一一一一

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
  • 以客户为对象:查询、路线、信头、表格、饼干、JWT。 水平缩放,但客户可以看到(必须酌情验证/签署/加密)。
  • 服务器携带:TempData、会话、缓存、DB。 需要亲和或分配战略。
  • 每项请求: HttpContext.项目 -- -- 可用于在一次请求中在内部传递数据。

金色规则(在我们潜入APPS之前)

  1. 安全第一:国家渗漏是一个真正的威胁。 服务器端状态(会话、模拟缓存)可以通过编码错误、种族条件或不正确的生命周期管理方式在用户之间渗漏。 无国籍模式不仅可以缩放,而且更安全。
  2. 当您需要水平缩放时( 将状态推至客户标牌、 耐久存储或分布式缓存) , 优先使用“ 无状态服务器” 模式 。
  3. 永远不要信任客户端控制状态。 验证、 签名和/ 加密 。
  4. 将大数据保存在 cookie 和信头之外; 它们让每个请求都浮出水面 。
  5. 仅将TempData 用于 后直接获取 (PRG) 一发像闪电一样的短消息 。
  6. 只有当您必须保持服务器- 端的谈话状态, 并且计划分配时才会使用会话, 并且对安全感到偏执。
  7. 索赔要求是身份和粗粗的地皮授权,而不是一般申请状态。
  8. Cache不是真相的来源,如果数据重要的话,用一个耐久的存储器来回溯。

请求国: HttpContext.

  • 范围:仅请求当前请求(管道结束时下降)
  • 用于: 在中件和端点/控制器之间传递计算值
  • 规模:无影响
  • 安全:仅服务器
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"]

Midroware 示例( 所有堆叠) :

app.Use(async (context, next) =>
{
    var tenantId = context.Request.Headers["X-TenantId"].FirstOrDefault() ?? "public";
    context.Items["TenantId"] = tenantId;
    await next(context);
});
  • 最小 API 端点 :
app.MapGet("/whoami", (HttpContext ctx) => new { Tenant = ctx.Items["TenantId"] });
  • MVC 主计长:
public IActionResult WhoAmI() => Json(new { Tenant = HttpContext.Items["TenantId"] });
  • Razor 页面处理程序 :
public IActionResult OnGet() => new JsonResult(new { Tenant = HttpContext.Items["TenantId"] });

透明客户端流动状态:路线价值和查询字符串

  • 范围:当前请求;客户明确承担。
  • 用于:导航环境、过滤、页数、资源特性
  • 安全:必须验证/授权;不隐藏秘密

实例实例实例实例

  • 最小 API :
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 });
  • Razor Pages (命令/细节.cshtml.cs):
public IActionResult OnGet(int id, int? page)
  => Page();

生成保存状态的链接 :

// 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)

标题:关联代号、相关性密钥、地标标志

  • 范围:当前请求,在答复中可选择性重复
  • 用于:追踪、重试安全、A/B旗帜
  • 安全性:作为不信任的输入、验证/白列表处理
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);
});

能力(模式):

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

窗体和隐藏字段

  • 范围:仅下一项请求(客户员额返回数值)
  • 用于: 用于向导步骤、 反伪造符号、 在整个 PRG 中保留小部分状态
  • 安全性:始终验证;与抗伪造结合

MVC/Razor Pages中的PRG模式:

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 });
}
  • Razor 页面:
public IActionResult OnPost(SettingsModel model)
{
    return RedirectToPage("/Settings/Summary", new { tab = model.SelectedTab });
}

曲奇:小、签名、有时加密

  • 范围:浏览器在到期前提出的每项请求
  • 用于:优惠、非敏感标志、同意、指定饼干(单独部分)
  • 权衡:规模限制(每饼干~4KB),业绩影响,必须符合同意法

最小 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"));

MVC/Razor Pages使用方式相同 HttpContext.

关于完整性/保密性,请使用 ASP.NET 核心数据保护系统 来保护自己放置在饼干中的有效载荷。


TemtaData: 单向通信管道

  • 范围:维持一个方向
  • 备份存储: Cookie( 默认) 或会话
  • 用于:闪电信息、PRG之后的审定摘要

设置( 方案. cs) :

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

MVC 控制器中:

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

在 Razor 页面处理器中 :

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

查看/页数 :

@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]

会议:服务器对话状态

  • 范围:浏览器会话( cookie 键+服务器存储)
  • 用于:多步向导、小马车数据、节流计数器
  • 权衡取舍:需要粘粘性会话或分布式支持储存;可以限制规模

配置 :

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();

使用会话( 任何堆叠) :

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);
});

会话助手 :

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;
}

一个警告故事:当会话状态变成瓶颈时

我曾经在英国政府的一个大型IT项目上工作过, 在这个项目中, 国家滥用(以及其他许多建筑罪)成了一个能杀死性能的瓶颈。 一切一切 用户偏好、多步骤格式数据、搜索结果、临时计算,甚至本应该放在适当缓存或数据库中的缓存查询。

问题: 会话状态被存储在处理中( ASP. NET 会话状态在 Web. config 中, 这是前科日 ) 。 每一项请求都必须解密大型会话对象 。 随着负荷的增加, 会话状态会向每个用户的数十兆字节膨胀。 使用数以千计同时使用的用户, 服务器将失去记忆 。

绝望的解决方案: 我们飞到位于斯图加特的惠普公司设施, 欧洲最强大的Windows机器。 它是一个野兽: 几十个处理器, 数百千兆字节的内存。 其想法是证明如果有足够的硬件, 系统可以满足要求 。

结果: 即使在超顶上,我们也无法击中要求的同步用户目标。 会话状态结构被从根本上破坏了。 垂直缩放无法拯救糟糕的设计。 会话的序列化/降空管理,加上来自大型会话物体的记忆压力,意味着系统根本无法规模化 — — 没有任何合理的成本。

安全恶梦: 比性能问题更糟糕的是,我们发现了一个编码错误,导致届会状态 用户间漏出用户 A 会话数据会偶尔出现在用户 B 的会话中。 这不仅仅是尴尬-这是灾难性的。 NHS 工作人员 我们无意中创建了一个数据保护违反机制,可以将敏感医疗信息暴露于不同的保健专业人员会议之中。

本来应该发生什么:

  1. 默认无国籍:该届会的大部分数据根本不可能存在
  2. 耐久状态数据库:多步骤形式的进展本应在数据库中,并附有工作流程编号
  3. 查找缓存缓存:共享搜索属于 IMemoryCache 或分布缓存
  4. 优先偏好客户方:用户偏好可能出现在 cookie 或本地存储中
  5. 必要时分发会议: 如果真的需要会话, Redis 支持的会话将共享负载

教训: 会话状态不是垂直的,也不是横向的(即使有粘合会话或分布式商店,你仍然在按每个请求进行序列化/分层化 ) 。 超市实验证明,向建筑问题投掷硬件成本昂贵,而且往往徒劳无益。

但更重要的是: 状态错误成为安全弱点在医疗(或银行,或任何受监管的行业)中,这是一种守法恶梦和潜在的刑事责任。

现代咨询:

  • 如果你发现自己需要超过几 KB 会话数据, 你可能会有设计问题
  • 如果你在会话中存储敏感数据, 你就会制造一个安全攻击表面
  • 无国籍建筑不仅规模更大,而且更安全,因为没有服务器端的状态可以泄漏
  • 在你需要飞到斯图加特(或向信息专员解释数据违反情况)之前,重新思考你的州管理战略。

缓存: I memoryCache 和 I 分布缓存

  • 范围: 服务器进程( IMemory缓存) 或分布式( ID分配缓存)
  • 用于:衍生/计算数据、调查、短期状态
  • 权衡取舍:缓存失效、分期分发

记忆缓存 :

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);
});

分布式缓存(如 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");
});

Cache-as-state 反模式警告:如果必须是耐久的或权威的,应将其储存在数据库中,并随意将其储存起来。

I MemoryCache 和 I 分布缓存之间的选择

  • 记忆缓存 :
    • 快速闪烁,在工艺中,物体作为物体停留(无序列化)。
    • 以内存压力、体积限制、绝对/下降到期和优先次序进行驱逐。
    • 未在节点之间共享;在应用回收/部署时清除。
    • 对于每个节点热点、计算外观、短期TTLs来说是件好事。
  • 分布缓存( 重新发布/ SQL/ etc.) :
    • 农场间共享; 活过应用程序重新启动; 需要序列化( 字符串/ 字节) 。
    • 稍高一点的潜伏;输送量取决于网络和后端。
    • 支持绝对/递减到期(依赖提供者; Redis 提供商在访问滑动时更新TTL)。
    • 想要交叉点的一致性, 大型扇式朗读器, 标志旗帜, 和会话。

共同暗藏共同战略

  • 缓存( 最常见的) :

    1. 尝试缓存; 2) 如果漏掉, 从源代码装入; 3) 写入缓存; 4) 返回 。
    • Pros:简单;真相来源仍然是数据库。
    • 同意:过期后第一次请求缓慢;可能盖章。
  • 读取(通过图书馆/提供者读取):缓存处理失事装载。

  • 写入: 写入缓存并同步支持商店 。

  • 书写后: 写入缓存, 冲去不同步存储( 风险: 损失/ 不一致 ) 。

  • 刷新前:在热键过期前刷新热键,以避免冷落。

过期、驱逐和规模化

  • 绝对到期:总是在固定期限之后到期(有利于外部数据更新)。
  • 滑坡到期:在访问时延长TTL(用于会议/用户专用数据)。
  • 基于大小的驱逐( imemoryCache) : 设置条目 。 大小缩放大小和配置为约束内存 。
  • 优先事项(小型缓冲):缓冲项目优先事项。 高/高/热/低/永远不迁移影响迫迁。
  • Jitter: 在 TTL 中添加小的随机偏移, 以避免同步过期( 标注) 。

防止缓冲盖盖章(防雷)

  • 使用 GetOrCreate/ GetOrCreateAsync 来确保每个节点的单行人口。
  • 分发: 使用短寿命锁定键( SET NX EX) 或库支持; 添加 TTL Qitter; 考虑背景更新 。
  • 服务于旧的重验证: 在计算新值时, 保留一个具有淡化值和短延的第二密钥 。

按键设计和命名间隔

  • 优先使用小写、有结号限制的密钥:AP:实体:123或承租人:us:us:us:us:us:42。
  • 包含版本段, 以取消所有无删除的密钥类别 : v2: 产品 : 123 。
  • 租户意识:与租户或组织代号前缀钥匙,以避免碰撞和缓解清洗。
  • 保留按键小但描述性; 避免用户控制的原始输入, 而不实现正常化 。

到期密钥集(标签/组)

当您需要废除许多相关条目时 :

  • 版本的前缀( 软无效) : 在小键中折叠一个全局版本, 并以此组成密钥 。

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

    废除所有产品:递增与产品(客户自然会错过旧的前置密钥)。

  • 每个组的标记集( Redis) : 每个标签保留一组密钥; 无效时, 抓取成员并删除 。

    // 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 无效: 发布“ 无效: key” 消息; 每个节点从本地 IMemoryCache 中删除密钥 。

  • 使用模式扫描:在预测热路中应当避免SCAN/KEYS;小密钥空间的管理工具可以使用。

实践帮助者

  • ImemoryCache 获取或设置选项 :
    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);
      });
    
  • 与 JSON 和过期分配缓存 :
    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;
    }
    

监测和可见度

  • 跟踪出/出故障率和平均装载时间;每个关键组的曝光度量(Prometheus计数器)。
  • IMemoryCache, 加上暗藏地人口周围的伐木和驱逐回召。
  • 对于Redis, 观看关键空间点击/失灵、延缓和记忆破碎; 酌情设定最大范围的政策 。

生产过程中的记忆缓存模式

以下是我如何在我的博客平台上实际使用 IMemoryCache , 经过试验和错误的演化。我会给你们展示三种从简单到复杂的模式。

模式1:简便缓存,用于极难变换的数据(类别)

博客分类不会经常改变, 所以在30分钟内隐藏它们:

// 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;
}

为何如此成功:

  • 每页都读取分类(在导航中显示)
  • 他们很少改变(仅在我添加新博客文章及新分类时)
  • 30分钟TTL罚款;如果出现新的类别,用户在30分钟内看到这些类别
  • 绝对到期仅( 不滑动) , 因为我们不在乎它被访问的次数 。

抓住了,我打中了: 最初我使用了字符串密钥“ 目录 ” 。 直到您有多个控制器, 一个不小心再使用相同的密钥。 现在我使用常数或强型密钥( 见先前关于避免碰撞的部分 ) 。

模式2:有规模限制和滑动到期(翻译任务)的用户状态

它会缓存每个用户的翻译状态。 它更复杂, 因为它需要界限, 只要用户在活动, 它就应该活下来 :

// 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)
        });
    }
}

为何有不同之处:

  • 滑滑过期:如果用户不断检查翻译状态,将缓存保存到最多6小时
  • 大小限制:每个用户仅保留5个最新任务,以防止内存膨胀
  • 手工管理: 我明确限制大小, 因为 MemoryCache 不强制执行入门级别项目计数限制( 只执行总体缓存大小)

我犯了错误: 起初我并没有限制任务的数量。 一个电源用户触发了50+的翻译, 我有一个内存泄漏。 现在我每个用户最多保存5个 。

权衡: 这不会波及数百万用户。 如果这成为一个问题, 我会移到 IdispultedCache (Redis) , 或者存储在数据库中, 在 userId + startTime 上输入索引 。

模式 3: 随可观察性缓存( 计数缓存)

使用 Serilog 追踪缓存效果 :

// 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;
    }
}

为何生产准备就绪:

  • 可观察性: " 侦察追踪 " 活动追踪缓冲袭击/失踪率和记录计量数
  • 复合密钥:包括日期范围和前缀以避免关键碰撞
  • 日期格式化日期: 用途 yyyyMMdd 共享缓存
  • 纸质处理:如果外部服务失败,返回无效;不缓存失败
  • 过滤的缓存: 将过滤的结果, 而不是原始响应凝结

进化 : 最初我缓存了10分钟。但Umami的量度非常重,没有太多变化,所以一个小时是好的。我通过查看SerlogTtravel数据发现这一点,并且看到过量的缓存误差。

监测行动: 在 Seq (我的对数聚合器) 中, 我可以查询 :

ActivityName = "GetMetricsWithPrefix" and CacheHit = false

这说明我的缓存误差率,如果高的话,我调整TTL或关键策略

比较这三种办法

|---------|----------|------------|--------------|---------------| | 类别类别类别类别类别类别 Global, 鲜有变化 30分钟绝对 不需要(小) 基本伐木 | 翻译任务 《手册》(5项最大) *无(应该加上的) * * * * * * * * * 无 * (应该加上的) * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *、 * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * | 计量数 * 昂贵的外部电话 * * 1 h 绝对 * 自然(时间窗口) * * 全面追踪 *

关键经验教训:

  1. 开始简单( 模式 1) , 仅在需要时才添加复杂性
  2. 总是想着缓存存储器的使用 - 增加每个用户缓存的大小限制
  3. 对于费用昂贵的操作,从第一天起加上可观察性
  4. 根据实际数据变化频率调整 TTL, 而非猜测

当我不使用 ImemoryCache:

  • 用户认证状态( 以 auuth cookie 表示)
  • 用于购物车(将使用DB+生产中的分布式缓存)
  • 博客日志内容( 已在 DB 中发布, 按要求装入一次)
  • 跨服务器状态(需要I分布式缓存/再分配)

Cooks和索赔

  • 范围:所有请求直至期满/终止
  • 用于:身份、粗粗角色/授权、少量概况数据
  • 权衡: cookie 大小; 不要过份。 索赔要求应该稳定。

设置 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();

与索赔书签名(MVC/最小数):

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("/");
});

读取索赔( 任何堆叠) :

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

JWT / 贝尔托肯斯

  • 范围:客户携带象征性物;无国籍服务器
  • 用于:SPAs/移动/APIs/APIs、跨领域、微服务
  • 权衡取舍:象征性大小;轮调/重新更新;储存最低报销要求,必要时使用内审
builder.Services.AddAuthentication("Bearer")
   .AddJwtBearer("Bearer", o =>
   {
       o.Authority = "https://demo.identityserver.io"; // example
       o.Audience = "api";
       o.RequireHttpsMetadata = true;
   });

使用 :

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

美人鱼概览:

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

国家:数据库和朋友

  • 范围:永远(直到删除为止)
  • 用于:任何不得丢失的东西:手推车、订单、简介、长期工作流程
  • 模式:标准CRUD,核心为EF;CQRS;事件外包;可靠性发箱模式

EF 核心草图:

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);
});

应对凝结、ETags和有条件请求

  • 不是严格意义上的“国家承载 ” , 而是通过让客户/代理人重新使用先前的回复来减少重复工作。 通常还配有查询/路径状态。
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();

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");
});

回应缓存对产出缓存:生产中使用真实世界

我在我的博客上同时使用应答缓存和输出缓存,

混淆:两个缓存属性?

ASP.NET核心 类似外观的缓存系统:

  1. 反应缓存( HTTP 缓存):设置 HTTP 信头(Cache-Control, Vary告诉浏览器和 CDN 如何缓存
    1. e
  2. 输出缓存( 服务器边):将服务器的输出压缩到不执行完全执行动作的位置

它们相互补充。 反应缓存处理客户端/ CDN 缓存; 产出缓存处理服务器端缓存。

我的博客文章行动(两个缓存都应用)

// 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);
}

当有人提出要求时会发生什么情况 /blog/my-post:

  1. 先检查产出缓存:我是否有一个缓冲反应 my-post + en 语言?

    • 命中:返回缓存的 HTML, 操作方法从不运行( fast! ~ 1ms)
    • 中 小姐:执行3600秒(1小时)的操作、显示视图、缓存结果
  2. 反应缓存设置信头:在输出缓存生成响应后,反应缓存补充 :

    Cache-Control: public, max-age=300
    Vary: hx-request
    
  3. 浏览器缓存:浏览器将回复缓存300秒(5分钟)。同一用户随后提出的请求甚至不会击中服务器。

  4. CDN 缓存 CDN 缓存5分钟。全球用户点击 CDN, 而不是我的服务器 。

为什么期限不同? (300比3600)

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

理由:

  • 服务器缓存时间长(1小时): 我控制着服务器; 如果我更新一个邮政, 我可以清除缓存
  • 客户的缓存时间较短(5分钟): 我不能轻易清除用户浏览器或 CDN ; 5分钟是一个合理的淡化窗口
  • 取舍:如果我编辑一篇文章,将出现新的内容:
    • 服务器侧边: 立即( 我可以撤销缓存)
    • CDN/浏览器:5分钟内(或我手动清除CDN)

进化 : 起初我两个都用了5分钟,但这意味着我的服务器每5分钟就重发一次,即使内容很少改变。现在:

  • 服务器可使用缓存 HTML 1小时
  • 客户每5分钟获得新鲜内容

HTMX 的 VaryBy 列头

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

为什么这很重要: HTMX要求包括 hx-request: true 标题。 我返回不同的回复 :

  • 全额请求全额请求: 带有布局的 HTML 页面完整
  • HTMX 请求: 部分视图, 没有布局

不无 VaryBy,缓存会返回错误的格式。 VaryBy,我隐藏了每页的两种版本。

示例:

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

用于语言和页码的 VaryBy QueryKeys

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

没有此选项的问题 : /blog/my-post?language=fr 提供英文缓存版本。

使用 VaryBy 查询密钥 : 独立的缓存条目 :

  • /blog/my-post?language=en 缓存密钥包括“ en ”
  • /blog/my-post?language=fr 缓存密钥包括“ fr ”

我的博客列表中的真实用法 :

[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, /* ... */)

缓存爆炸警告 : 每个独特的参数组合 = 单独的缓存条目 :

  • page=1&pageSize=20&language=en 一个条目
  • page=2&pageSize=20&language=en 另一个条目
  • page=1&pageSize=10&language=en 另一个条目

缓解:

  • 合理最大缓存大小( OutputCache 自动评估最近使用最少)
  • 已缓存的通用参数( 第1页, 默认页数Size)
  • 非常见组合可能会错过缓存(可接受)

需要设置

反应缓存 盒子外的工程,但 产出缓存 您需要设置 :

// 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

当输出缓存没有帮助时

输出缓存被跳过, 原因是 :

  • 经认证的请求(不同用户看到不同数据)
  • POST/PUT/DELETE(只有 GET/HEAD 缓存)
  • 答复者的答复 Set-Cookie 标题标题标题
  • 明确规定的 Cache-Control: no-store

我不使用它的例子:

// 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
}

衡量成效

我使用普罗米修斯度量(通过我的应用程序暴露)追踪:

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

我的缓存速率:~98% 博客文章(大多数交通流量多次点击相同的流行文章),

影响:

  • 无缓存: ~ 50ms 平均响应时间( DB 查询 + 标记创建)
  • 输出缓存: ~ 1-2ms 用于缓存响应
  • 25x 加速速度

当我暂时禁用缓存时

有时候我调试了 每次都需要新的反应

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

或者使用特定环境设置:

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

更好的办法: 使用配置 :

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

何 地 responseCacheDuration 0 正在开发,300 正在生产。

这种双轨制战略的利弊

专业:

  • 服务器效率: 输出缓存将 CPU/DB 负载减少98%
  • 客户/CDN福利回应缓冲会减少我的带宽,
  • 灵活性: 服务器与客户端的不同 TTL
  • 节省成本节约: DB 查询次数减少,带宽减少

关节 :

  • 静态:内容更新需要5分钟时间向客户传播。
  • 缓存无效复杂性:更新邮件要求服务器和 CDN 缓存同时失效
  • 内存使用: 输出缓存持有服务器内存中的 HTML
  • 调试混淆:有时忘记缓存, 并想知道为什么变化不出现

当我跳过焦头烂额时:

  • 实时数据(股票价格、现场运动)
  • 个人化内容(针对用户的建议)
  • 低流量网页(正在累积的间接费用 > 福利)
  • 经常改变的页面

我的裁决是: 对于一个大部分是静态内容和高读/写比率的博客来说,双叠加是一个巨大的胜利。 我不会在数据迅速变化的行政面板或仪表板上使用它。


ViewBag、ViewData和TempData:从主计长到查看国(以及为什么我大多避免其中两个)

这三个问题经常被混淆起来。这里是它们的不同之处, 以及我在生产中实际使用的东西。

三个朋友比较

// 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");
--------- ---------- --------- ----------
使用寿命 * 现请求 * * 现请求 * 改头换面 *
密钥访问 字符串键 * 属性语法 * 字符串键 * * 字符串键 *
类型安全 * 无(需要播报) * 无(动态) * 无(需要播报) * * 无(需要播报) *
汇编时间检查 不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不。
幸存者调整 不,不,不,不,是,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不,不。

我实际使用的是: Global布局数据 ViewBag

将数据从控制器传送到共享布局(分析、分类等):

// 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);
}

然后在我的布局中,_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>
}

为何此模式有效 :

  • 全球全球:每页都需要分类 nav 和 分析
  • 计算一次: 基地主计长在每次行动之前运行
  • HTMX优化: 跳过部分请求的分析脚本( HTMX 不需要重新输入)
  • 结结结结结结结结结结结:分类被缓存(见上文我的 IMemoryCache 模式) , 所以不要按 DB 键

我之前犯的错误是: 我当时在设置 ViewBag.Categories 每一种行动方法,都有一个方法。 OnActionExecutionAsync 在基本控制器中解决了这个问题。

页面特定数据的 ViewBag(可接受模式)

// 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);
}

鉴于:

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

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

这没关系,因为:

  • 简单标量值( 字符串、 英特)
  • 仅在视图中使用,不传过
  • 将添加备选案文 TitleCategory 每个视图模型的属性

我所要避免的是:在 ViewBag 中复杂的物体

反模式:

// 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 };

问题:

  • 无编译时间安全( 音频) ViewBag.Usr 运行时失败)
  • 难以跟踪现有数据
  • 使测试更难( 需要检查 ViewBag 字典)
  • 诺特利感

更好的:强型视图模型:

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

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

我不使用它(这是为什么)

标准TempData 使用大小写 :

[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();
}

为什么不在我的博客使用TempData:

  1. 我用HTMX代替改方向:我的表格通过 HTMX 提交部分观点,并用内嵌成功/错误信息返回部分观点。不重定向 = 不需要 TempData 。
[HttpPost]
public async Task<IActionResult> Submit(ContactViewModel model)
{
    if (!ModelState.IsValid)
        return PartialView("_ContactForm", model); // Show errors inline

    await sender.SendEmailAsync(contactModel);

    // Return success view directly (no redirect)
    return PartialView("_Response", new ContactViewModel
    {
        Email = model.Email,
        Name = model.Name,
        Comment = "Message sent!"
    });
}
  1. 用于传统PRG(后再直接获得),我会使用TempData(TempData),但我更希望尽可能避免调整方向,以便改进UX。

当TempData有意义时:

  • POST后有整页重定向的传统MVC应用软件
  • 多步向导, 在两步之间重新定向
  • 认证重定向后的闪电消息

坦普达塔(TempData)得到: 默认情况下由 cookie 支持( 自 ASP. NET Core 2.0 ) 。 如果您在 TempData 中放置大对象, 您将会随所有请求发送 cookie 。 对于大状态, 请使用会话, 使用支持存储或 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]

我的规则:

  1. 仅用于全局布局内容的 ViewBag (分析、导航、面包屑)
  2. 用于简单页面标题/标题的 ViewBag (可选项; 可使用视图模式)
  3. 复杂对象从不查看Bag (使用查看模式)
  4. 从未查看数据 (ViewBag有更好的语法)
  5. 只有当您需要PRG时才会使用TempData (我不知道,多亏了HTMX)

模式:向导和多步流程

哪个州可以使用?

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
  • 小型、单届会议:会话或TempData 介于两步之间。
  • 交叉设置/长期运行:坚持到 DB, 在 URL 中带有密钥 。

示例 (DB+路由键):

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");
});

樣式: 带有 TemtData 的閃光訊息

  • 设置在 POST 中; 在重定向后读取一次 。
TempData["Flash"] = "Profile saved";
return RedirectToAction("Index");

Razor 视图 :

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

模式:购物墨盒

  • 小马车:会话(如果您的规模不大,且您有粘附/分配会话) 。
  • 大马车/多设备:在 cookie 或 URL 中, DB + Card id。 速度缓存 。
flowchart LR
  U[User] -- cart-id cookie --> S[Server]
  S --> DB[(Cart Table)]
  S <--> Cache[Distributed Cache]

安全、隐私和遵守检查清单

  • 验证所有客户提供的国家:查询、信头、表格、饼干、JWT索赔。
  • 保护敏感的客户端存储状态: 使用数据保护来保护您的 cookie; 永远不要在查询字符串中存储机密 。
  • 设定 cookie 标记 : Secure, HttpOnly, SameSite, IsEssential (如果同意/职能需要需要)。
  • 恢复有关特权更改的认证 cookie; 将索赔要求保持在最低水平 。
  • 必要时在服务器端仓库休息时加密; 确保密钥旋转( 数据保护密钥、 JWT 签名密钥) 。
  • GDPR/CCPA:提供用户数据输出/删除路径;尽量减少保留。

决定矩阵(热表)

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
  }

快速选择 :

  • 需要单向闪光吗?
  • 在一次会话中对多个请求需要向导吗? 会话(如果是长期/多功能的话,会话(或 DB + 键) 。)
  • 需要伸缩和无国籍的动保单? 用于身份的JWT,用于国家的DB/分发。
  • 只需通过管道内的数据吗? HttpContext.项目。
  • 需要计算查询的缓存吗 ? 本地的 IMemoryCache ; 跨农场的分布缓存 。

MVC 诉 Razor Pages 诉 Minimal APIs: 同一基础, 不同形状

所有三个堆叠都位于相同的原始体上( HttpContext、 模型绑定、 auth、 数据保护 ) 。 上面的例子显示, API 在人类工程学上差异很大:

  • 最小 APIs: 从路由/询问/身体/索赔中绑定的参数; 返回 Results.*.
  • MVC: 属性、过滤器、对行动参数/观察模型具有约束力的模型。
  • Razor Pages:有捆绑特性的页面处理器和生成链接/窗体的标签辅助器。

他们都拥有在这里讨论过的相同的州机制。


码头和反码头

  • 在 cookie 或 TempData 中存储大型或敏感数据 。
  • 取决于记忆中的缓存是否正确(这是缓存,而非真理)。
  • 未经服务器检查,根据客户持有的路线/查询标志授权建筑。
  • 超负荷的经认证饼干 或JWTs 与波动性索赔。
  • 会话没有分布战略( 本地工作, 比例间断) 。

现实世界国家管理:我实际使用的东西(不要使用)

在展示了所有这些选择之后, 以下是我对我的博客平台的作品的诚实评估。

我的州管理堆(按频率顺序排列)

使用案件 满足 满足 满足 满足 满足 |-----------|-----------|-----------|--------------| | 记忆缓存 高度的类别、标准、翻译任务 | 产出缓存 高调的博客文章, 列出 | 反应缓存 高端 HTTP 缓冲信头 与 Outtacache 合作 | 视图包 中度分析设置, 页面标题 * OK 对于简单的东西 * * * OK for simple stuff * | 授权请求书 中度 用户身份 行政旗帜 正确工具 | 路线/查询 中度 | 数据库数据库数据库数据库 中度部落格文章、评论、状态、真相来源 | 曲曲曲曲曲曲曲曲曲曲曲曲曲 低用户偏好(未来) 尚未需要 | 届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议届会议 绝不 - - - - - - - -没有分销店的布置 | 临时数据 永不 - 'HTMX消除需求' | Httpcontext. 项目 永远不要 - - 还没有使用 案例 | 分布缓存 永远不要 - - \ \ \ \ \ \ \ \ \ \ 单个服务器 (目前) \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \

源自生产经验的详细意见/意见

I memoryCache(记忆缓冲)

我用它来做什么:

  • 类别列表(全球,30分钟TTL)
  • 逐用户翻译任务跟踪(6小时绝对+1小时滑动)
  • 外国对外国投资价格指数的外部答复(Umami指标,1h TTL)

实践证明:

  • 防爆快(在工艺中)
  • 无连序间接费用
  • 将DB/API负载减少~95%
  • 易于执行和理解
  • 通过 " SerilogTtratra trace " 观测

我击中了控制杆:

  • 如果您不限制每个用户的缓存, 内存会泄漏
  • 应用程序重新启动时清除( 允许我使用选项)
  • 未在服务器上共享( 仅举一个可以)
  • 缓存无效是手动( 需要明确删除 () )

缩放限制 : 如果我能找到多个服务器, 我需要 I dispreditedCache (Redi) 共享状态 。 目前, 单个服务器 + 内存缓存是完美的 。

产出缓存 + 反应缓存

我用它来做什么:

  • 博客文章(1小时服务器、5分钟客户/CDN)
  • 博客列表/分类页面
  • 日历数据

实践证明:

  • 25x 加速速度 (单位:50米
  • 以高交通流量为尺度,不破汗流
  • 服务器与客户端的分隔 TTL
  • 使用 HTMX (VaryBy Header) 无缝工作
  • 通过Prometheus指标衡量的可衡量影响

我击中了控制杆:

  • 调试混淆( 忘记缓存)
  • 含有许多查询参数组合的缓存爆炸
  • 静态窗口(客户端5分钟)
  • 内容更新需要手动失效

最佳用于: 大多为静态内容的重读应用程序。 不适合个人化或实时数据 。

视图Bag {

我用它来做什么:

  • 全球布局数据(分析、类别)
  • 标 标 标 标 标 标题

实践证明:

  • 简单且快速的布局级别数据
  • 基地总经理,有一次在基地总经理,无处不在
  • 使用缓存分类列表精细工作

我击中了控制杆:

  • 没有类型安全性( 运行时typoss 失败)
  • 诱惑过度使用复杂数据
  • 难以测试

规则一如下: 仅用于简单标量的 ViewBag 。 复杂的对象在 ViewModels 中进行 。

(a) 被裁定的索赔

我用它来做什么:

  • 用户名、 用户名、 电子邮件、 Avatar URL 用户名、 用户名、 电子邮件、 Avatar URL
  • 管理员标志( 检查) sub (a) 针对 config的索赔)

实践证明:

  • 安全( 签名 cookie, 数据保护)
  • ASP.net 核心身份/身份自动
  • 可通过 User.Claims 各地
  • 滑滑到期日让用户登录

我击中了控制杆:

  • Cookie 尺寸限制( 不要超额索赔)
  • 索赔在重新入住之前是静止的索赔
  • 如果我加上一个索赔要求(如“IsEditor”),需要重新发行经认证的饼干

最佳做法: 索赔要求保持最低程度和稳定。不要在索赔中提出经常变化的数据。

我不使用的东西(和为什么)

会话( 从未使用过 ) :

  • 需要分配储存(再储存)
  • 为最低效益增加复杂性
  • 我使用的案例更适合使用:
    • 被证明要求(身份)
    • ImemoryCache(短期状态)
    • 数据库(可使用状态)

TempData( 从未使用过) :

  • HTMX已消除
  • 窗体返回含有内嵌信件的部分视图
  • 无需维持改道

HttpContext. 项目( 从未使用过 ) :

  • 还没有每个请求状态的有用案例
  • 我的中间软件不计算控制器的值
  • 如果我需要发现房客,我会用它

Idisp分配缓存( 从未使用过) :

  • 单一服务器部署
  • I MemoryCache 满足所有需要
  • 如果我缩到多个服务器, 将会使用 Redis

我的方法的演变

第1阶段(初始): 完全没有堵塞,每个请求都击中数据库 并设定了马克唐,因为交通流量低所以罚款。

第2阶段(第一次优化): 添加了分类的 I MemoryCache 。 立即看到 DB 减载。 简单说来: 30 分钟 TTL, 没有花哨逻辑 。

第3阶段(扩大规模): 交通激增时添加了博客文章的 输出缓存 。 大规模性能改进 。 初始错误 : 只缓存5 分钟 。 监控显示内容后增加到 1 小时 很少变化 。

第4阶段(可观察性): 将 Serilog 跟踪添加到度量缓存中 。 由于密钥格式化日期, 发现缓存缺失率很高 。 固定密钥格式 yyyyMMdd 点击率从60%上升到95%。

第5阶段(HTMX一体化): 已添加 VaryByHeader 用于2004年和 hx-requestHTMX 请求时, 最初忘记了这个内容, 并提供了完整的页面。 直到我发现为止, 调试恶梦 。

当前状态 : 满意的堆叠 。 I memoryCache + extraCache + reactionCache 处理98%的我的国家管理需求。 数据库用于耐久状态。 验证身份。 仅此而已 。

您应用程序的咨询咨询

从这里开始 :

  1. 用于导航/过滤的路线/查询地标(总是首先无国籍)
  2. 身份证明索赔
  3. 任何必须持久的数据库
  4. 用于读重计算数据的记忆缓存
  5. 昂贵到发送页面的输出缓存

必要时添加: 6. 会话( 只有当您必须拥有服务器- 端对话状态时) 7. 分配缓存(仅在您向多个服务器缩放时) 8. 饼干(用于客户方偏好、同意)

避免:

  • ViewBag/ TempData 中的复杂对象
  • 没有分布式支持商店的会话
  • 缓存用户专用或经常交换的数据
  • 过早优化( 先量度! )

关键经验教训:

  • 开始简单, 仅在测量显示需要时才添加复杂性
  • 可观察性是关键 (知道您的缓存点击率 ! )
  • 尽早考虑规模限制(每个用户的缓存需要界限)
  • 测试缓存完全失效( 粘贴是一个实际问题)
  • 记录您的 TTL 选项( 未来您会问“ 为何30分钟? ” ) 。

换环加上

状态在网络应用程序中并非一刀切。 选择满足您需要的最轻的选择, 选择可以选择的无国籍模式, 并明确安全性和生命周期。

如果你想深入了解这些碎片如何通过管道 看我的系列从 第1部分:概述和基础 特别是中间器械和路由部件。

快乐的建筑。


附录:更多可复制简略示例(改进)

这些例子加深了前几节内容,可以粘贴到净9最低模板、MVC或Razor Pages。

Cookies: 带有数据保护的保护价值

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();
});

提示 : 在多“ 节点” 部署中, 持续的数据保护密钥( 如共享文件系统、 Redis 或 Azure 密钥 Vault ) , 这样 cookies 就可以在多个实例中读取 。

MVC、Razor Pages和最小API中的抗伪药

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

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

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

app.MapControllers();
app.MapRazorPages();
  • MVC: 装饰行动 [ValidateAntiForgeryToken] 以及使用 @Html.AntiForgeryToken() 窗体中。
  • Razor Pages: 默认情况下在窗体柱上启用; 使用; asp-antiforgery="true" 必要时。
  • 最小度:通过 IAntiforgery 显示。

带 Rediis 和滑动与绝对到期的会话

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();

仅存储小的、可压缩的数据。 固态真实的手推车/ 订单到 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
    }));
});

I 分布式缓存获得“ 或设置” , 以静态避免印记

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);
});

使用StackExchange. Redis(SET NX EX),

在当地发布和验证 JWT( 演示)

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");

使用 ETags 有条件更新( 如果匹配)

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

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

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

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

TempData:通过JSON的复杂物体

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

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

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

EF 核心货币代汇

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();
});

这应该弥补差距:更强大的安全违约、多节准备状态以及缓存、象征性物品和有条件请求的现实世界模式。


深海底: HttpContext. 项目(实用模式和助手)

HtpContext 。 项目是每个请求的包( IDctionary <object, object? > ) , 仅存于一个请求的一生。 它完美地将计算值从中器/过滤器传递到您的端点、 控制器和 Razor Page 处理器, 而不触及全球状态或长寿命仓库 。

  • 生命周期:在请求启动时创建;在回复完成时丢弃。
  • 范围:目前的请求仅指目前的请求——绝不交叉重定向或背景工作。
  • 性能:O(1)调查;每个请求的理想缓存。
  • 安全:仅服务器侧;客户无法看到。

为什么项目 而不是

  • 会议/临时数据:这些交叉请求并介绍分配问题,项目时间短且规模小。
  • DI 范围服务: 用于行为和共有依赖关系。 物品对于临时、 计算值( 租户、 用户位置、 特征标记) 和每个请求的缓存来说更好 。
  • HtpContext. 特性: 框架/ 运输级特性( 外部点特性, IHttpUpPAppriage Fature) 。 项目用于应用程序级数据 。

避免关键碰撞:强型键

因为项目使用对象键, 更喜欢私有静态对象键或专用密钥类型, 以避免名称碰撞 。

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

或者创建带有扩展名的打印包装纸 :

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) });

模式:为避免重复工作而请求的缓存

使用物品作为小缓存, 重复阅读相同请求中的内容, 不要重新点燃数据库/服务 。

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);
});

注:

  • 线索 : 单个请求通常在一个逻辑路径上执行; 项目对平行写作不使用线索安全。 如果您开始共享项目的平行任务, 请添加您自己的同步 。
  • 大小: 保持小值和低价值, 以计算/ 序列。 这是每个请求的模拟值 。

模式: 填充项目的过滤器( MVC/ Razor Pages)

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>());

模式: 各地没有拨款的丰富原木

计算一次,然后在记录范围或中间软件中读取。

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);
    }
});

何时不使用项目

  • 重新定向后或提出要求后所需的数据(使用TempData/Session/DB代替)。
  • 通用单吨或交叉请求缓存( 使用 I MemoryCache/ IDspreittedCache) 。
  • 属于身份/授权(使用索赔/政策)的价值。

快速规则:如果在请求期间计算并在请求中按您自己的代码阅读,项目是理想的。

logo

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