ASP.NET Core 中的输出缓存中间件

本文介绍如何在 ASP.NET Core 应用中配置输出缓存中间件。关于输出缓存的概念,见输出缓存概述。

输出缓存中间件可用于 Minimal API、带控制器的 Web API、MVC 和 Razor Pages。下面提供 Minimal API 与控制器 API 的示例。控制器示例用特性配置缓存,这些特性也能用于 MVC 和 Razor Pages。

示例引用一个 Gravatar 类,它生成图像并显示生成日期与时间,方便观察是否使用了缓存响应。该类只定义和用于示例应用,并非框架自带 API。更多信息见下载示例和预处理器指令。

将中间件添加到应用

调用 AddOutputCache,把输出缓存服务加入服务集合:

builder.Services.AddOutputCache();

调用 UseOutputCache,把中间件加入请求处理管道:

var app = builder.Build();

// Configure the HTTP request pipeline.
app.UseHttpsRedirection();
app.UseAuthorization();
app.UseOutputCache();

这两个调用使缓存能力可用,但不会自动开始缓存响应。还需要按下文配置缓存策略或端点。

  • 使用 CORS 中间件时,UseOutputCache 必须在 UseCors 之后调用。
  • Razor Pages 与控制器应用中,UseOutputCache 必须在 UseRouting 之后调用。
  • 使用身份验证或授权时,UseOutputCache 必须在 UseAuthentication 和 UseAuthorization 之后调用。否则可能向未授权用户提供缓存内容。此顺序按官方英文和文档仓库核对,修正了中文页面的倒译。

为一个端点或页面配置缓存

Minimal API 可以调用 CacheOutput,也可以使用 [OutputCache] 特性:

app.MapGet("/cached", Gravatar.WriteGravatar).CacheOutput();
app.MapGet("/attribute", [OutputCache] (context) =>
    Gravatar.WriteGravatar(context));

控制器应用可以在操作方法上应用 [OutputCache]。下面示例把特性放在控制器类上:

[ApiController]
[Route("/[controller]")]
[OutputCache]
public class CachedController : ControllerBase
{
    public async Task GetAsync()
    {
        await Gravatar.WriteGravatar(HttpContext);
    }
}

Razor Pages 应用把特性应用到 Razor 页面类。

为多个端点或页面配置缓存

调用 AddOutputCache 时创建策略,为多个端点指定缓存配置。可以给具体端点选择命名策略,也可以用基本策略为一组端点提供默认配置。

下面的基本策略把所有端点的过期时间设为 10 秒。未指定过期时间时,默认值为 1 分钟:

builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(builder =>
        builder.Expire(TimeSpan.FromSeconds(10)));
    options.AddPolicy("Expire20", builder =>
        builder.Expire(TimeSpan.FromSeconds(20)));
    options.AddPolicy("Expire30", builder =>
        builder.Expire(TimeSpan.FromSeconds(30)));
});

同一段配置还创建了两个命名策略,分别使用 20 秒和 30 秒的过期时间:

builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(builder =>
        builder.Expire(TimeSpan.FromSeconds(10)));
    options.AddPolicy("Expire20", builder =>
        builder.Expire(TimeSpan.FromSeconds(20)));
    options.AddPolicy("Expire30", builder =>
        builder.Expire(TimeSpan.FromSeconds(30)));
});

调用 CacheOutput 或使用 [OutputCache] 时,可以选择端点策略。以下 Minimal API 端点分别选择 20 秒与 30 秒:

app.MapGet("/20", Gravatar.WriteGravatar).CacheOutput("Expire20");
app.MapGet("/30", [OutputCache(PolicyName = "Expire30")] (context) =>
    Gravatar.WriteGravatar(context));

控制器应用使用特性的 PolicyName 选择策略:

[ApiController]
[Route("/[controller]")]
[OutputCache(PolicyName = "Expire20")]
public class Expire20Controller : ControllerBase
{
    public async Task GetAsync()
    {
        await Gravatar.WriteGravatar(HttpContext);
    }
}

Razor Pages 应用把特性应用到页面类。

使用默认输出缓存策略

默认策略遵循以下规则:

  • 只缓存 HTTP 200 响应。
  • 只缓存 HTTP GET 或 HEAD 请求。
  • 不缓存设置 Cookie 的响应。
  • 不缓存已经过身份验证的请求所产生的响应。

下面把全部默认缓存规则应用到所有端点:

builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(builder => builder.Cache());
});

覆盖默认策略

下面的自定义策略允许缓存 HTTP POST 请求以及 HTTP 301 响应。它仍检查认证信息与 Cookie,并启用查询字符串变化键和资源锁:

using Microsoft.AspNetCore.OutputCaching;
using Microsoft.Extensions.Primitives;

namespace OCMinimal;

public sealed class MyCustomPolicy : IOutputCachePolicy
{
    public static readonly MyCustomPolicy Instance = new();

    private MyCustomPolicy()
    {
    }
    ValueTask IOutputCachePolicy.CacheRequestAsync(
        OutputCacheContext context,
        CancellationToken cancellationToken)
    {
        var attemptOutputCaching = AttemptOutputCaching(context);
        context.EnableOutputCaching = true;
        context.AllowCacheLookup = attemptOutputCaching;
        context.AllowCacheStorage = attemptOutputCaching;
        context.AllowLocking = true;

        // Vary by any query by default
        context.CacheVaryByRules.QueryKeys = "*";

        return ValueTask.CompletedTask;
    }

    ValueTask IOutputCachePolicy.ServeFromCacheAsync
        (OutputCacheContext context, CancellationToken cancellationToken)
    {
        return ValueTask.CompletedTask;
    }

    ValueTask IOutputCachePolicy.ServeResponseAsync
        (OutputCacheContext context, CancellationToken cancellationToken)
    {
        var response = context.HttpContext.Response;

        // Verify existence of cookie headers
        if (!StringValues.IsNullOrEmpty(response.Headers.SetCookie))
        {
            context.AllowCacheStorage = false;
            return ValueTask.CompletedTask;
        }

        // Check response code
        if (response.StatusCode != StatusCodes.Status200OK &&
            response.StatusCode != StatusCodes.Status301MovedPermanently)
        {
            context.AllowCacheStorage = false;
            return ValueTask.CompletedTask;
        }

        return ValueTask.CompletedTask;
    }

    private static bool AttemptOutputCaching(OutputCacheContext context)
    {
        // Check if the current request fulfills the requirements
        // to be cached
        var request = context.HttpContext.Request;

        // Verify the method
        if (!HttpMethods.IsGet(request.Method) &&
            !HttpMethods.IsHead(request.Method) &&
            !HttpMethods.IsPost(request.Method))
        {
            return false;
        }

        // Verify existence of authorization headers
        if (!StringValues.IsNullOrEmpty(request.Headers.Authorization) ||
            request.HttpContext.User?.Identity?.IsAuthenticated == true)
        {
            return false;
        }

        return true;
    }
}

使用该自定义策略时,先创建命名策略:

builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("CachePost", MyCustomPolicy.Instance);
});

然后为端点选择该策略。Minimal API 示例:

app.MapPost("/cachedpost", Gravatar.WriteGravatar)
    .CacheOutput("CachePost");

控制器示例:

[ApiController]
[Route("/[controller]")]
[OutputCache(PolicyName = "CachePost")]
public class PostController : ControllerBase
{
    public async Task GetAsync()
    {
        await Gravatar.WriteGravatar(HttpContext);
    }
}

使用另一种默认策略覆盖方式

也可以通过依赖注入(DI)初始化策略实例。此时把自定义策略类的构造函数从私有改为公开,并删除 Instance 属性。下面仅展示变化后的类开头,类的其余部分与前面的实现相同:

public sealed class MyCustomPolicy2 : IOutputCachePolicy
{

    public MyCustomPolicy2()
    {
    }

按下面方式加入自定义策略:

builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("CachePost", builder =>
        builder.AddPolicy<MyCustomPolicy2>(), true);
});

DI 会创建该策略类的实例,构造函数中的公开参数会被解析。

将自定义策略作为基本策略时,不要在该基本策略应覆盖的端点上再调用不带参数的 CacheOutput(),也不要再应用 [OutputCache];它们会给端点加入默认策略。原文这一段写作 OutputCache(),这里按官方端点扩展 API和上下文示例澄清方法名称,代码保持原样。

指定缓存键

默认情况下,URL 的每个部分都包含在缓存键中,包括 scheme、host、port、path 与 query string。但有时需要显式控制缓存键。例如某端点只按 culture 查询参数返回不同响应,那么其他查询参数的变化不应创建不同缓存项。

可以在策略中指定这样的规则:

builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(builder => builder
        .With(c => c.HttpContext.Request.Path.StartsWithSegments("/blog"))
        .Tag("tag-blog"));
    options.AddBasePolicy(builder => builder.Tag("tag-all"));
    options.AddPolicy("Query", builder => builder.SetVaryByQuery("culture"));
    options.AddPolicy("NoCache", builder => builder.NoCache());
    options.AddPolicy("NoLock", builder => builder.SetLocking(false));
});

再为端点选择查询参数变化策略。Minimal API 示例:

app.MapGet("/query", Gravatar.WriteGravatar).CacheOutput("Query");

控制器示例:

[ApiController]
[Route("/[controller]")]
[OutputCache(PolicyName = "Query")]
public class QueryController : ControllerBase
{
    public async Task GetAsync()
    {
        await Gravatar.WriteGravatar(HttpContext);
    }
}

控制缓存键的一些选项:

  • SetVaryByQuery:指定加入缓存键的一个或多个查询参数名。
  • SetVaryByHeader:指定加入缓存键的一个或多个 HTTP 请求头。
  • VaryByValue:提供一个加入缓存键的值。下面按当前服务器时间秒数的奇偶性生成变化键;只有秒数从奇变偶或从偶变奇时,才生成新的响应:
builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(builder => builder
        .With(c => c.HttpContext.Request.Path.StartsWithSegments("/blog"))
        .Tag("tag-blog"));
    options.AddBasePolicy(builder => builder.Tag("tag-all"));
    options.AddPolicy("Query", builder => builder.SetVaryByQuery("culture"));
    options.AddPolicy("NoCache", builder => builder.NoCache());
    options.AddPolicy("NoLock", builder => builder.SetLocking(false));
    options.AddPolicy("VaryByValue", builder =>
        builder.VaryByValue((context) =>
            new KeyValuePair<string, string>(
            "time", (DateTime.Now.Second % 2)
                .ToString(CultureInfo.InvariantCulture))));
});

可以用 OutputCacheOptions.UseCaseSensitivePaths 指定缓存键的路径部分区分大小写;默认不区分。更多选项见 OutputCachePolicyBuilder。

启用缓存重新验证

重新验证允许服务器返回 304 Not Modified,代替完整响应体。这个状态告诉客户端,响应内容与其此前收到的版本相同。

下面用 ETag 响应头启用重新验证。客户端发送 If-None-Match,值与先前响应的 ETag 相同,并且缓存项仍有效时,服务器会返回 304 Not Modified。Minimal API 示例:

app.MapGet("/etag", async (context) =>
{
    var etag = $"\"{Guid.NewGuid():n}\"";
    context.Response.Headers.ETag = etag;
    await Gravatar.WriteGravatar(context);

}).CacheOutput();

控制器 API 示例:

[ApiController]
[Route("/[controller]")]
[OutputCache]
public class EtagController : ControllerBase
{
    public async Task GetAsync()
    {
        var etag = $"\"{Guid.NewGuid():n}\"";
        HttpContext.Response.Headers.ETag = etag;
        await Gravatar.WriteGravatar(HttpContext);
    }
}

另一种方式是比较缓存项创建时间与客户端请求的日期。请求包含 If-Modified-Since 时,如果缓存项比该日期更旧且没有过期,输出缓存会返回 304。

启用输出缓存后,中间件会自动响应这些客户端请求头;除启用缓存外,无需另外配置服务器来开启这项重新验证行为。

使用标签驱逐缓存项

标签可以标识一组端点,并一次驱逐整组缓存项。下面创建两个 URL 以 blog 开头的 Minimal API 端点,并赋予 tag-blog 标签:

app.MapGet("/blog", Gravatar.WriteGravatar)
    .CacheOutput(builder => builder.Tag("tag-blog"));
app.MapGet("/blog/post/{id}", Gravatar.WriteGravatar)
    .CacheOutput(builder => builder.Tag("tag-blog"));

控制器 API 的标签配置:

[ApiController]
[Route("/[controller]")]
[OutputCache(Tags = new[] { "tag-blog", "tag-all" })]
public class TagEndpointController : ControllerBase
{
    public async Task GetAsync()
    {
        await Gravatar.WriteGravatar(HttpContext);
    }
}

也可以通过适用于 blog 路由端点的基本策略设置标签:

builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(builder => builder
        .With(c => c.HttpContext.Request.Path.StartsWithSegments("/blog"))
        .Tag("tag-blog"));
    options.AddBasePolicy(builder => builder.Tag("tag-all"));
    options.AddPolicy("Query", builder => builder.SetVaryByQuery("culture"));
    options.AddPolicy("NoCache", builder => builder.NoCache());
    options.AddPolicy("NoLock", builder => builder.SetLocking(false));
});

Minimal API 还可以通过 MapGroup 设置:

var blog = app.MapGroup("blog")
    .CacheOutput(builder => builder.Tag("tag-blog"));
blog.MapGet("/", Gravatar.WriteGravatar);
blog.MapGet("/post/{id}", Gravatar.WriteGravatar);

上面的标签示例让两个端点都带有 tag-blog。下面通过引用该标签,一次驱逐这些端点的缓存项:

app.MapPost("/purge/{tag}", async (IOutputCacheStore cache, string tag) =>
{
    await cache.EvictByTagAsync(tag, default);
});

向 https://localhost:<port>/purge/tag-blog 发送 HTTP POST 请求,会驱逐对应缓存。

如果需要驱逐所有端点的全部缓存项,可以给所有端点加上基本标签策略:

builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(builder => builder
        .With(c => c.HttpContext.Request.Path.StartsWithSegments("/blog"))
        .Tag("tag-blog"));
    options.AddBasePolicy(builder => builder.Tag("tag-all"));
    options.AddPolicy("Query", builder => builder.SetVaryByQuery("culture"));
    options.AddPolicy("NoCache", builder => builder.NoCache());
    options.AddPolicy("NoLock", builder => builder.SetLocking(false));
});

这样就能用 tag-all 驱逐全部缓存项。

关闭资源锁

默认启用资源锁,以降低缓存击穿与大量并发请求同时重新生成同一内容的风险。更多背景见输出缓存概述。

创建策略时调用 SetLocking(false) 可以关闭资源锁:

builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(builder => builder
        .With(c => c.HttpContext.Request.Path.StartsWithSegments("/blog"))
        .Tag("tag-blog"));
    options.AddBasePolicy(builder => builder.Tag("tag-all"));
    options.AddPolicy("Query", builder => builder.SetVaryByQuery("culture"));
    options.AddPolicy("NoCache", builder => builder.NoCache());
    options.AddPolicy("NoLock", builder => builder.SetLocking(false));
});

Minimal API 为端点选择不加锁策略:

app.MapGet("/nolock", Gravatar.WriteGravatar)
    .CacheOutput("NoLock");

控制器 API 使用特性选择:

[ApiController]
[Route("/[controller]")]
[OutputCache(PolicyName = "NoLock")]
public class NoLockController : ControllerBase
{
    public async Task GetAsync()
    {
        await Gravatar.WriteGravatar(HttpContext);
    }
}

配置容量与期限限制

OutputCacheOptions 提供以下作用于所有端点的设置:

  • SizeLimit:缓存存储的最大容量。达到上限后,旧条目被驱逐前不再缓存新响应。默认 100 MB。
  • MaximumBodySize:允许缓存的最大响应体大小。响应体超过限制时不缓存。默认 64 MB。
  • DefaultExpirationTimeSpan:策略未指定过期时间时使用的默认缓存时长。默认 60 秒。

选择缓存存储

存储通过 IOutputCacheStore 接口提供。默认采用内存缓存,响应缓存在进程内,因此各服务器拥有独立缓存;服务器进程重启后,该进程的缓存会丢失。

替代方案:Redis

Redis 提供多个服务器节点之间的共享缓存,使各节点的缓存状态一致,并且不随单个应用进程结束而丢失。使用 Redis 输出缓存:

  1. 安装 Microsoft.AspNetCore.OutputCaching.StackExchangeRedis NuGet 包。
  2. 调用 builder.Services.AddStackExchangeRedisOutputCache,提供指向 Redis 服务器的连接字符串;这里使用的是输出缓存注册方法,不是 AddStackExchangeRedisCache。
builder.Services.AddStackExchangeRedisOutputCache(options =>
{
    options.Configuration =
        builder.Configuration.GetConnectionString("MyRedisConStr");
    options.InstanceName = "SampleInstance";
});

builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(builder =>
        builder.Expire(TimeSpan.FromSeconds(10)));
});
  • options.Configuration 是本地或托管 Redis 服务器的连接字符串。例如官方 Azure 示例为 <instance_name>.redis.cache.windows.net:6380,password=,pw,ssl=True,abortConnect=False;它是占位示例,不是真实凭据。
  • 可选的 options.InstanceName 为缓存指定逻辑分区。

这些配置选项与 Redis 分布式缓存选项相同。

不推荐:直接使用 IDistributedCache

不推荐把 IDistributedCache 用作输出缓存存储,因为它不提供标签功能所需的原子操作。推荐使用内置 Redis 支持,或直接依赖底层存储机制,实现自定义 IOutputCacheStore。

相关资料

作者:Tom Dykstra;Copyright (c) Microsoft Corporation。原文:输出缓存中间件,按官方英文与文档源文件核对 .NET 10 适用的内容。正文按 CC BY 4.0 使用,完整许可及免责声明;示例代码按 MIT 许可使用。本版本整理为中文,修正中间件调用顺序的倒译,并澄清端点扩展方法名及片段上下文;代码保持原样,未编译、运行或连接 Redis。

代码的 MIT 完整许可
The MIT License (MIT)
Copyright (c) Microsoft Corporation

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容