در این مقاله نمیخواهیم به طور عمیقی وارد جزییاتی مثل توضیح Redis یا کش بشویم؛ فرض شدهاست که کاربر با این مفاهیم آشناست. به طور خلاصه کش کردن یعنی همیشه به دیتابیس یا هارددیسک برای گرفتن اطلاعاتی که میخواهیم و گرفتنش هم کند است، وصل نشویم و بجای آن، اطلاعات را در یک محل موقتی که گرفتنش خیلی سریعتر بوده قرار دهیم و برای استفاده به آنجا برویم و اطلاعات را با سرعت بالا بخوانیم. کش کردن هم دسته بندیهای مختلفی دارد که بر حسب سناریوهای مختلفی که وجود دارد، کاربرد خود را دارند. مثلا سادهترین کش در ASP.NET Core، کش محلی (In-Memory Cache) میباشد که اینترفیس IMemoryCache را اعمال میکند و نیازی به هیچ پکیجی ندارد و به صورت درونی در ASP.NET Core در دسترس است که برای حالت توسعه، یا حالتیکه فقط یک سرور داشته باشیم، مناسب است؛ ولی برای برنامههای چند سروری، نوع دیگری از کش که به اصطلاح به آن Distributed Cache میگویند، بهتر است استفاده شود. چند روش برای پیادهسازی با این ساختار وجود دارد که نکته مشترکشان اعمال اینترفیس واحد
میباشد. در نتیجهی آن، تغییر ساختار کش به روشهای دیگر، که اینترفیس مشابهی را اعمال میکنند، با کمترین زحمت صورت میگیرد. این روشها به طور خیلی خلاصه شامل موارد زیر میباشند:
Install-Package Microsoft.Extensions.Caching.StackExchangeRedis
سپس اینترفیس
IResponseCacheService را میسازیم تا از این اینترفیس به جای IDistributedCache استفاده کنیم. البته میتوان از IDistributedCache به طور مستقیم استفاده کرد؛ ولی چون همهی ویژگیهای این اینترفیس را نمیخواهیم و هم اینکه میخواهیم serialize کردن نتایج API را در کلاسی که از این اینترفیس ارثبری میکند (ResponseCacheService) بیاوریم (تا آن را کپسولهسازی (Encapsulation) کرده باشیم تا بعدا بتوانیم مثلا بجای پکیج Newtonsoft.Json، از System.Text.Json برای serialize کردنها استفاده کنیم):
public interface IResponseCacheService
{
Task CacheResponseAsync(string cacheKey, object response, TimeSpan timeToLive);
Task<string> GetCachedResponseAsync(string cacheKey);
}
یادآوری: Redis قابلیت ذخیرهی دادههایی از نوع آرایهی بایتها را دارد (و نه هر نوع دلخواهی را). بنابراین اینجا ما بجای ذخیرهی مستقیم نتایج APIهایمان (که ممکن نیست)، میخواهیم ابتدا آنها را با serialize کردن به نوع رشتهای (که فرمت json دارد) تبدیل کنیم و سپس آن را ذخیره نماییم.
حالا کلاس ResponseCacheService که این اینترفیس را اعمال میکند میسازیم:
public class ResponseCacheService : IResponseCacheService, ISingletonDependency
{
private readonly IDistributedCache _distributedCache;
public ResponseCacheService(IDistributedCache distributedCache)
{
_distributedCache = distributedCache;
}
public async Task CacheResponseAsync(string cacheKey, object response, TimeSpan timeToLive)
{
if (response == null) return;
var serializedResponse = JsonConvert.SerializeObject(response);
await _distributedCache.SetStringAsync(cacheKey, serializedResponse, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = timeToLive
});
}
public async Task<string> GetCachedResponseAsync(string cacheKey)
{
var cachedResponse = await _distributedCache.GetStringAsync(cacheKey);
return string.IsNullOrWhiteSpace(cachedResponse) ? null : cachedResponse;
}
}
دقت کنید که اینترفیس IDistributedCache در این کلاس استفاده شده است. اینترفیس ISingletonDependency صرفا یک اینترفیس نشان گذاری برای اعمال خودکار ثبت سرویس به صورت Singleton میباشد (اینترفیس را خودمان ساختهایم و آن را برای رجیستر راحت سرویسهایمان تنظیم کردهایم). اگر نمیخواهید از این روش برای ثبت این سرویس استفاده کنید، میتوانید به صورت عادی این سرویس را رجیستر کنید که در ادامه، در قسمت مربوطه به صورت کامنت شده آمده است.
حالا کدهای لازم برای رجیستر کردن Redis و تنظیمات آن را در برنامه اضافه میکنیم. قدم اول ایجاد یک کلاس POCO به نام RedisCacheSettings است که به فیلدی به همین نام در appsettings.json نگاشت میشود:
public class RedisCacheSettings
{
public bool Enabled { get; set; }
public string ConnectionString { get; set; }
public int DefaultSecondsToCache { get; set; }
}
این فیلد را در appsettings.json هم اضافه میکنیم تا در استارتاپ برنامه، با مپ شدن به کلاس RedisCacheSettings، قابلیت استفاده شدن در تنظیمات Redis را داشته باشد.
"RedisCacheSettings": {
"Enabled": true,
"ConnectionString": "192.168.1.107:6379,ssl=False,allowAdmin=True,abortConnect=False,defaultDatabase=0,connectTimeout=500,connectRetry=3",
"DefaultSecondsToCache": 600
},
حالا باید سرویس Redis را در متد ConfigureServices، به همراه تنظیمات آن رجیستر کنیم. میتوانیم کدهای مربوطه را مستقیم در متد ConfigureServices بنویسیم و یا به صورت یک متد الحاقی در کلاس جداگانه بنویسیم و از آن در ConfigureServices استفاده کنیم و یا اینکه از
روش Installer برای ثبت خودکار سرویس و تنظیماتش استفاده کنیم. اینجا از روش آخر استفاده میکنیم. برای این منظور کلاس
CacheInstaller را میسازیم:
public class CacheInstaller : IServiceInstaller
{
public void InstallServices(IServiceCollection services, AppSettings appSettings, Assembly startupProjectAssembly)
{
var redisCacheService = appSettings.RedisCacheSettings;
services.AddSingleton(redisCacheService);
if (!appSettings.RedisCacheSettings.Enabled) return;
services.AddStackExchangeRedisCache(options =>
options.Configuration = appSettings.RedisCacheSettings.ConnectionString);
// Below code applied with ISingletonDependency Interface
// services.AddSingleton<IResponseCacheService, ResponseCacheService>();
}
}
خب تا اینجا اینترفیس اختصاصی خودمان را ساختیم و Redis را به همراه تنظیمات آن، رجیستر کردیم. برای اعمال کش، چند روش وجود دارد که همانطور که گفته شد، اینجا از روش ActionFilterAttribute استفاده میکنیم که یکی از راحتترین راههای اعمال کش در APIهای ماست. کلاس CachedAttribute را ایجاد میکنیم:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class CachedAttribute : Attribute, IAsyncActionFilter
{
private readonly int _secondsToCache;
private readonly bool _useDefaultCacheSeconds;
public CachedAttribute()
{
_useDefaultCacheSeconds = true;
}
public CachedAttribute(int secondsToCache)
{
_secondsToCache = secondsToCache;
_useDefaultCacheSeconds = false;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var cacheSettings = context.HttpContext.RequestServices.GetRequiredService<RedisCacheSettings>();
if (!cacheSettings.Enabled)
{
await next();
return;
}
var cacheService = context.HttpContext.RequestServices.GetRequiredService<IResponseCacheService>();
// Check if request has Cache
var cacheKey = GenerateCacheKeyFromRequest(context.HttpContext.Request);
var cachedResponse = await cacheService.GetCachedResponseAsync(cacheKey);
// If Yes => return Value
if (!string.IsNullOrWhiteSpace(cachedResponse))
{
var contentResult = new ContentResult
{
Content = cachedResponse,
ContentType = "application/json",
StatusCode = 200
};
context.Result = contentResult;
return;
}
// If No => Go to method => Cache Value
var actionExecutedContext = await next();
if (actionExecutedContext.Result is OkObjectResult okObjectResult)
{
var secondsToCache = _useDefaultCacheSeconds ? cacheSettings.DefaultSecondsToCache : _secondsToCache;
await cacheService.CacheResponseAsync(cacheKey, okObjectResult.Value,
TimeSpan.FromSeconds(secondsToCache));
}
}
private static string GenerateCacheKeyFromRequest(HttpRequest httpRequest)
{
var keyBuilder = new StringBuilder();
keyBuilder.Append($"{httpRequest.Path}");
foreach (var (key, value) in httpRequest.Query.OrderBy(x => x.Key))
{
keyBuilder.Append($"|{key}-{value}");
}
return keyBuilder.ToString();
}
}
در این کلاس، تزریق وابستگیهای IResponseCacheService و RedisCacheSettings به روش خاصی انجام شده است و نمیتوانستیم از روش Constructor Dependency Injection استفاده کنیم چون در این حالت میبایستی این ورودی در Controller مورد استفاده هم تزریق شود و سپس در اتریبیوت [Cached] بیاید که مجاز به اینکار نیستیم؛ بنابراین از این روش خاص استفاده کردیم. مورد دیگر فرمول ساخت کلید کش میباشد تا بتواند کش بودن یک Endpoint خاص را به طور خودکار تشخیص دهد که این متد در همین کلاس آمده است.
حالا ما میتوانیم با استفاده از attributeی به نام
[Cached] که روی APIهای از نوع HttpGet قرار میگیرد آنها را براحتی کش کنیم. کلاس بالا هم طوری طراحی شده (با دو سازنده متفاوت) که در حالت استفاده به صورت [Cached] از مقدار زمان پیشفرضی استفاده میکند که در فایل appsettings.json تنظیم شده است و یا اگر زمان خاصی را مد نظر داشتیم (مثال 1000 ثانیه) میتوانیم آن را به صورت
[(Cached(1000] بیاوریم. کلاس زیر نمونهی استفادهی از آن میباشد:
[Cached]
[HttpGet]
public IActionResult Get()
{
var rng = new Random();
var weatherForecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = rng.Next(-20, 55),
Summary = Summaries[rng.Next(Summaries.Length)]
})
.ToArray();
return Ok(weatherForecasts);
}
بنابراین وقتی تنظیمات اولیه، برای پیادهسازی این کش انجام شود، اعمال کردن آن به سادگی قرار دادن یک اتریبیوت سادهی [Cached] روی هر apiی است که بخواهیم خروجی آن را کش کنیم. فقط توجه نمایید که این روش فقط برای اکشنهایی که کد 200 را بر میگردانند، یعنی متد Ok را return میکنند (OkObjectResult) کار میکند. بعلاوه اگر از اتریبیوت ApiResultFilter یا مفهوم مشابه آن برای تغییر خروجی API به فرمت خاص استفاده میکنید، باید در آن تغییرات کوچکی را انجام دهید تا با این حالت هماهنگ شود.