在ASP.NET核心Web API中实现HTTP缓存(ETag)



我正在使用ASP.NET Core(ASP.NET 5(Web API应用程序,必须在实体标记的帮助下实现HTTP缓存。早些时候,我也使用了CacheCow,但到目前为止,它似乎不支持ASP.NET Core。我也没有找到任何其他相关的库或框架支持细节。

我可以为它编写自定义代码,但在此之前,我想看看是否有什么可用的。请分享是否已经有可用的东西,以及更好的实施方式。

经过一段时间的尝试,我发现MVC操作过滤器实际上更适合此功能。

public class ETagFilter : Attribute, IActionFilter
{
    private readonly int[] _statusCodes;
    public ETagFilter(params int[] statusCodes)
    {
        _statusCodes = statusCodes;
        if (statusCodes.Length == 0) _statusCodes = new[] { 200 };
    }
    public void OnActionExecuting(ActionExecutingContext context)
    {
    }
    public void OnActionExecuted(ActionExecutedContext context)
    {
        if (context.HttpContext.Request.Method == "GET")
        {
            if (_statusCodes.Contains(context.HttpContext.Response.StatusCode))
            {
                //I just serialize the result to JSON, could do something less costly
                var content = JsonConvert.SerializeObject(context.Result);
                var etag = ETagGenerator.GetETag(context.HttpContext.Request.Path.ToString(), Encoding.UTF8.GetBytes(content));
                if (context.HttpContext.Request.Headers.Keys.Contains("If-None-Match") && context.HttpContext.Request.Headers["If-None-Match"].ToString() == etag)
                {
                    context.Result = new StatusCodeResult(304);
                }
                context.HttpContext.Response.Headers.Add("ETag", new[] { etag });
            }
        }
    }        
}
// Helper class that generates the etag from a key (route) and content (response)
public static class ETagGenerator
{
    public static string GetETag(string key, byte[] contentBytes)
    {
        var keyBytes = Encoding.UTF8.GetBytes(key);
        var combinedBytes = Combine(keyBytes, contentBytes);
        return GenerateETag(combinedBytes);
    }
    private static string GenerateETag(byte[] data)
    {
        using (var md5 = MD5.Create())
        {
            var hash = md5.ComputeHash(data);
            string hex = BitConverter.ToString(hash);
            return hex.Replace("-", "");
        }            
    }
    private static byte[] Combine(byte[] a, byte[] b)
    {
        byte[] c = new byte[a.Length + b.Length];
        Buffer.BlockCopy(a, 0, c, 0, a.Length);
        Buffer.BlockCopy(b, 0, c, a.Length, b.Length);
        return c;
    }
}

然后在你想要的动作或控制器上使用它作为属性:

[HttpGet("data")]
[ETagFilter(200)]
public async Task<IActionResult> GetDataFromApi()
{
}

中间件和过滤器之间的重要区别在于,中间件可以在MVC中间件之前和之后运行,并且只能与HttpContext一起工作。此外,一旦MVC开始将响应发送回客户端,就太晚了,无法对其进行任何更改

另一方面,过滤器是MVC中间件的一部分。他们可以访问MVC上下文,在这种情况下,使用MVC上下文实现此功能更简单。关于MVC中过滤器及其管道的更多信息。

基于Eric的回答,我将使用一个可以在实体上实现的接口来支持实体标记。在过滤器中,只有当操作返回具有此接口的实体时,才会添加ETag。

这允许您对标记哪些实体更加挑剔,并允许每个实体控制其标记的生成方式。这将比序列化所有内容和创建哈希更高效。它还消除了检查状态代码的需要。它可以安全方便地作为全局过滤器添加,因为您是通过在模型类上实现接口来"选择加入"该功能的。

public interface IGenerateETag
{
    string GenerateETag();
}
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
public class ETagFilterAttribute : Attribute, IActionFilter
{
    public void OnActionExecuting(ActionExecutingContext context)
    {
    }
    public void OnActionExecuted(ActionExecutedContext context)
    {
        var request = context.HttpContext.Request;
        var response = context.HttpContext.Response;
        if (request.Method == "GET" &&
            context.Result is ObjectResult obj &&
            obj.Value is IGenerateETag entity)
        {
            string etag = entity.GenerateETag();
            // Value should be in quotes according to the spec
            if (!etag.EndsWith("""))
                etag = """ + etag +""";
            string ifNoneMatch = request.Headers["If-None-Match"];
            if (ifNoneMatch == etag)
            {
                context.Result = new StatusCodeResult(304);
            }
            context.HttpContext.Response.Headers.Add("ETag", etag);
        }
    }
}

我使用的中间件对我来说很好。

它将HttpCache头添加到响应(Cache Control、Expires、ETag、Last Modified(中,并实现缓存过期&验证模型。

你可以在nuget.org上找到一个名为Marvin.Cache.Headers.的包

您可以在其Github主页上找到更多信息:https://github.com/KevinDockx/HttpCacheHeaders

这里有一个更广泛的MVC视图版本(用asp.net内核1.1测试(:

using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Net.Http.Headers;
namespace WebApplication9.Middleware
{
    // This code is mostly here to generate the ETag from the response body and set 304 as required,
    // but it also adds the default maxage (for client) and s-maxage (for a caching proxy like Varnish) to the cache-control in the response
    //
    // note that controller actions can override this middleware behaviour as needed with [ResponseCache] attribute   
    //
    // (There is actually a Microsoft Middleware for response caching - called "ResponseCachingMiddleware", 
    // but it looks like you still have to generate the ETag yourself, which makes the MS Middleware kinda pointless in its current 1.1.0 form)
    //
    public class ResponseCacheMiddleware
    {
        private readonly RequestDelegate _next;
        // todo load these from appsettings
        const bool ResponseCachingEnabled = true;
        const int ActionMaxAgeDefault = 600; // client cache time
        const int ActionSharedMaxAgeDefault = 259200; // caching proxy cache time 
        const string ErrorPath = "/Home/Error";
        public ResponseCacheMiddleware(RequestDelegate next)
        {
            _next = next;
        }
        // THIS MUST BE FAST - CALLED ON EVERY REQUEST 
        public async Task Invoke(HttpContext context)
        {
            var req = context.Request;
            var resp = context.Response;
            var is304 = false;
            string eTag = null;
            if (IsErrorPath(req))
            {
                await _next.Invoke(context);
                return;
            }

            resp.OnStarting(state =>
            {
                // add headers *before* the response has started
                AddStandardHeaders(((HttpContext)state).Response);
                return Task.CompletedTask;
            }, context);

            // ignore non-gets/200s (maybe allow head method?)
            if (!ResponseCachingEnabled || req.Method != HttpMethods.Get || resp.StatusCode != StatusCodes.Status200OK)
            {
                await _next.Invoke(context);
                return;
            }

            resp.OnStarting(state => {
                // add headers *before* the response has started
                var ctx = (HttpContext)state;
                AddCacheControlAndETagHeaders(ctx, eTag, is304); // intentional modified closure - values set later on
                return Task.CompletedTask;
            }, context);

            using (var buffer = new MemoryStream())
            {
                // populate a stream with the current response data
                var stream = resp.Body;
                // setup response.body to point at our buffer
                resp.Body = buffer;
                try
                {
                    // call controller/middleware actions etc. to populate the response body 
                    await _next.Invoke(context);
                }
                catch
                {
                    // controller/ or other middleware threw an exception, copy back and rethrow
                    buffer.CopyTo(stream);
                    resp.Body = stream;  // looks weird, but required to keep the stream writable in edge cases like exceptions in other middleware
                    throw;
                }

                using (var bufferReader = new StreamReader(buffer))
                {
                    // reset the buffer and read the entire body to generate the eTag
                    buffer.Seek(0, SeekOrigin.Begin);
                    var body = bufferReader.ReadToEnd();
                    eTag = GenerateETag(req, body);

                    if (req.Headers[HeaderNames.IfNoneMatch] == eTag)
                    {
                        is304 = true; // we don't set the headers here, so set flag
                    }
                    else if ( // we're not the only code in the stack that can set a status code, so check if we should output anything
                        resp.StatusCode != StatusCodes.Status204NoContent &&
                        resp.StatusCode != StatusCodes.Status205ResetContent &&
                        resp.StatusCode != StatusCodes.Status304NotModified)
                    {
                        // reset buffer and copy back to response body
                        buffer.Seek(0, SeekOrigin.Begin);
                        buffer.CopyTo(stream);
                        resp.Body = stream; // looks weird, but required to keep the stream writable in edge cases like exceptions in other middleware
                    }
                }
            }
        }

        private static void AddStandardHeaders(HttpResponse resp)
        {
            resp.Headers.Add("X-App", "MyAppName");
            resp.Headers.Add("X-MachineName", Environment.MachineName);
        }

        private static string GenerateETag(HttpRequest req, string body)
        {
            // TODO: consider supporting VaryBy header in key? (not required atm in this app)
            var combinedKey = req.GetDisplayUrl() + body;
            var combinedBytes = Encoding.UTF8.GetBytes(combinedKey);
            using (var md5 = MD5.Create())
            {
                var hash = md5.ComputeHash(combinedBytes);
                var hex = BitConverter.ToString(hash);
                return hex.Replace("-", "");
            }
        }

        private static void AddCacheControlAndETagHeaders(HttpContext ctx, string eTag, bool is304)
        {
            var req = ctx.Request;
            var resp = ctx.Response;
            // use defaults for 404s etc.
            if (IsErrorPath(req))
            {
                return;
            }
            if (is304)
            {
                // this will blank response body as well as setting the status header
                resp.StatusCode = StatusCodes.Status304NotModified;
            }
            // check cache-control not already set - so that controller actions can override caching 
            // behaviour with [ResponseCache] attribute
            // (also see StaticFileOptions)
            var cc = resp.GetTypedHeaders().CacheControl ?? new CacheControlHeaderValue();
            if (cc.NoCache || cc.NoStore)
                return;
            // sidenote - https://tools.ietf.org/html/rfc7232#section-4.1
            // the server generating a 304 response MUST generate any of the following header 
            // fields that WOULD have been sent in a 200(OK) response to the same 
            // request: Cache-Control, Content-Location, Date, ETag, Expires, and Vary.
            // so we must set cache-control headers for 200s OR 304s
            cc.MaxAge = cc.MaxAge ?? TimeSpan.FromSeconds(ActionMaxAgeDefault); // for client
            cc.SharedMaxAge = cc.SharedMaxAge ?? TimeSpan.FromSeconds(ActionSharedMaxAgeDefault); // for caching proxy e.g. varnish/nginx
            resp.GetTypedHeaders().CacheControl = cc; // assign back to pick up changes
            resp.Headers.Add(HeaderNames.ETag, eTag);
        }
        private static bool IsErrorPath(HttpRequest request)
        {
            return request.Path.StartsWithSegments(ErrorPath);
        }
    }
}

作为Erik Božič回答的补充,我发现当从ActionFilterAttribute继承时,HttpContext对象没有正确报告StatusCode,并应用了控制器范围。HttpContext.Response.StatusCode始终为200,这表明它可能在管道中的这一点上没有设置。相反,我能够从ActionExecutedContext上下文中获取StatusCode。Result.StatusCode.

我找到了一个"更接近"web api控制器方法的替代解决方案,因此您可以根据方法决定要设置哪个ETag。。。

请参阅我的回复:如何使用操作过滤器和HttpResponseMessage 在Web API中使用ETag

我们可以在ControllerBase类上编写简单的扩展方法

    using Microsoft.AspNetCore.Mvc;
    
    namespace WebApiUtils.Caching
    {
        public static class ExtensionMethods
        {
            public static IActionResult OkOr304<T>(
                this ControllerBase controller,
                T resultObject,
                Func<T, string> etagBuilder
            )
            {
                var etag = etagBuilder(resultObject);
    
                if (
                    // Add additional headers if needed
                    controller.Request.Headers.Keys.Contains("If-None-Match")
                    && controller.Request.Headers["If-None-Match"].ToString() == etag
                )
                {
                    return controller.StatusCode(304);
                }
    
                controller.Response.Headers.Add("ETag", new[] { etag });
    
                return controller.Ok(resultObject);
            }
    
            public static IActionResult OkOr304<T>(this ControllerBase controller, T resultObject)
            {
                return controller.OkOr304(
                    resultObject,
                    x =>
                    {
                        // Implement default ETag strategy
                        return "";
                    }
                );
            }
        }
    }

然后我们可以在带有的控制器中使用它

return this.OkOr304(resultObject, etagBuilder);

return this.OkOr304(resultObject);

如果结果对象具有某种版本指示符,例如,则此操作效果非常好

return this.OkOr304(resultObject, x => x.VersionNumber.ToString());

最健壮的实现(ASP.NET Core 5.0+(:

以下是一个更健壮和可靠的实现,作为结果过滤器而不是动作过滤器。

以下是该解决方案相对于公认答案的优点:

  • 操作筛选器中未正确报告状态代码。在OnActionExecuted方法中,context.HttpContext.Response.StatusCode的值总是200,例如,即使操作方法返回了NotFound()。看看这个问题的答案,还有这个问题和这个问题
  • 使用类型化的头,使HTTP头的检查比接受答案的操作过滤器进行的简单字符串比较更可靠
  • 没有JSON序列化——这是一个完全不必要的昂贵操作,而且对于返回对象(例如文件等(以外的任何内容的操作方法来说,它也根本不起作用
  • 不需要将请求路径包括为ETag计算的一部分;资源";(即请求URI(
  • 支持条件请求以及If-Modified-SinceIf-None-Match请求头,如果没有它们,实现就不完整。如果您不打算实际处理客户端稍后发送的条件请求(使用If-None-Match(,为什么还要麻烦向客户端发送ETag
public class HandleHttpCachingAttribute : ResultFilterAttribute
{
    // NOTE: When a "304 Not Modified" response is to be sent back to the client, all headers apart from the following list should be stripped from the response to keep the response size minimal. See https://datatracker.ietf.org/doc/html/rfc7232#section-4.1:~:text=200%20(OK)%20response.-,The%20server%20generating%20a%20304,-response%20MUST%20generate
    private static readonly string[] _headersToKeepFor304 = {
        HeaderNames.CacheControl,
        HeaderNames.ContentLocation,
        HeaderNames.ETag,
        HeaderNames.Expires,
        HeaderNames.Vary,
        // NOTE: We don't need to include `Date` here — even though it is one of the headers that should be kept — because `Date` will always be included in every response anyway.
    };
    public override async Task OnResultExecutionAsync(
        ResultExecutingContext context,
        ResultExecutionDelegate next
    )
    {
        var request = context.HttpContext.Request;
        var response = context.HttpContext.Response;
        // NOTE: For more info on this technique, see https://stackoverflow.com/a/65901913 and https://www.madskristensen.net/blog/send-etag-headers-in-aspnet-core/ and https://gist.github.com/madskristensen/36357b1df9ddbfd123162cd4201124c4
        var originalStream = response.Body; // NOTE: This specific `Stream` object is what ASP.NET Core will eventually read and send to the client in the response body.
        using MemoryStream memoryStream = new();
        response.Body = memoryStream;
        await next();
        memoryStream.Position = 0;
        // NOTE: We only work with responses that have a status code of 200.
        if (response.StatusCode == StatusCodes.Status200OK)
        {
            var requestHeaders = request.GetTypedHeaders();
            var responseHeaders = response.GetTypedHeaders();
            responseHeaders.CacheControl = new()
            {
                Public = true,
                MaxAge = TimeSpan.FromDays(365), // NOTE: One year is one of the most common values for the `max-age` directive of `Cache-Control`. It's typically used for resources that are immutable (never change) and can therefore be cached indefinitely. See https://stackoverflow.com/a/25201898
            };
            responseHeaders.ETag ??= GenerateETag(memoryStream); // NOTE: We calculate an ETag based on the body of the request, if some later middleware hasn't already set one.
            if (IsClientCacheValid(requestHeaders, responseHeaders))
            {
                response.StatusCode = StatusCodes.Status304NotModified;
                // NOTE: Remove all unnecessary headers while only keeping the ones that should be included in a `304` response.
                foreach (var header in response.Headers)
                    if (!_headersToKeepFor304.Contains(header.Key))
                        response.Headers.Remove(header.Key);
                return;
            }
        }
        await memoryStream.CopyToAsync(originalStream); // NOTE: Writes anything the later middleware wrote to the the body (and by extension our `memoryStream`) to the original response body stream, so that it will be sent back to the client as the response body.
    }
    private static EntityTagHeaderValue GenerateETag(Stream stream)
    {
        byte[] hashBytes = MD5.HashData(stream); // NOTE: MD5 is still suitable for use cases like this one, even though it's "cryptographically broken". It's pretty commonly used for generating ETags.
        stream.Position = 0; // NOTE: Reset the position to 0 so that the calling code can still read the stream.
        string hashString = Convert.ToBase64String(hashBytes); // NOTE: We choose base64 instead of hex because it'd be shorter, since the character set is larger.
        return new('"' + hashString + '"'); // NOTE: An `ETag` needs to be surrounded by quotes. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag#:~:text=It%20is%20a%20string%20of%20ASCII%20characters%20placed%20between%20double%20quotes
    }
    private static bool IsClientCacheValid(RequestHeaders reqHeaders, ResponseHeaders resHeaders)
    {
        // NOTE: If both `If-None-Match` and `If-Modified-Since` are present in a request, `If-None-Match` takes precedence and `If-Modified-Since` is ignored (provided, of course, that the resource supports entity-tags, hence the second condition after the `&&` operator in the following `if`). See https://datatracker.ietf.org/doc/html/rfc7232#section-3.3:~:text=A%20recipient%20MUST%20ignore%20If%2DModified%2DSince%20if
        // NOTE: Therefore, we put the condition that checks if `If-None-Match` exists first.
        if (reqHeaders.IfNoneMatch.Any() && resHeaders.ETag is not null)
            return reqHeaders.IfNoneMatch.Any(etag =>
                etag.Compare(resHeaders.ETag, useStrongComparison: false) // NOTE: We shouldn't use `Contains` here because it would use the `Equals` method which apparently shouldn't be used for ETag equality checks. See https://learn.microsoft.com/en-us/dotnet/api/microsoft.net.http.headers.entitytagheadervalue.equals?view=aspnetcore-7.0. We also use weak comparison, because that seems to what the built-in response caching middleware (which is general-purpose enough in this particular respect to be able to inform us here) is doing. See https://github.com/dotnet/aspnetcore/blob/7f4ee4ac2fc945eab33d004581e7b633bdceb475/src/Middleware/ResponseCaching/src/ResponseCachingMiddleware.cs#LL449C51-L449C70
            );
        if (reqHeaders.IfModifiedSince is not null && resHeaders.LastModified is not null)
            return reqHeaders.IfModifiedSince >= resHeaders.LastModified;
        return false;
    }
}

用法--在FooController.c:中

[HandleHttpCaching]
public IActionResult Get(int id)
{
}

最新更新