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

40

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

如果已经有相关可用的东西,请告诉我,最好的方式是什么。否则我会为此编写自定义代码。


5
如果使用了 app.UseStaticFiles(),则静态文件会实现 etags,具体内容请参考 this - Sam Sippe
1
FYI,CacheCow现在支持ASP.NET Core。 - Drew Sumido
8个回答

46

经过一段时间尝试使用中间件,我发现 MVC action filters 实际上更适合这个功能。

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中的管道。

这是一条提示,针对那些可能考虑使用此答案作为网页(而不是API)的人。它似乎没有考虑到视图文件的更改。 - jimasp
执行此操作在端点执行后是否否定了不必序列化结果的主要优点?即,检测到可以返回304的一个好处是可以立即停止任何进一步的服务器处理并返回304。 - oatsoda
@oatsoda 是的,你说得对。这里的主要好处是我们不会浪费客户端的带宽。但服务器仍然会执行它本来要执行的所有处理。理想情况下,服务器应该将响应缓存到某个键值结构中,其中键是etag。我已经做过类似的事情,但它不是完全通用的解决方案。 如果我能以足够通用的方式完成它,可以在这里发布SO答案。 - erikbozic
@erikbozic 是的,我想有两个部分。1)读取/计算当前ETag所需的时间 - 然后2)序列化响应所需的时间。我认为你至少要防止#2,因为如果返回304,则它是多余的! #1不是严格多余的,但可以进行优化! - oatsoda
@oatsoda 只是为了澄清:这个实现依赖于序列化响应来生成 etag。这就是为什么它在端点执行之后。你必须在决定返回 304 之前生成它。如果涉及实际的服务器端缓存,那么你可以在执行端点之前将请求中发送的 etag 值与缓存的值进行比较,如果它们匹配,就返回 304。但是服务器端缓存非常特定于应用程序(键是仅 url、附加租户 ID 标头、每个用户缓存等)。 - erikbozic
显示剩余2条评论

5

Eric's answer的基础上,我会使用一个可以在实体上实现的接口来支持实体标记。在筛选器中,只有在返回具有此接口的实体时,您才会添加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);
        }
    }
}

嘿,你能展示一下 GenerateETag 在这里是如何工作的吗?我需要向它发送 JSON 数据吗? - Divyang Desai

3
我正在使用一个中间件,对我来说运行得很好。
它会向响应添加 HttpCache 头部(Cache-Control、Expires、ETag、Last-Modified),并实现缓存过期和验证模型。
你可以在 nuget.org 上找到它,作为名为 Marvin.Cache.Headers 的包。
你可以在其 Github 主页上找到更多信息: https://github.com/KevinDockx/HttpCacheHeaders

1
在Stack Overflow上,仅提供链接的答案通常会受到反对。随着时间的推移,链接可能会失效或无法访问,这意味着您的答案将来对用户没有用处。最好的做法是在实际帖子中提供您答案的一般细节,并引用您的链接作为参考。 - herrbischoff
1
@herrbischoff,我在我的回答中添加了更多细节,希望现在更好了。 - Jzrzmy

3
这是一个更详细的MVC视图版本(已在asp.net core 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);
        }
    }
}

1
作为 Erik Božič 的回答 的补充,我发现当从 ActionFilterAttribute 继承并应用于整个控制器时,HttpContext 对象无法正确地报告 StatusCode。HttpContext.Response.StatusCode 总是返回 200,这表明它可能在管道中的某个点尚未被设置。相反,我能够从 ActionExecutedContext context.Result.StatusCode 中获取 StatusCode。

附录作为注释更合适。 - Marshall Davis
2
@ToothlessRebel 先前尝试过了,声望值太低了 :( - cagefree
这不是绕过现有系统并将评论发布为答案的有效理由。 - Marshall Davis
@cagefree,您只有在应用于整个控制器而不是单个REST端点时才遇到了这个问题吗?如果您提供此信息,我将编辑Erik的答案以突出您的答案。 - William Bernting
虽然有点晚(而且是在不同版本的asp.net core上),但我现在尝试了一下,对我来说运行得很好。也许你的请求管道中还有其他影响因素?你还能重现吗? 我的工作示例只是一个新的API项目,其中包含我发布的代码。 - erikbozic

0

0
我们可以在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());

0

最强大的实现方式(ASP.NET Core 5.0+):

以下是一个更为强大和可靠的实现方式,它作为结果过滤器而不是操作过滤器。

这种解决方案相对于已接受答案的优点如下:

  • 在操作过滤器中,状态码没有正确报告。例如,即使操作方法返回了NotFound()context.HttpContext.Response.StatusCode的值在OnActionExecuted方法中始终为200。请参见这个相同问题的答案,以及这个问题这个问题
  • 使用类型化标头,使HTTP标头的检查比接受的答案操作过滤器所做的简单字符串比较更可靠。
  • 没有JSON序列化——这是一个完全不必要的昂贵操作,对于返回除对象(例如文件等)之外的任何内容的操作方法也根本不起作用。
  • 没有必要将请求路径包含在ETag计算中,因为ETags基本上与它们所属的“资源”(即请求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://dev59.com/yL_qa4cB1Zd3GeqPCjSb#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://dev59.com/zGw05IYBdhLWcg3wuUAL#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)
{

}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接