title: .NET Core浏览器缓存方案
date: 2022-12-02 14:17:36
tags:
- .NET

缓存介绍及方案

在后端开发中经常会使用缓存来提高接口的响应速度,降低系统的计算成本,比如将一些不经常改变的数据通过Redis或者直接在应用内缓存下来,后续一段时间内用户访问该接口时直接返回数据,而不是重新计算

实际上,数据不仅可以缓存在后端服务器,还可以通过浏览器进行缓存,下面介绍一下Http请求时Header内跟缓存相关的属性:

  1. Cache-Control:包含了大部分用于缓存控制的属性,比如max-age,public,private,no-cache,no-store,下面细说一下这些值的作用:
    1. public: 所有人都可以缓存,比如客户端和代理服务器(没有强制作用,实际上中间服务器想缓存也没办法控制,只是一个告知作用)
    2. private:所有内容只有客户端可以缓存
    3. no-cache:不进行强制缓存,但是是否使用缓存需要经过协商缓存来验证决定
    4. max-age:缓存的最大时间,单位是秒
    5. no-store:不进行缓存,每次都是从服务器获取最新的数据,和no-cache的区别在于no-cache是可以缓存的,只是每次都要经过协商缓存来验证是否使用缓存
  2. Expired:过期时间,如果在过期时间内,浏览器不会向服务器发送请求,直接使用缓存,Http1.0的属性,Http1.1已经不再使用,可以忽略
  3. Etag+If-None-Match:用于协商缓存,第一次服务器计算返回内容的MD5作为etag返回,第二次客户端请求时,将Etag放到If-None-Match字段请求服务器,服务器判断etag是否发生变化,用于协商缓存
  4. Last-Modified+If-Modified-Since:服务器返回的最后修改时间,用于协商缓存,客户端第二次请求时将Last-Modified放到If-Modified-Since中,服务器根据这两个值来判断是否使用缓存

知道了缓存需要的几个属性之后,我们还需要知道浏览器缓存的2种方式:

  1. 强缓存,请求不会到后端,而是直接从缓存中获取,如果缓存过期了,才会向后端发送请求
  2. 协商缓存,请求会到后端,后端会根据请求头中的Etag或者Last-Modified来判断是否使用缓存,如果使用缓存,返回304,不使用缓存,返回200,Etag优先级高于Last-Modified(有Etag先用Etag,没有Etag再用Last-Modified)

强缓存是通过Cache-Control中的max-age字段控制的,而即使是在缓存时间内,也不一定就会是强缓存,还跟用户的行为有关,也和请求的方式有关,根据我的一些操作实验,我总结了一下:

  1. 浏览器填入接口地址后,多次F5刷新,不会走强缓存,还是会发起http请求
  2. 在访问过一次接口地址后,在新的标签页打开接口地址,会走强缓存,不会发起http请求
  3. vue开发的网页内使用axios发起请求获取数据,会走强缓存,不会发起http请求,同时F5刷新页面也不会发起http请求
  4. 控制台勾选了停止缓存,所有操作都不会走强缓存,都会发起http请求
  5. 前进后退走强缓存,不会发起http请求

协商缓存通过后端控制,下面以AspNetCore.CacheOutput库中的缓存方案为例,来看看如何使用Etag+Max-age实现客户端缓存:

在方法执行前执行如下代码:


public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
    Console.WriteLine("进入了缓存AOP");
    if (context == null)
    {
        throw new ArgumentNullException(nameof(context));
    }

    if (next == null)
    {
        throw new ArgumentNullException(nameof(next));
    }

    if (context.Result != null)
    {
        return;
    }

    if (!IsCachingAllowed(context, AnonymousOnly))
    {
        await next();

        return;
    }

    SwapResponseBodyToMemoryStream(context);

    IServiceProvider serviceProvider = context.HttpContext.RequestServices;
    IApiCacheOutput cache = serviceProvider.GetRequiredService(typeof(IApiCacheOutput)) as IApiCacheOutput;
    CacheKeyGeneratorFactory cacheKeyGeneratorFactory = serviceProvider.GetRequiredService(typeof(CacheKeyGeneratorFactory)) as CacheKeyGeneratorFactory;
    ICacheKeyGenerator cacheKeyGenerator = cacheKeyGeneratorFactory.GetCacheKeyGenerator(this.CacheKeyGenerator);

    EnsureCacheTimeQuery();

    string expectedMediaType = GetExpectedMediaType(context);

    string cacheKey = cacheKeyGenerator.MakeCacheKey(context, expectedMediaType, ExcludeQueryStringFromCacheKey);

    context.HttpContext.Items[CurrentRequestCacheKey] = cacheKey;

    if (!await cache.ContainsAsync(cacheKey))
    {
        ActionExecutedContext result = await next();

        if (result.Exception != null)
        {
            await SwapMemoryStreamToResponseBody(context);
        }

        return;
    }

    context.HttpContext.Items[CurrentRequestSkipResultExecution] = true;

    if (context.HttpContext.Request.Headers[HeaderNames.IfNoneMatch].Any())
    {
        string etag = await cache.GetAsync<string>(cacheKey + Constants.EtagKey);

        if (etag != null)
        {
            if (context.HttpContext.Request.Headers[HeaderNames.IfNoneMatch].Any(e => e == etag))
            {
                SetEtag(context.HttpContext.Response, etag);

                CacheTime time = CacheTimeQuery.Execute(DateTime.Now);

                ApplyCacheHeaders(context.HttpContext.Response, time);

                context.HttpContext.Response.ContentLength = 0;
                context.HttpContext.Response.StatusCode = StatusCodes.Status304NotModified;

                return;
            }
        }
    }

    byte[] val = await cache.GetAsync<byte[]>(cacheKey);

    if (val == null)
    {
        ActionExecutedContext result = await next();

        if (result.Exception != null)
        {
            await SwapMemoryStreamToResponseBody(context);
        }

        return;
    }

    await context.HttpContext.Response.Body.WriteAsync(val, 0, val.Length);

    string contentType = await cache.GetAsync<string>(cacheKey + Constants.ContentTypeKey) ?? expectedMediaType;

    context.HttpContext.Response.Headers[HeaderNames.ContentType] = contentType;

    context.HttpContext.Response.StatusCode = StatusCodes.Status200OK;

    string responseEtag = await cache.GetAsync<string>(cacheKey + Constants.EtagKey);

    if (responseEtag != null)
    {
        SetEtag(context.HttpContext.Response, responseEtag);
    }

    CacheTime cacheTime = CacheTimeQuery.Execute(DateTime.Now);

    if (
        DateTimeOffset.TryParse(
            await cache.GetAsync<string>(cacheKey + Constants.LastModifiedKey),
            out DateTimeOffset lastModified
        )
    )
    {
        ApplyCacheHeaders(context.HttpContext.Response, cacheTime, lastModified);
    }
    else
    {
        ApplyCacheHeaders(context.HttpContext.Response, cacheTime);
    }
}

上面这段代码的核心逻辑如下:

  1. 获取http请求头的if-none-match字段,如果有值,则说明客户端缓存了该资源,需要和服务端的Etag进行比较,如果一致,则返回304,否则继续往下
  2. 第一步条件不满足的话说明Etag不一致,查找服务端本地的缓存,如果存在,则返回新的数据和Etag,否则执行方法逻辑,获取执行后的结果计算Etag并返回

在方法执行完成后,执行如下代码:

public override async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
{
    await base.OnResultExecutionAsync(context, next);

    if (
        context.HttpContext.RequestAborted.IsCancellationRequested ||
        context.HttpContext.Response == null ||
        context.HttpContext.Items[CurrentRequestSkipResultExecution] != null ||
        !(
            context.HttpContext.Response.StatusCode >= (int)HttpStatusCode.OK &&
            context.HttpContext.Response.StatusCode < (int)HttpStatusCode.Ambiguous
        )
    )
    {
        await SwapMemoryStreamToResponseBody(context);

        return;
    }

    if (!IsCachingAllowed(context, AnonymousOnly))
    {
        await SwapMemoryStreamToResponseBody(context);

        return;
    }

    DateTimeOffset actionExecutionTimestamp = DateTimeOffset.Now;
    CacheTime cacheTime = CacheTimeQuery.Execute(actionExecutionTimestamp.DateTime);

    if (cacheTime.AbsoluteExpiration > actionExecutionTimestamp)
    {
        IServiceProvider serviceProvider = context.HttpContext.RequestServices;
        IApiCacheOutput cache = serviceProvider.GetRequiredService(typeof(IApiCacheOutput)) as IApiCacheOutput;
        CacheKeyGeneratorFactory cacheKeyGeneratorFactory = serviceProvider.GetRequiredService(typeof(CacheKeyGeneratorFactory)) as CacheKeyGeneratorFactory;
        ICacheKeyGenerator cacheKeyGenerator = cacheKeyGeneratorFactory.GetCacheKeyGenerator(this.CacheKeyGenerator);

        string cacheKey = context.HttpContext.Items[CurrentRequestCacheKey] as string;

        if (!string.IsNullOrWhiteSpace(cacheKey))
        {
            if (!await cache.ContainsAsync(cacheKey))
            {
                SetEtag(context.HttpContext.Response, CreateEtag());

                context.HttpContext.Response.Headers.Remove(HeaderNames.ContentLength);

                var actionDescriptor = context.ActionDescriptor as ControllerActionDescriptor;
                string controller = actionDescriptor?.ControllerTypeInfo.FullName;
                string action = actionDescriptor?.ActionName;
                string baseKey = cacheKeyGenerator.MakeBaseCacheKey(controller, action);
                string contentType = context.HttpContext.Response.ContentType;
                string etag = context.HttpContext.Response.Headers[HeaderNames.ETag];

                var memoryStream = context.HttpContext.Response.Body as MemoryStream;

                if (memoryStream != null)
                {
                    byte[] content = memoryStream.ToArray();

                    await cache.AddAsync(baseKey, string.Empty, cacheTime.AbsoluteExpiration);
                    await cache.AddAsync(cacheKey, content, cacheTime.AbsoluteExpiration, baseKey);

                    await cache.AddAsync(
                        cacheKey + Constants.ContentTypeKey,
                        contentType,
                        cacheTime.AbsoluteExpiration,
                        baseKey
                    );

                    await cache.AddAsync(
                        cacheKey + Constants.EtagKey,
                        etag,
                        cacheTime.AbsoluteExpiration,
                        baseKey
                    );

                    await cache.AddAsync(
                        cacheKey + Constants.LastModifiedKey,
                        actionExecutionTimestamp.ToString(),
                        cacheTime.AbsoluteExpiration,
                        baseKey
                    );
                }
            }
        }
    }

    ApplyCacheHeaders(context.HttpContext.Response, cacheTime, actionExecutionTimestamp);

    await SwapMemoryStreamToResponseBody(context);
}

这段代码的核心逻辑就是如果方法执行前缓存命中了,则返回,如果缓存未命中则在执行方法逻辑之后,然后将结果缓存到内存中,将响应的缓存头和数据放到Response中,把结果返回给客户端

精细化控制缓存策略

如果需要让浏览器每次都来服务器校验缓存是否过期(也就是走协商缓存),可以使用no-cache(参考AspNetCore.CacheOutput库文档),这样就不会在响应头中加上Cache-Control和Expires字段了,这样浏览器每次都会向服务器发起请求,然后服务器会根据Etag来判断是否返回304,可以控制资源随时更新

我们以AspNetCore.CacheOutput库为例,下面写几个示例代码:

// 仅使用协商缓存 Cache-Control: no-cache 保证每次都会向服务器发起请求,然后服务器会根据Etag来判断是否返回304
[CacheOutput(ServerTimeSpan = 500,NoCache = true)]

// Cache-Control为空,一般情况下和上面一致,不过不保证浏览器会走强缓存
[CacheOutput(ServerTimeSpan = 500)]

// 同时使用强缓存和协商缓存 根据不同的情况走不同的策略
[CacheOutput(ClientTimeSpan = 500,ServerTimeSpan = 500)]

// 仅仅使用强缓存,到期或者根据用户行为,可能会发起http请求,服务器由于没有缓存,所以会重新执行方法
[CacheOutput(ClientTimeSpan = 500)]

总结

浏览器缓存最大的好处就是在进行强缓存时,不会发送http请求到后端,这样后端服务器的压力就更小了,可以认为成本几乎是0,但有一个缺点就是无法100%保证浏览器能够走强缓存,因为浏览器的缓存策略是由浏览器自己决定的,我们只能尽量的让浏览器走强缓存,而不是每次都去请求后端服务器

如果没有走强缓存而是走协商缓存的话,每次都会发送http请求到后端,后端服务器想缓存的话还需要使用本地内存缓存或者Redis,否则就得重新计算,不过相较于直接使用Redis或者内存缓存还是有好处的,就是直接返回304+ETag,不需要带响应体,如果数据量大且并发量高的话,对带宽又是一个非常大的节约,同时还可以控制数据的过期时间,这样就可以保证在数据更新后浏览器能请求到最新的数据

补充测试代码

记录请求体长度的中间件:

public class ContentLengthMiddleware
{
    RequestDelegate _next;

    public ContentLengthMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task Invoke(HttpContext context)
    {
        using (var buffer = new MemoryStream())
        {
            var request = context.Request;
            var response = context.Response;

            var bodyStream = response.Body;
            response.Body = buffer;

            await _next(context);
            Console.WriteLine($"{request.Path} ({response.ContentType}) Content-Length: {response.ContentLength ?? buffer.Length}");
            buffer.Position = 0;
            await buffer.CopyToAsync(bodyStream);
        }
    }
}

测试接口:

[HttpGet]
[CacheOutput(ClientTimeSpan = 500)]
public IEnumerable<WeatherForecast> Get()
{
    Thread.Sleep(3000);
    var rng = new Random();

    return Enumerable.Range(1, 100).Select(index => new WeatherForecast
    {
        Date = DateTime.Now.AddDays(index),
        TemperatureC = rng.Next(-20, 55),
        Summary = Summaries[rng.Next(Summaries.Length)]
    })
    .ToArray();
}

参考链接

  1. https://blog.csdn.net/qq_34556414/article/details/106337292
  2. https://blog.csdn.net/u012193330/article/details/99668549
  3. https://www.ltonus.com/Fenxiang/web-cache-rule.html
  4. https://stackoverflow.com/questions/11245767/is-chrome-ignoring-cache-control-max-age