我正在开发ASP.NET Core(ASP.NET 5)Web API应用程序,并必须使用实体标记实现HTTP缓存。之前我使用了CacheCow ,但目前它似乎不支持ASP.NET Core。我也没有找到任何其他相关库或框架的支持细节。
如果已经有相关可用的东西,请告诉我,最好的方式是什么。否则我会为此编写自定义代码。
我正在开发ASP.NET Core(ASP.NET 5)Web API应用程序,并必须使用实体标记实现HTTP缓存。之前我使用了CacheCow ,但目前它似乎不支持ASP.NET Core。我也没有找到任何其他相关库或框架的支持细节。
如果已经有相关可用的东西,请告诉我,最好的方式是什么。否则我会为此编写自定义代码。
经过一段时间尝试使用中间件,我发现 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()
{
}
在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 Desaiusing 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);
}
}
}
我找到了一种“更接近”Web API控制器方法的替代解决方案 - 因此您可以根据每种方法决定设置哪个ETag...
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());
以下是一个更为强大和可靠的实现方式,它作为结果过滤器而不是操作过滤器。
这种解决方案相对于已接受答案的优点如下:
NotFound()
,context.HttpContext.Response.StatusCode
的值在OnActionExecuted
方法中始终为200。请参见这个相同问题的答案,以及这个问题和这个问题。If-Modified-Since
和If-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)
{
}