在ASP.NET MVC中创建ETag过滤器

30

我想在MVC中创建一个ETag过滤器。

问题是,我无法控制Response.OutputStream,如果能够控制,那么我将根据结果流计算ETag。

我之前在WCF中做过这件事,但找不到在MVC中实现的简单方法。

我想能够编写类似于以下的代码:

[ETag]
public ActionResult MyAction()
{
    var myModel = Factory.CreateModel();
    return View(myModel);
}

有任何想法吗?


1
你的意思是你希望ETag代表实际的流内容,例如流内容的哈希值吗? - Rob Levine
1
是的,我想根据模型使用ETag缓存。当我不知道模型类型时 - 我只希望能够将ETag添加到任何结果中。 - anativ
1
@anativ - 你应该避免使用OutputStream来生成ETag。这是因为每次都需要重新生成响应,即使没有改变。一个更有效的方法是哈希用于构造输出流的所有参数,并将其用作ETag值。这样可以避免昂贵的处理,如果客户端已经有一份内容的副本。 - Andrew Theken
@Andrew Theken - 这意味着,如果您的内容不改变,那么给定输入参数。但是ETag的目的正是为了知道自客户端请求以来内容是否未更改。如果您这样做,那么您完全错过了首次使用ETag的重点:您只需要一个永远不会过期的客户端缓存。个人见解。 - Alberto Chiesa
4个回答

27

这是我能想到的最好方案,但我并不真正理解你所说的不能控制Response.OutputStream的含义。

using System;
using System.IO;
using System.Security.Cryptography;
using System.Web.Mvc;

public class ETagAttribute : ActionFilterAttribute
{
    private string GetToken(Stream stream) {
        MD5 md5 = MD5.Create();
        byte [] checksum = md5.ComputeHash(stream);
        return Convert.ToBase64String(checksum, 0, checksum.Length);
    }

    public override void OnResultExecuted(ResultExecutedContext filterContext)
    {
        filterContext.HttpContext.Response.AppendHeader("ETag", GetToken(filterContext.HttpContext.Response.OutputStream));
        base.OnResultExecuted(filterContext);
    }
}

这应该可以工作,但实际上并没有。

显然,Microsoft覆盖了System.Web.HttpResponseStream.Read(Byte[] buffer, Int32 offset, Int32 count)方法,以便它返回“指定的方法不受支持”。不确定为什么他们会这样做,因为它继承自System.IO.Stream基类...

这是以下资源的混合体,Response.OutputStream是一个只写流,所以我们必须使用Response.Filter类来读取输出流,有点奇怪,你必须在过滤器上使用一个过滤器,但它可以工作 =)

http://bytes.com/topic/c-sharp/answers/494721-md5-encryption-question-communication-java
http://www.codeproject.com/KB/files/Calculating_MD5_Checksum.aspx
http://blog.gregbrant.com/post/Adding-Custom-HTTP-Headers-to-an-ASPNET-MVC-Response.aspx
http://www.infoq.com/articles/etags
http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html

更新

经过长时间的奋斗,我终于成功让它工作了:

using System;
using System.IO;
using System.Security.Cryptography;
using System.Web;
using System.Web.Mvc;

public class ETagAttribute : ActionFilterAttribute {
    public override void OnActionExecuting(ActionExecutingContext filterContext) {
        try {
            filterContext.HttpContext.Response.Filter = new ETagFilter(filterContext.HttpContext.Response);
        } catch (System.Exception) {
            // Do Nothing
        };
    }
}

public class ETagFilter : MemoryStream {
    private HttpResponseBase o = null;
    private Stream filter = null;

    public ETagFilter (HttpResponseBase response) {
        o = response;
        filter = response.Filter;
    }

    private string GetToken(Stream stream) {
        byte[] checksum = new byte[0];
        checksum = MD5.Create().ComputeHash(stream);
        return Convert.ToBase64String(checksum, 0, checksum.Length);
    }

    public override void Write(byte[] buffer, int offset, int count) {
        byte[] data = new byte[count];
        Buffer.BlockCopy(buffer, offset, data, 0, count);
        filter.Write(data, 0, count);
        o.AddHeader("ETag", GetToken(new MemoryStream(data)));
    }
}

更多资源:

http://authors.aspalliance.com/aspxtreme/sys/Web/HttpResponseClassFilter.aspx
http://forums.asp.net/t/1380989.aspx/1


我不确定这个链接是否真的有帮助。原帖提出了一个关于如何从模型类的内容计算ETag值的具体问题,这可能与链接中的文件依赖方法无关。根据我的理解,原帖要么想从任意模型对象计算ETag值,要么想从OutputStream本身计算。在任一情况下,这个建议似乎并没有提供任何帮助。 - Rob Levine
@anativ,我不确定如何使用Model类来完成这个任务,但我已经找到了一种使用Response.OutputStream的方法。 - ThatGuyInIT
1
注意。这可能看起来是有效的,但它并不适用于每种情况。这仅在响应在单个写入块内完成时才有效。当文件很大时,情况并非如此,因为流将在多个块中缓冲写入。也就是说,AddHeader和GetToken将被调用多次。 - Patrick Desjardins

14

非常感谢,这正是我在寻找的。我对ETagFilter进行了一点小修复,可以处理304状态码,以防内容没有更改。

public class ETagAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        filterContext.HttpContext.Response.Filter = new ETagFilter(filterContext.HttpContext.Response, filterContext.RequestContext.HttpContext.Request);
    }
}

public class ETagFilter : MemoryStream
{
    private HttpResponseBase _response = null;
    private HttpRequestBase _request;
    private Stream _filter = null;

    public ETagFilter(HttpResponseBase response, HttpRequestBase request)
    {
        _response = response;
        _request = request;
        _filter = response.Filter;
    }

    private string GetToken(Stream stream)
    {
        byte[] checksum = new byte[0];
        checksum = MD5.Create().ComputeHash(stream);
        return Convert.ToBase64String(checksum, 0, checksum.Length);
    }

    public override void Write(byte[] buffer, int offset, int count)
    {
        byte[] data = new byte[count];
        Buffer.BlockCopy(buffer, offset, data, 0, count);
        var token = GetToken(new MemoryStream(data));

        string clientToken = _request.Headers["If-None-Match"];

        if (token != clientToken)
        {
            _response.Headers["ETag"] = token;
            _filter.Write(data, 0, count);
        }
        else
        {
            _response.SuppressContent = true;
            _response.StatusCode = 304;
            _response.StatusDescription = "Not Modified";
            _response.Headers["Content-Length"] = "0";
        }
    }
}

1
这段代码存在问题:多次调用“Write”方法,响应ETag非常长。 - Fan TianYi
由于MD5校验和的存在,这段程序不会很长。但是它将被多次调用,这可能会影响性能。 - Tuan

3
有几个很有前途的答案,但没有一个是完美的解决方案。另外,在问题中也没有提到它。但是应该使用ETag进行缓存验证。因此,它应该与Cache-Control头一起使用。这样,客户端甚至不必在缓存过期之前调用服务器(它可以是非常短的时间,取决于资源)。当缓存过期时,客户端会使用ETag进行请求并对其进行验证。有关缓存的更多详细信息,请参阅此文章
这是我的CacheControl属性解决方案与ETags。它可以改进,例如启用公共缓存等... 然而,我强烈建议您了解缓存并仔细修改它。如果您使用HTTPS并且终点是安全的,则此设置应该没有问题。
/// <summary>
/// Enables HTTP Response CacheControl management with ETag values.
/// </summary>
public class ClientCacheWithEtagAttribute : ActionFilterAttribute
{
    private readonly TimeSpan _clientCache;

    private readonly HttpMethod[] _supportedRequestMethods = {
        HttpMethod.Get,
        HttpMethod.Head
    };

    /// <summary>
    /// Default constructor
    /// </summary>
    /// <param name="clientCacheInSeconds">Indicates for how long the client should cache the response. The value is in seconds</param>
    public ClientCacheWithEtagAttribute(int clientCacheInSeconds)
    {
        _clientCache = TimeSpan.FromSeconds(clientCacheInSeconds);
    }

    public override async Task OnActionExecutedAsync(HttpActionExecutedContext actionExecutedContext, CancellationToken cancellationToken)
    {
        if (!_supportedRequestMethods.Contains(actionExecutedContext.Request.Method))
        {
            return;
        }
        if (actionExecutedContext.Response?.Content == null)
        {
            return;
        }

        var body = await actionExecutedContext.Response.Content.ReadAsStringAsync();
        if (body == null)
        {
            return;
        }

        var computedEntityTag = GetETag(Encoding.UTF8.GetBytes(body));

        if (actionExecutedContext.Request.Headers.IfNoneMatch.Any()
            && actionExecutedContext.Request.Headers.IfNoneMatch.First().Tag.Trim('"').Equals(computedEntityTag, StringComparison.InvariantCultureIgnoreCase))
        {
            actionExecutedContext.Response.StatusCode = HttpStatusCode.NotModified;
            actionExecutedContext.Response.Content = null;
        }

        var cacheControlHeader = new CacheControlHeaderValue
        {
            Private = true,
            MaxAge = _clientCache
        };

        actionExecutedContext.Response.Headers.ETag = new EntityTagHeaderValue($"\"{computedEntityTag}\"", false);
        actionExecutedContext.Response.Headers.CacheControl = cacheControlHeader;
    }

    private static string GetETag(byte[] contentBytes)
    {
        using (var md5 = MD5.Create())
        {
            var hash = md5.ComputeHash(contentBytes);
            string hex = BitConverter.ToString(hash);
            return hex.Replace("-", "");
        }
    }
}

例如:使用1分钟客户端缓存:

[ClientCacheWithEtag(60)]

1
这是我为解决此问题创建的代码 - 我继承自gzip,因为我还想压缩流(您始终可以使用常规流)。不同之处在于,我计算所有响应的ETag,而不仅仅是其中的一部分。
public class ETagFilter : GZipStream
{
    private readonly HttpResponseBase m_Response;
    private readonly HttpRequestBase m_Request;
    private readonly MD5 m_Md5;
    private bool m_FinalBlock;



    public ETagFilter(HttpResponseBase response, HttpRequestBase request)
        : base(response.Filter, CompressionMode.Compress)
    {
        m_Response = response;
        m_Request = request;
        m_Md5 = MD5.Create();
    }

    protected override void Dispose(bool disposing)
    {
        m_Md5.Dispose();
        base.Dispose(disposing);
    }

    private string ByteArrayToString(byte[] arrInput)
    {
        var output = new StringBuilder(arrInput.Length);
        for (var i = 0; i < arrInput.Length; i++)
        {
            output.Append(arrInput[i].ToString("X2"));
        }
        return output.ToString();
    }

    public override void Write(byte[] buffer, int offset, int count)
    {
        m_Md5.TransformBlock(buffer, 0, buffer.Length, null, 0);
        base.Write(buffer, 0, buffer.Length);
    }

    public override void Flush()
    {
        if (m_FinalBlock)
        {
            base.Flush();
            return;
        }
        m_FinalBlock = true;
        m_Md5.TransformFinalBlock(new byte[0], 0, 0);
        var token = ByteArrayToString(m_Md5.Hash);
        string clientToken = m_Request.Headers["If-None-Match"];

        if (token != clientToken)
        {
            m_Response.Headers["ETag"] = token;
        }
        else
        {
            m_Response.SuppressContent = true;
            m_Response.StatusCode = 304;
            m_Response.StatusDescription = "Not Modified";
            m_Response.Headers["Content-Length"] = "0";
        }
        base.Flush();
    }
}

这个解决方案看起来很有前途,但我无法让它工作(响应中只得到了乱码)。你能发 Attribute 的完整代码吗? - TNT
@TNT 注意,我继承自gzipStream。你可能没有压缩你的内容或者没有传递响应被压缩的头信息。 - Ram Y

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