Подтвердить что ты не робот

Внедрить HTTP-кеш (ETag) в ASP.NET Core Web API

Я работаю над приложением ASP.NET Core (ASP.NET 5) Web API и должен реализовать HTTP-кэширование с помощью тегов сущностей. Ранее я использовал CacheCow для того же, но, похоже, он не поддерживает ASP.NET Core на данный момент. Я также не нашел других релевантных библиотек или подробностей поддержки фреймворка для этого.

Я могу написать собственный код для того же самого, но перед этим я хочу увидеть, если что-нибудь уже доступно. Пожалуйста, поделитесь, если что-то уже доступно и как лучше это реализовать.

4b9b3361

Ответ 1

Спустя некоторое время, пытаясь заставить его работать с промежуточным программным обеспечением, я понял, что фильтры действий 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.

Ответ 2

Я создал промежуточное ПО следующим образом:

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using System;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;

namespace api.Middleware
{
    public class ETagMiddleware
    {
        private readonly RequestDelegate _next;

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

        public async Task Invoke(HttpContext context)
        {
            var body = string.Empty;

            context.Response.OnStarting(state =>
            {
                var httpContext = (HttpContext)state;

                if (context.Response.StatusCode == (int)HttpStatusCode.OK &&
                context.Request.Method == "GET")
                {
                    var key = GetKey(context.Request);
                    Debug.WriteLine($"Key: {key}");
                    Debug.WriteLine($"Body: {body}");

                    var combinedKey = key + body;
                    Debug.WriteLine($"CombinedKey: {combinedKey}");

                    var combinedBytes = Encoding.UTF8.GetBytes(combinedKey);

                    // generate ETag
                    var ETAG = GenerateETag(combinedBytes);
                    Debug.WriteLine($"ETag: {ETAG}");

                    if (context.Request.Headers.Keys.Contains("If-None-Match") &&
                        context.Request.Headers["If-None-Match"].ToString() == ETAG)
                    {
                        // not modified
                        context.Response.StatusCode = (int)HttpStatusCode.NotModified;
                    }
                    else
                    {
                        context.Response.Headers.Add("ETag", new[] { ETAG });
                    }
                }

                return Task.FromResult(0);
        }, context);

            using (var buffer = new MemoryStream())
            {
                // replace the context response with our buffer
                var stream = context.Response.Body;
                context.Response.Body = buffer;

                // invoke the rest of the pipeline
                await _next.Invoke(context);

                // reset the buffer and read out the contents
                buffer.Seek(0, SeekOrigin.Begin);
                var reader = new StreamReader(buffer);
                using (var bufferReader = new StreamReader(buffer))
                {
                    body = await bufferReader.ReadToEndAsync();

                    //reset to start of stream
                    buffer.Seek(0, SeekOrigin.Begin);

                    //copy our content to the original stream and put it back
                    await buffer.CopyToAsync(stream);
                    context.Response.Body = stream;

                    Debug.WriteLine($"Response: {body}");
                }
            }
        }

        private string GenerateETag(byte[] data)
        {
            string ret = string.Empty;

            using (var md5 = MD5.Create())
            {
                var hash = md5.ComputeHash(data);
                string hex = BitConverter.ToString(hash);
                ret = hex.Replace("-", "");
            }

            return ret;
        }

        private static string GetKey(HttpRequest request)
        {
            return UriHelper.GetDisplayUrl(request);
        }
    }
}

Ответ 3

Основываясь на Eric 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);
        }
    }
}

Ответ 4

Здесь более обширная версия для 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);
        }
    }
}

Ответ 5

Я использую промежуточное программное обеспечение, которое отлично работает для меня.

Он добавляет заголовки HttpCache к ответам (Cache-Control, Expires, ETag, Last-Modified) и реализует модели истечения срока действия и валидации.

Вы можете найти его на nuget.org как пакет под названием Marvin.Cache.Headers.

Вы можете найти дополнительную информацию со своей домашней страницы Github: https://github.com/KevinDockx/HttpCacheHeaders

Ответ 7

В качестве добавления к Ответ Эрика Божича Я обнаружил, что объект HttpContext не возвращал StatusCode правильно при наследовании от ActionFilterAttribute и применял весь контроллер. HttpContext.Response.StatusCode всегда был 200, указывая, что он, вероятно, не был установлен этим пунктом в конвейере. Вместо этого я смог захватить StatusCode из ActionExecutedContext context.Result.StatusCode.