ASP.NET MVC "Donut caching" и TempData

Saturday, 28 March 09, 15:16 Z

Что делать если в ASP.NET MVC, в странице, находящейся в кеше, необходимо выделить несколько динамически генерируемых областей? Необходимо использовать Post-Cache Substitution ("Donut caching").

Согласно http://weblogs.asp.net/scottgu/archive/2006/11/28/tip-trick-implement-donut-caching-with-the-asp-net-2-0-output-cache-substitution-feature.aspx можно сделать так:

<% Response.WriteSubstitution(context => DateTime.Now.ToString()); %>

Или согласно http://haacked.com/archive/2008/11/05/donut-caching-in-asp.net-mvc.aspx, что более логично в ASP.NET MVC, так:

    public delegate string MvcCacheCallback(HttpContextBase context);

    public static object Substitute(this HtmlHelper html, MvcCacheCallback cb) {
        html.ViewContext.HttpContext.Response.WriteSubstitution(
            c => HttpUtility.HtmlEncode(
                cb(new HttpContextWrapper(c))
            ));
        return null;
    }
<%= Html.Substitute(c => DateTime.Now.ToString()) %>

Идем далее. Есть ASP.NET MVC приложение, кэшированные страницы которого просматриваются пользователями. На странице есть область для коротких сообщений, порядок появления которых в общем случае случайный (сообщение хранится в TempData). Следующий код не будет работать правильно:

<%= Html.Substitute(c => TempData["message"].ToString()) %>

Согласно MSDN этот код отработает правильно один раз во время кэширования страницы. При первом вызове делегата с => TempData["message"].ToString() еще доступен экземпляр класса страницы, в контексте которой он выполняется. При последующих запросах этой страницы, жизненный цикл запроса заканчивается на событии HttpApplication.ResolveRequestCache без создания экземпляра класса HttpSessionState и без назначения конкретного IHttpHandler текущему запросу.

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

В случае с нашим приложением это значит что мы не можем "просто так" обратиться к TempData во время Post-cache Substitution.

Решением данной проблемы является создание класса CustomTempDataProvider, наследующего SessionStateTempDataProvider и переопределяющего метод SaveTempData для сохранения TempData в Cache. Идея заключается в том, что Cache доступен во время жизненного цикла запроса и без создания конкретного IHttpHandler. Так как каждый конкретный экземпляр TempData сохраняется только между двумя последовательными запросами, и при каждом новом запросе создается вновь, то необходимо убирать HttpData из Cache при каждом запросе. Еще один нюанс: все перечисленные манипуляции с TempData необходимо выполнять с учетом ID текущей сессии.

Класс CustomTempDataProvider унаследован от SessionStateTempDataProvider и реализует интерфейс IHttpModule:

    using System;
    using System.Collections.Generic;
    using System.Web;
    using System.Web.Caching;
    using System.Web.Mvc;

    public class CustomTempDataProvider : SessionStateTempDataProvider, IHttpModule
    {
        public void Init(HttpApplication application)
        {
            application.BeginRequest += new EventHandler(application_BeginRequest);
        }

        void application_BeginRequest(object sender, EventArgs e)
        {
            var httpContext = HttpContext.Current;

            var tempData = httpContext.Cache[TempDataKey]
                           ?? new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);

            httpContext.Items.Add("TempData", tempData);

            httpContext.Cache.Remove(TempDataKey);
        }

        public override void SaveTempData(ControllerContext controllerContext,
            IDictionary<string, object> values)
        {
            HttpContext.Current.Cache.Insert(TempDataKey, values, null, DateTime.Now.AddMinutes(5),
                Cache.NoSlidingExpiration, CacheItemPriority.NotRemovable, null);

            base.SaveTempData(controllerContext, values);
        }

        public static string TempDataKey
        {
            get
            {
                string sessionID = "0";

                var httpContext = HttpContext.Current;

                if(httpContext.Session != null)
                {
                    sessionID = httpContext.Session.SessionID;
                }
                else if (httpContext.Request.Cookies["ASP.NET_SessionId"] != null)
                {
                    sessionID = httpContext.Request.Cookies["ASP.NET_SessionId"].Value;
                }

                return "TempData-For-Session-" + sessionID;
            }
        }

        public void Dispose()
        {
        }
    }

Класс CustomTempDataProvider небходимо зарегистрировать как модуль в web.config и как TempDataProvider при создании конкретного котроллера.

В web.config добавляем:

    <?xml version="1.0" encoding="UTF-8"?>
    <configuration>
        <system.web>
            <httpModules>
                <add name="CustomTempDataProvider" type="CustomTempDataProvider" />
            </httpModules>
        </system.web>
        <system.webServer>
            <modules>
                <remove name="CustomTempDataProvider" />
                <add name="CustomTempDataProvider" type="CustomTempDataProvider" />
            </modules>
        </system.webServer>
    </configuration>

Следующий класс:

    using System.Web.Routing;
    using System.Web.Mvc;

    public class CustomControllerFactory : DefaultControllerFactory
    {
        public override IController CreateController(
            RequestContext requestContext, string controllerName)
        {
            var controller = (Controller)base.CreateController(requestContext, controllerName);

            controller.TempDataProvider = new CustomTempDataProvider();

            return controller;
        }
    }

регистрируем в Global.asax:

    protected void Application_Start()
    {
        RegisterRoutes(RouteTable.Routes);

        ControllerBuilder.Current.SetControllerFactory(typeof(CustomControllerFactory));
    }

Осталось определить статический класс для доступа к TempData:

    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Web;

    public static class CustomTempData
    {
        public static object Get(string key)
        {
            var tempData = HttpContext.Current.Items["TempData"] as IDictionary<string, object>

            var item = tempData.FirstOrDefault(x => x.Key == key).Value ?? String.Empty;

            return item;
        }
    }

и helper для Post-Cache Substitution:

    using System;
    using System.Web;
    using System.Web.Mvc;

    public static class Html
    {
        public delegate object MvcResponseSubstitutionCallback(HttpContextBase context);

        public static object MvcResponseSubstitute(this HtmlHelper html,
            MvcResponseSubstitutionCallback callback)
        {
            html.ViewContext.HttpContext.Response.WriteSubstitution(
                context => HttpUtility.HtmlEncode(
                    (callback(new HttpContextWrapper(context)) ?? String.Empty).ToString()
                )
            );

            return null;
        }
    }

Теперь следующий код успешно работает:

<h3><%= Html.MvcResponseSubstitute(context => CustomTempData.Get("message")) %></h3>