Создать ETag-фильтр в ASP.NET MVC

Я хотел бы создать фильтр ETag в MVC. Проблема в том, что я не могу управлять Response.OutputStream, если бы я мог это сделать, я бы просто рассчитал ETag в соответствии с потоком результатов. Я делал это раньше в WCF, но не мог найти простой идеи сделать это в MVC.

Я хочу быть в состоянии написать что-то подобное

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

Любая идея?

4 ответа

Решение

Большое спасибо, это именно то, что я искал. Просто сделал небольшое исправление для 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";
        }
    }
}

Это лучшее, что я мог придумать, я не совсем понял, что вы имели в виду, что не можете контролировать 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[], смещение Int32, счетчик Int32), так что она возвращает "Указанный метод не поддерживается". Не уверен, почему они это сделают, поскольку он наследуется для системы. Базовый класс 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 должен использоваться для проверки кэша. Поэтому его следует использовать с заголовком Cache-Control. Таким образом, клиентам даже не нужно вызывать сервер, пока не истечет срок действия кэша (это может быть очень короткий период времени, зависит от вашего ресурса). Когда срок действия кэша истек, клиент делает запрос с помощью ETag и проверяет его. Для более подробной информации о кешировании смотрите эту статью.

Вот мое решение для атрибута CacheControl с ETags. Его можно улучшить, например, с включенным Public cache и т. Д. Однако я настоятельно рекомендую вам разобраться в кешировании и тщательно его изменить. Если вы используете 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)]

Это код, который я создал, чтобы решить эту проблему - я наследую от 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();
    }
}
Другие вопросы по тегам