کش خروجی HTML در ASP.NET Core با Redis

در برنامه های قبلی MVC ویژگی به نام OutputCache  در اختیار داشتیم که می توانستیم خروجی HTML تولید شده یک اکشن متد را به صورت لوکال کش کرده و کارآیی برنامه را افزایش دهیم.

در ASP.NET Core ویژگی با عنوان ResponseCache در اختیار داریم که با استفاده از آن صرفا می توانیم هدر Cache-Control را جهت کش کردن محتوا سمت پروکسی سرورهای واسط تنظیم کنیم. اما خبری از کش لوکال نیست. بنابراین باید خودمان دست به کار شده و آن را پیاده سازی کنیم.

در این مطلب ما از بانک اطلاعاتی NoSql مقیم در حافظه به نام Redis  که ساختار آن به شکل کلید/مقدار می باشد استفاده می کنیم. Redis در حال حاضر نسخه رسمی مخصوص ویندوز ندارد هرچند روش های برای نصب آن وجود دارد (redis-windows) اما شما برای شروع می توانید یک حساب رایگان در سایت RedisLab ایجاد کرده و مقدار 30Mb رم دریافت کنید که برای کار ما کافی می باشد.

راهکار ما ترکیبی از یک Middleware و به دنبال آن یک Attribute خواهد بود، برای شروع یک پروژه جدید ASP.NET Core Web Application ایجاد نمایید و بسته های زیر را نصب کنید:

PM> Install-Package Microsoft.Extensions.Caching.Memory -Version 2.0.0 
PM> Install-Package Microsoft.Extensions.Caching.Redis -Version 2.0.0

قبل از اینکه کدنویسی رو شروع کنیم ابتدا میپردازیم به اینکه راه حل ما شامل چه مراحلی خواهد بود

1- ایجاد یک ActionFilter سفارشی به نام Cache

2- این فیلتر، event های زیر را برای ما مدیریت خواهد کرد:

  • OnActionExecuting از اجرای درخواست در صورتی که پاسخ آن قبلا در کش وجود داشته باشد جلوگیری می کند و پاسخ قبلا کش شده را در خروجی درخواست جاری قرار میدهد.
  • OnResultExecuting پایان دادن به درخواست جاری در صورتی که پاسخ از کش خوانده شده باشد.
  • OnResultExecuted در صورتی که پاسخ در کش وجود نداشته آن را در کش ذخیره نماید.

3- یک میان افزار سفارشی که Stream درخواست را رهگیری کرده و یک MemoryStream را برای ما مهیا می کند که بعدا با استفاده از آن قادر خواهیم بود که در خروجی درخواست دستکاری کنیم.

پیاده سازی اینترفیس

از آنجا که قصد نداریم در کدهای خود وابستگی سخت بوجود آوریم، کار خود را با اینترفیس مرتبط با Caching شروع می کنیم که متدهای کلی مورد نیاز ما جهت کش اطلاعات را در اختیارمان قرار می دهد.

یک پوشه جدید به نام CacheToolkit ایجاد کرده و اینترفیس زیر را با نام ICacheService به آن اضافه کنید:

public interface ICacheService
{
    void Store(string key, object content);
    void Store(string key, object content, int duration);
    T Get<T>(string key) where T : class;
}

پیاده سازی این اینترفیس را بعدا انجام می دهیم.

یک پوشه جدید به نام FilterAttribute ایجاد کرده و کلاس CacheAttribute را مطابق زیر پیاده سازی کنید:

public class CacheAttribute : ResultFilterAttribute, IActionFilter
{
//...
}

پیاده سازی میان افزار سفارشی جهت کار با جریان خروجی

قبل از اینکه بتوانیم به پیاده سازی اکشن فیلتر مورد نظر خود بپردازیم نیاز به دسترسی به Stream خروجی درخواست داریم. به همین دلیل میان افزار CacheMiddleware  را به برنامه اضافه می کنیم. هر میان افزار به یک متد با امضای زیر احتیاج دارد:

public async Task Invoke(HttpContext httpContext)

در پیاده سازی میان افزار ها اینطور در نظر گرفته شده که هر میان افزار باید میان افزار بعدی را جهت تکمیل چرخه درخواست صدا بزند، به همین دلیل ما یک وابستگی به  RequestDelegate را از طریق متد سازنده به میان افزار خود اضافه می کنیم و سیستم تزریق وابستگی توکار Core کار تزریق را برای ما انجام خواهد داد.

پوشه جدیدی به نام Middlewares ایجاد کرده و و سپس کلاسی به نام CacheMiddleware به آن اضافه کنید.

کد های کامل میان افزار مطابق زیر خواهد بود:

public class CacheMiddleware
{
    protected RequestDelegate NextMiddleware;

    public CacheMiddleware(RequestDelegate nextMiddleware)
    {
        NextMiddleware = nextMiddleware;
    }

    public async Task Invoke(HttpContext httpContext)
    {
        using (var responseStream = new MemoryStream())
        {
            var fullResponse = httpContext.Response.Body;
            httpContext.Response.Body = responseStream;
            await NextMiddleware.Invoke(httpContext);
            responseStream.Seek(0, SeekOrigin.Begin);
            await responseStream.CopyToAsync(fullResponse);
        }
    }
}

سپس جهت معرفی این میان افزار در فایل Startup و در متد Configure خط زیر را قبل از app.UseMvc() قرار دهید:

app.UseMiddleware<CacheMiddleware>();

فیلتر سفارشی Cache

به فیلتر سفارشی CacheAttribute باز گشته و فیلد های زیر را به آن اضافه کنید:

protected ICacheService CacheService { set; get; }
public int Duration { set; get; }

فیلد Duration صرفا جهت تعیین مدت زمان کش در صورتی که مایل به تغییر مقدار آن باشیم به کار خواهد رفت. درباره CacheService در ادامه صحبت خواهیم کرد.

متد OnResultExecuted را مطابق زیر پیاده سازی کنید:

public override void OnResultExecuted(ResultExecutedContext context)
{
    CacheService = context.HttpContext.RequestServices.GetService<ICacheService>();

    var cacheKey = context.HttpContext.Request.GetEncodedUrl().ToMd5();
    var httpResponse = context.HttpContext.Response;
    var responseStream = httpResponse.Body;

    responseStream.Seek(0, SeekOrigin.Begin);

    using (var streamReader = new StreamReader(responseStream, Encoding.UTF8, true, 512, true))
    {
        var toCache = streamReader.ReadToEnd();
        var contentType = httpResponse.ContentType;
        var statusCode = httpResponse.StatusCode.ToString();
        Task.Factory.StartNew(() =>
        {
            CacheService.Store(cacheKey + "_contentType", contentType, Duration);
            CacheService.Store(cacheKey + "_statusCode", statusCode, Duration);
            CacheService.Store(cacheKey, toCache, Duration);
        });

    }
    base.OnResultExecuted(context);
}

کار این متد چیست؟ ابتدا با استفاده از الگوی Service Locator دسترسی به CacheService را برای ما مهیا می کند، در ادامه کل جریان پاسخ درخواست را (که با استفاده از میان افزاری سفارشی ذکر شده امکان استفاده از آن را فراهم آوردیم) خوانده و پاسخ درخواست، نوع درخواست و کد وضعیت درخواست را در Cache ذخیره می کند. این پروسه در پس زمینه اجرا شده و هیچ عملیاتی به خاطر آن بلاک نخواهد شد. در آخر متد OnResultExecuted کلاس پایه فراخوانی شده و دیگر فیلتر های برنامه هم اعمال خواهند شد.

حال که پاسخ درخواست را کش کردیم نیاز به روشی داریم که درخواست را رهگیری کرده و پاسخ را در خروجی درخواست بنویسیم، متد OnActionExecuting را مطابق زیر پیاده سازی نمایید:

public void OnActionExecuting(ActionExecutingContext context)
{
    CacheService = context.HttpContext.RequestServices.GetService<ICacheService>();

    var requestUrl = context.HttpContext.Request.GetEncodedUrl();
    var cacheKey = requestUrl.ToMd5();
    var cachedResult = CacheService.Get<string>(cacheKey);
    var contentType = CacheService.Get<string>(cacheKey + "_contentType");
    var statusCode = CacheService.Get<string>(cacheKey + "_statusCode");
    if (!string.IsNullOrEmpty(cachedResult) && !string.IsNullOrEmpty(contentType) &&
        !string.IsNullOrEmpty(statusCode))
    {
        //cache hit
        var httpResponse = context.HttpContext.Response;
        httpResponse.ContentType = contentType;
        httpResponse.StatusCode = Convert.ToInt32(statusCode);

        var responseStream = httpResponse.Body;
        responseStream.Seek(0, SeekOrigin.Begin);
        if (responseStream.Length <= cachedResult.Length)
        {
            responseStream.SetLength((long)cachedResult.Length << 1);
        }
        using (var writer = new StreamWriter(responseStream, Encoding.UTF8, 4096, true))
        {
            writer.Write(cachedResult);
            writer.Flush();
            responseStream.Flush();
            context.Result = new ContentResult { Content = cachedResult };
        }
    }
    else
    {
        //cache miss
    }
}

اینبار به دنبال کلیدهای ایجاد شده از روی درخواست درون کش جستجو می کنیم و در صورتی که موارد پیدا شوند یعنی که پاسخ درخواست از قبل در کش موجود می باشد. در این صورت ما نوع محتوا Content Type و وضعیت Status Code را از کش مقدار دهی کرده و HTML خوانده شده از کش را در Stream خروجی می نویسیم. در نظر داشته باشید که مقداردهی context.Result بسیار مهم می باشد، از آنجا که پیاده سازی متد بعدی بر اساس آن خواهد بود:

public override void OnResultExecuting(ResultExecutingContext context)
{
    if (context.Result is ContentResult)
    {
        context.Cancel = true;
    }
}

این کد در صورتی که پاسخ درخواست از کش خوانده شده باشد ادامه پردازش آنرا متوقف می کند. (البته مطمئنا میتوان موارد بیشتری را جهت پشتیبانی از حالت های مختلف اضافه کرد اما در اینجا، هدف صرفا توضیح ایده کلی استفاده از کش است).

متد OnActionExecuted نیاز به پیاده سازی ندارد می توانید آنرا خالی رها کنید.

پیاده سازی سرویس Cache

تنها بخش باقی مانده پیاده سازی خود CacheService هست. اکنون همانطور که از عنوان مقاله مشخص است این پیاده سازی بر اساس پایگاه داده مقیم در حافظه Redis خواهد بود. البته در ادامه یک پیاده سازی از نوع MemoryCache هم انجام خواهد شد تا در صورتی که به سرور Redis دسترسی ندارید بتوانید از آن جهت تست استفاده کنید.

کلاس جدیدی به نام RedisCacheService در مسیر CacheToolkit ایجاد کنید:

public class RedisCacheService : ICacheService
{
    protected IDistributedCache Cache;
    private static int DefaultCacheDuration => 60;

    public RedisCacheService(IDistributedCache cache)
    {
        Cache = cache;
    }

    public void Store(string key, object content)
    {
        Store(key, content, DefaultCacheDuration);
    }

    public void Store(string key, object content, int duration)
    {
        string toStore;
        if (content is string)
        {
            toStore = (string)content;
        }
        else
        {
            toStore = JsonConvert.SerializeObject(content);
        }

        duration = duration <= 0 ? DefaultCacheDuration : duration;
        Cache.Set(key, Encoding.UTF8.GetBytes(toStore), new DistributedCacheEntryOptions()
        {
            AbsoluteExpiration = DateTime.Now + TimeSpan.FromSeconds(duration)
        });
    }

    public T Get<T>(string key) where T : class
    {
        var fromCache = Cache.Get(key);
        if (fromCache == null)
        {
            return null;
        }

        var str = Encoding.UTF8.GetString(fromCache);
        if (typeof(T) == typeof(string))
        {
            return str as T;
        }

        return JsonConvert.DeserializeObject<T>(str);
    }
}

این کلاس یک وابستگی از نوع IDistributedCache دارد که توسط خود ASP.NET Core در اختیار ما قرار خواهد گرفت، تنها کاری که ما باید برای استفاده از آن انجام دهیم مشخص سازی تنظیمات اولیه آن می باشد. به فایل Startup بازگشته و در متد ConfigureServices کد های زیر را اضافه کنید:

services.AddMemoryCache();

string redisConnection = Configuration["Redis:ConnectionString"],
    redisInstance = Configuration["Redis:InstanceName"];

services.AddSingleton<IDistributedCache>(factory =>
{
    var cache = new RedisCache(new RedisCacheOptions
    {
        Configuration = redisConnection,
        InstanceName = redisInstance,
    });

    return cache;
});

همانطور که مشاهده می کنید این کد از یک سری تنظیمات از پیش تعریف شده استفاده می کند، در برنامه های ASP.NET Core این تنظیمات در فایل appsettings.json ذخیره می شوند. این فایل را گشوده و تنظیمات زیر را را به آن اضافه کنید:

  "Redis": {
    "ConnectionString": "redis-10090.c12.us-east-1-4.ec2.cloud.redislabs.com:10090",
    "InstanceName": "mrpDb"
}

در صورتی که قصد استفاده از سرور ابری معرفی شده در ابتدای مطلب را دارید باید مقدار Endpoint را که پس از ثبت نام در اختیار شما قرار میدهد در اینجا جایگزین کنید.

آخرین قدم باقی مانده معرفی کردن CacheAttribute و RedisCacheService  به برنامه می باشد. درفایل Startup کدهای زیر را در همان قسمت ConfigureServices و بعد از کدهای قبلی مرتبط با تنظیمات Redis اضافه کنید:

services.AddSingleton<ICacheService, RedisCacheService>();
services.AddTransient<CacheAttribute>();

تست برنامه

خب، کار ما تمام شد؛ جهت تست برنامه، کدهای Home/Index.cshtml را با نمونه زیر جایگزین کنید:

@model dynamic

Server Time: @DateTime.Now
<br />
Local Time: 
<script type="text/javascript">
    document.write("<p>" + new Date().toLocaleString() + "</p>");
</script>

و فیلتر CacheAttribute را به HomeController و اکشن Index اضافه کنید:

[Cache(Duration = 30)]
public IActionResult Index()
{
  return View();
}

پروژه را اجرا کنید، مشاهده خواهید کرد که درخواست برای بار اول تمام چرخه درخواست را طی خواهد کرد، اما بار دوم پاسخ از کش خوانده و ارسال خواهد شد.

Cached Request Full Request
AspNetCoreRedisCacheSample AspNetCoreRedisCacheSample

تصویر کلیدهای ایجاد شده در Redis server:

Redis Desktop Manager

 استفاده از Memory به جای Redis جهت کش کردن HTML

از آنجا که طراحی خود را با استفاده از interface ها انجام دادیم، می توانیم به سادگی RedisCacheService را با یک پیاده سازی بر پایه In-Memory جایگزین کنیم. کدهای این کلاس را در زیر مشاهده می کنید:

public class MemoryCacheService : ICacheService
{
    protected IMemoryCache Cache;

    public MemoryCacheService(IMemoryCache cache)
    {
        Cache = cache;
    }

    public void Store(string key, object content)
    {
        Store(key, content, DefaultCacheDuration);
    }

    public void Store(string key, object content, int duration)
    {
        object cached;
        if (Cache.TryGetValue(key, out cached))
        {
            Cache.Remove(key);
        }

        Cache.Set(key, content,
            new MemoryCacheEntryOptions
            {
                AbsoluteExpiration = DateTime.Now + TimeSpan.FromSeconds(duration),
                Priority = CacheItemPriority.Low
            });
    }


    private static int DefaultCacheDuration => 60;

    public T Get<T>(string key) where T : class
    {
        object result;
        if (Cache.TryGetValue(key, out result))
        {
            return result as T;
        }
        return null;
    }
}

تنها کاری که برای جایگزینی باید بکنید، اضافه کردن خط زیر در فایل Startup است:

//services.AddSingleton<ICacheService, RedisCacheService>();
services.AddSingleton<ICacheService, MemoryCacheService>();

سورس کامل این مطلب را می توانید از مخزن گیت دانلود کنید.