Внедрить HTTP-кеш (ETag) в ASP.NET Core Web API
Я работаю над приложением ASP.NET Core (ASP.NET 5) Web API и должен реализовать HTTP-кэширование с помощью тегов сущностей. Ранее я использовал CacheCow для того же, но, похоже, он не поддерживает ASP.NET Core на данный момент. Я также не нашел других релевантных библиотек или подробностей поддержки фреймворка для этого.
Я могу написать собственный код для того же самого, но перед этим я хочу увидеть, если что-нибудь уже доступно. Пожалуйста, поделитесь, если что-то уже доступно и как лучше это реализовать.
Ответы
Ответ 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
Ответ 6
Я нашел альтернативное решение, которое "ближе" к методу контроллера web-api - так что вы можете решить, какой метод ETag установить...
Смотрите мой ответ здесь: Как использовать ETag в веб-API с помощью фильтра действий вместе с HttpResponseMessage
Ответ 7
В качестве добавления к Ответ Эрика Божича Я обнаружил, что объект HttpContext не возвращал StatusCode правильно при наследовании от ActionFilterAttribute и применял весь контроллер. HttpContext.Response.StatusCode всегда был 200, указывая, что он, вероятно, не был установлен этим пунктом в конвейере. Вместо этого я смог захватить StatusCode из ActionExecutedContext context.Result.StatusCode.