۱۳۹۱/۰۲/۰۱

ASP.NET MVC #19


مروري بر امكانات Caching اطلاعات در ASP.NET MVC

در برنامه‌هاي وب، بالاترين حد كارآيي برنامه‌ها از طريق بهينه سازي الگوريتم‌ها حاصل نمي‌شود، بلكه با بكارگيري امكانات Caching سبب خواهيم شد تا اصلا كدي اجرا نشود. در ASP.NET MVC اين هدف از طريق بكارگيري فيلتري به نام OutputCache ميسر مي‌گردد:

using System.Web.Mvc;

namespace MvcApplication16.Controllers
{
    public class HomeController : Controller
    {
        [OutputCache(Duration = 60, VaryByParam = "none")]
        public ActionResult Index()
        {
            return View();
        }
    }
}

همانطور كه ملاحظه مي‌كنيد، OutputCache را به يك اكشن متد يا حتي به يك كنترلر نيز مي‌توان اعمال كرد. به اين ترتيب HTML نهايي حاصل از View متناظر با اكشن متد جاري فراخواني شده، Cache خواهد شد. سپس زمانيكه درخواست بعدي به سرور ارسال مي‌شود، نتيجه دريافت شده، همان اطلاعات Cache شده قبلي است و عملا در سمت سرور كدي اجرا نخواهد شد. در اينجا توسط پارامتر Duration، مدت زمان معتبر بودن كش حاصل، برحسب ثانيه مشخص مي‌شود. VaryByParam مشخص مي‌كند كه اگر متدي پارامتري را دريافت مي‌كند، آيا بايد به ازاي هر مقدار دريافتي، مقادير كش شده متفاوتي ذخيره شوند يا خير. در اينجا چون متد Index پارامتري ندارد، از مقدار none استفاده شده است.


مثال يك
يك پروژه جديد خالي ASP.NET MVC را آغاز كنيد. سپس كنترلر جديد Home را نيز به آن اضافه نمائيد:

using System;
using System.Web.Mvc;

namespace MvcApplication16.Controllers
{
    public class HomeController : Controller
    {
        [OutputCache(Duration = 60, VaryByParam = "none")]
        public ActionResult Index()
        {
            ViewBag.ControllerTime = DateTime.Now;
            return View();
        }
    }
}

همچنين كدهاي View متد Index را نيز به نحو زير تغيير دهيد:

@{
    ViewBag.Title = "Index";
}

<h2>Index</h2>
<p>@ViewBag.ControllerTime</p>
<p>@DateTime.Now</p>

در اينجا نمايش دو زمان دريافتي از كنترلر و زمان محاسبه شده در View را مشاهده مي‌كنيد. هدف اين است كه بررسي كنيم آيا فيلتر OutputCache بر روي اين دو مقدار تاثيري دارد يا خير.
برنامه را اجرا نمائيد. سپس چند بار صفحه را Refresh كنيد. مشاهده خواهيد كرد كه هر دو زمان ياد شده تا 60 ثانيه، تغييري نخواهند كرد و حاصل نهايي از Cache خواهنده مي‌شود.
كاربرد يك چنين حالتي براي مثال نمايش اطلاعات بازديدهاي يك سايت است. نبايد به ازاي هر كاربر وارد شده به سايت، يكبار به بانك اطلاعاتي مراجعه كرد و آمار جديدي را تهيه نمود. يا براي نمونه اگر جايي قرار است اطلاعات وضعيت آب و هوا نمايش داده شود، بهتر است اين اطلاعات، مثلا هر نيم ساعت يكبار به روز شود و نه به ازاي هر بازديد جديد از سايت، توسط صدها بازديد كننده همزمان. يا براي مثال كش كردن خروجي فيد RSS يك بلاگ به مدت چند ساعت نيز ايده خوبي است. از اين لحاظ كه اگر اطلاعات بلاگ شما روزي يكبار به روز مي‌شود، نيازي نيست تا به ازاي هر برنامه فيدخوان، يكبار اطلاعات از بانك اطلاعاتي دريافت شده و پروسه رندر نهايي فيد صورت گيرد. منوهاي پوياي يك سايت نيز در همين رده قرار مي‌گيرند. دريافت اطلاعات منوهاي پوياي سايت به ازاي هر درخواست رسيده كاربري جديد، كار اشتباهي است. اين اطلاعات نيز بايد كش شوند تا بار سرور كاهش يابد. البته تمام اين‌ها زماني ميسر خواهند شد كه اطلاعات سمت سرور كش شوند.


مثال دو
همان مثال قبلي را در اينجا جهت بررسي پارامتر VaryByParam به نحو زير تغيير مي‌دهيم:

using System;
using System.Web.Mvc;

namespace MvcApplication16.Controllers
{
    public class HomeController : Controller
    {
        [OutputCache(Duration = 60, VaryByParam = "none")]
        public ActionResult Index(string parameter)
        {
            ViewBag.Msg = parameter ?? string.Empty;
            ViewBag.ControllerTime = DateTime.Now;
            return View();
        }
    }
}


در اينجا يك پارامتر به متد Index اضافه شده است. مقدار آن به ViewBag.Msg انتساب داده شده و سپس در View ، در بين تگ‌هاي h2 نمايش داده خواهد شد. همچنين يك فرم ساده هم جهت ارسال parameter به متد Index اضافه شده است:

@{
    ViewBag.Title = "Index";
}

<h2>@ViewBag.Msg</h2>

<p>@ViewBag.ControllerTime</p>
<p>@DateTime.Now</p>

@using (Html.BeginForm())
{
    @Html.TextBox("parameter")
    <input type="submit" />
}

اكنون برنامه را اجرا كنيد. در TextBox نمايش داده شده يكبار مثلا بنويسيد Test1 و فرم را به سرور ارسال نمائيد. سپس مقدار Test2 را وارد كرده و ارسال نمائيد. در بار دوم، خروجي صفحه همانند زماني است كه مقدار Test1 ارسال شده است. علت اين است كه مقدار VaryByParam به none تنظيم شده است و صرفنظر از ورودي كاربر، همان اطلاعات كش شده قبلي بازگشت داده خواهد شد. براي رفع اين مشكل، متد Index را به نحو زير تغيير دهيد، به طوريكه مقدار VaryByParam به نام پارامتر متد جاري اشاره كند:

[OutputCache(Duration = 60, VaryByParam = "parameter")]
public ActionResult Index(string parameter)

در ادامه مجددا برنامه را اجرا كنيد. اكنون يكبار مقدار Test1 را به سرور ارسال كنيد. سپس مقدار Test2 را ارسال نمائيد. مجددا همين دو مرحله را با مقادير Test1 و Test2 تكرار كنيد. مشاهده خواهيد كرد كه اينبار اطلاعات بر اساس مقدار پارامتر ارسالي كش شده است.



تنظيمات متفاوت OutputCache

الف) VaryByParam : اگر مساوي none قرار گيرد، همواره همان مقدار كش شده قبلي نمايش داده مي‌شود. اگر مقدار آن به نام پارامتر خاصي تنظيم شود، اطلاعات كش شده بر اساس مقادير متفاوت پارامتر دريافتي، متفاوت خواهند بود. در اينجا پارامترهاي متفاوت را با يك «,» مي‌توان از هم جدا ساخت. اگر تعداد پارامترها زياد است مي‌توان مقدار VaryByParam را مساوي با * قرار داد. در اين حالت به ازاي مقادير متفاوت دريافتي پارامترهاي مختلف، اطلاعات مجزايي در كش قرار خواهد گرفت. اين روش آخر آنچنان توصيه نمي‌شود چون سربار بالايي دارد و حجم بالايي از اطلاعات بر اساس پارامترهاي مختلف، بايد در كش قرار گيرند.
ب) Location : مكان قرارگيري اطلاعات كش شده را مشخص مي‌كند. مقدار آن نيز بر اساس يك enum به نام OutputCacheLocation مشخص مي‌گردد. در اين حالت براي مثال مي‌توان مكان‌هاي Server، Client و ServerAndClient را مقدار دهي نمود. مقدار Downstream به معناي كش شدن اطلاعات بر روي پروكسي سرورهاي بين راه و يا مرورگرها است. پيش فرض آن Any است كه تركيبي از Server و Downstream مي‌باشد.
اگر قرار است اطلاعات يكساني به تمام كاربران نمايش داده شود، مثلا محتواي ليست يك منوي پويا،‌ محل قرارگيري اطلاعات كش بايد سمت سرور باشد. اگر نياز است به ازاي هر كاربر محتواي اطلاعات كش شده متفاوت باشد، بهتر است محل سمت كلاينت را مقدار دهي نمود.
ج) VaryByHeader : اطلاعات، بر اساس هدرهاي مشخص شده، كش مي‌شوند. براي مثال مرسوم است كه از Accept-Language در اينجا استفاده شود تا اطلاعات مثلا فرانسوي كش شده، به كاربر آلماني تحويل داده نشود.
د) VaryByCustom :‌ در اين حالت نام يك متد استاتيك تعريف شده در فايل global.asax.cs بايد مشخص گردد. توسط اين متد كليد رشته‌اي اطلاعاتي كه قرار است كش شود، بازگشت داده خواهد شد.
ه) SqlDependency : در اين حالت اطلاعات تا زمانيكه تغييري در جداول بانك اطلاعاتي SQL Server صورت نگيرد، كش خواهد شد.
و) Nostore : به پروكسي سرورهاي بين راه و همچنين مرورگرها اطلاع مي‌دهد كه اطلاعات را نبايد كش كنند. اگر قسمت اعتبار سنجي اين سري را به خاطر داشته باشيد، چنين تعريفي در قسمت Remote validation بكارگرفته شد:

[OutputCache(Location = OutputCacheLocation.None, NoStore = true)]  

و يا مي‌توان براي اينكار يك فيلتر سفارشي را نيز تهيه كرد:

using System;
using System.Web.Mvc;

namespace MvcApplication16.Helper
{
    /// <summary>
    /// Adds "Cache-Control: private, max-age=0" header,
    /// ensuring that the responses are not cached by the user's browser. 
    /// </summary>
    public class NoCachingAttribute : ActionFilterAttribute
    {
        public override void OnActionExecuted(ActionExecutedContext filterContext)
        {
            base.OnActionExecuted(filterContext);
            filterContext.HttpContext.Response.CacheControl = "private";
            filterContext.HttpContext.Response.Cache.SetMaxAge(TimeSpan.FromSeconds(0));
        }
    }
}

كار اين فيلتر اضافه كردن هدر «Cache-Control: private, max-age=0» به Response است.


استفاده از فايل Web.Config براي معرفي تنظيمات Caching

يكي ديگر از تنظيمات ويژگي OutputCache، پارامتر CacheProfile است كه امكان تنظيم آن در فايل web.config نيز وجود دارد. براي نمونه تنظيمات زير را به قسمت system.web فايل وب كانفيگ برنامه اضافه كنيد:


<system.web>
    <caching>
      <outputCacheSettings>
        <outputCacheProfiles>
          <add name="Aggressive" location="ServerAndClient" duration="300"/>
          <add name="Mild" duration="100"  location="Server" />
        </outputCacheProfiles>
      </outputCacheSettings>
    </caching>

سپس مثلا براي استفاده از پروفايلي به نام Aggressive، خواهيم داشت:

[OutputCache(CacheProfile = "Aggressive", VaryByParam = "parameter")]
public ActionResult Index(string parameter)


استفاده از ويژگي به نام donut caching

تا اينجا به اين نتيجه رسيديم كه OutputCache، كل خروجي يك View را بر اساس پارامترهاي مختلفي كه دريافت مي‌كند، كش خواهد كرد. در اين بين اگر بخواهيم تنها قسمت كوچكي از صفحه كش نشود چه بايد كرد؟ براي حل اين مشكل قابليتي به نام cache substitution كه به donut caching هم معروف است (چون آن‌را مي‌توان به شكل يك donut تصور كرد!) در ASP.NET MVC قابل استفاده است.

@{ Response.WriteSubstitution(ctx => DateTime.Now.ToShortTimeString()); }

همانطور كه ملاحظه مي‌كنيد براي تعريف يك چنين اطلاعاتي بايد از متد Response.WriteSubstitution در يك view استفاده كرد. در اين مثال، نمايش زمان جاري معرفي شده، صرف نظر از وضعيت كش صفحه جاري، كش نخواهد شد.

عكس آن هم ممكن است. فرض كنيد كه صفحه جاري شما از سه partial view تشكيل شده است. هر كدام از اين partial viewها نيز مزين به OutpuCache هستند. اما صفحه اصلي درج كننده اطلاعات اين سه partial view فاقد ويژگي Output كش است. در اين حالت تنها اطلاعات اين partial viewها كش خواهند شد و ساير قسمت‌هاي صفحه با هر بار درخواست از سرور، مجددا بر اساس اطلاعات جديد به روز خواهند شد. حالت توصيه شده نيز همين مورد است و متد Response.WriteSubstitution را صرفا جهت اطلاعات عمومي درنظر داشته باشيد.


استفاده از امكانات Data Caching به صورت مستقيم

مطالبي كه تا اينجا عنوان شدند به كش كردن اطلاعات Response اختصاص داشتند. اما امكانات Caching موجود، به اين مورد خلاصه نشده و مي‌توان اطلاعات و اشياء را نيز كش كرد. براي مثال اطلاعات «با سطح دسترسي عمومي» دريافتي از بانك اطلاعاتي توسط يك كوئري را نيز مي‌توان كش كرد. جهت انجام اينكار مي‌توان از متدهاي HttpRuntime.Cache.Insert و يا HttpContext.Cache.Insert استفاده كرد. استفاده از HttpContext.Cache.Insert حين نوشتن Unit tests دردسر كمتري دارد و mocking آن ساده است؛ از اين جهت كه بر اساس HttpContextBase تعريف شده‌است.
در ادامه يك كلاس كمكي نوشتن اطلاعات در cache و سپس بازيابي آن‌را ملاحظه مي‌كنيد:

using System;
using System.Web;
using System.Web.Caching;

namespace MvcApplication16.Helper
{
    public static class CacheManager
    {
        public static void CacheInsert(this HttpContextBase httpContext, string key, object data, int durationMinutes)
        {
            if (data == null) return;
            httpContext.Cache.Add(
                key,
                data,
                null,
                DateTime.Now.AddMinutes(durationMinutes),
                TimeSpan.Zero,
                CacheItemPriority.AboveNormal,
                null);
        }

        public static T CacheRead<T>(this HttpContextBase httpContext, string key)
        {
            var data = httpContext.Cache[key];
            if (data != null)
                return (T)data;
            return default(T);
        }

        public static void InvalidateCache(this HttpContextBase httpContext, string key)
        {
            httpContext.Cache.Remove(key);
        }
    }
}

و براي استفاده از آن در يك اكشن متد، ابتدا نياز است فضاي نام اين كلاس تعريف شود و سپس براي نمونه متد HttpContext.CacheInsert در دسترس خواهد بود. HttpContext يكي از خواص تعريف شده در شيء كنترلر است كه با ارث بري كنترلرها از آن، همواره در دسترس مي‌باشد.
در اينجا براي نمونه اطلاعات يك ليست جنريك دريافتي از بانك اطلاعاتي را مثلا 10 دقيقه (بسته به پارامتر durationMinutes آن) مي‌توان كش كرد و سپس توسط متد CacheRead آن‌را دريافت نمود. اگر متد CacheRead نال برگرداند به معناي خالي بودن كش است. بنابراين يكبار اطلاعات را از بانك اطلاعاتي دريافت نموده و سپس آن‌را كش خواهيم كرديم.
البته هستند ORMهايي كه يك چنين كارهايي را به صورت توكار پشتيباني كنند. به مكانيزم آن، Second level cache هم گفته مي‌شود؛ به علاوه امكان استفاده از پروايدرهاي ديگري را بجز كش IIS براي ذخيره سازي موقتي اطلاعات نيز فراهم مي‌كنند.
همچنين بايد دقت داشت اين اعداد مدت زمان، هيچگونه ضمانتي ندارند. اگر IIS احساس كند كه با كمبود منابع مواجه شده است، به سادگي شروع به حذف اطلاعات موجود در كش خواهد كرد.


نكته امنيتي مهم!
به هيچ عنوان از OutputCache در صفحاتي كه نياز به اعتبار سنجي دارند، استفاده نكنيد و به همين جهت در قسمت كش كردن اطلاعات، بر روي «اطلاعاتي با سطح دسترسي عمومي» تاكيد شد.
فرض كنيد كارمندي به صفحه مشاهده فيش حقوقي خودش مراجعه كرده است. اين ماه هم اضافه حقوق آنچناني داشته است. شما هم اين صفحه را به مدت سه ساعت كش كرده‌ايد. آيا مي‌توانيد تصور كنيد اگر همين گزارش كش شده با اين اطلاعات، به ساير كارمندان نمايش داده شود چه قشقرقي به پا خواهد شد؟!
بنابراين هيچگاه اطلاعات مخصوص به يك كاربر اعتبار سنجي شده را كش نكنيد و «تنها» اطلاعاتي نياز به كش شدن دارند كه عمومي باشند. براي مثال ليست آخرين اخبار سايت؛ ليست آخرين مدخل‌هاي فيد RSS سايت؛ ليست اطلاعات منوي عمومي سايت؛ ليست تعداد كاربران مراجعه كننده به سايت در طول يك روز؛ گزارش آب و هوا و كليه اطلاعاتي با سطح دسترسي عمومي كه كش شدن آن‌ها مشكل ساز نباشد.
به صورت خلاصه هيچگاه در كدهاي شما چنين تعريفي نبايد مشاهده شود:
[Authorize]
[OutputCache(Duration = 60)]
public ActionResult Index()