🚀 کشینگ در ASP.NET Core (قسمت ۲):
شیرجه عمیق در IMemoryCache
در قسمت اول با مبانی کشینگ آشنا شدیم. حالا وقتشه آستینها رو بالا بزنیم و به صورت عملی، اولین و سادهترین نوع کشینگ در ASP.NET Core یعنی کشینگ درون-حافظهای (In-Memory) رو با IMemoryCache پیادهسازی کنیم.
1️⃣ قدم اول: فعالسازی سرویس
مثل خیلی از قابلیتهای دیگه در ASP.NET Core، اول باید سرویس IMemoryCache رو در فایل Program.cs به اپلیکیشن خودمون اضافه کنیم تا بتونیم اون رو از طریق Dependency Injection (تزریق وابستگی) همه جا استفاده کنیم.
var builder = WebApplication.CreateBuilder(args);
// اضافه کردن سرویس کشینگ درون-حافظهای
builder.Services.AddMemoryCache();
// ... بقیه تنظیمات
2️⃣ قدم دوم: الگوی پیادهسازی (Try-Get-Set)
الگوی استاندارد برای کار با کش خیلی سادهست:
• امتحان کن (Try): سعی کن داده رو از کش بگیری.
• بگیر (Get): اگه تو کش بود، همونو برگردون.
• تنظیم کن (Set): اگه تو کش نبود، از منبع اصلی (مثل دیتابیس) بگیر و برای دفعههای بعدی، تو کش ذخیره کن.
این الگو رو در یک Minimal API ببینیم:
app.MapGet("products/{id}",
(int id, IMemoryCache cache, AppDbContext context) =>
{
// ۱. سعی میکنیم محصول رو از کش با کلید id بخونیم
if (!cache.TryGetValue(id, out Product product))
{
// ۲. اگه تو کش نبود (Cache Miss)، از دیتابیس میخونیم
product = context.Products.Find(id);
// ۳. داده رو در کش ذخیره میکنیم تا دفعه بعد استفاده بشه
// (گزینههای انقضا رو در بخش بعدی توضیح میدیم)
var cacheOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromMinutes(5));
cache.Set(id, product, cacheOptions);
}
// ۴. داده رو (چه از کش، چه از دیتابیس) برمیگردونیم
return Results.Ok(product);
});3️⃣ قدم سوم: مدیریت انقضای کش (Cache Expiration) ⏱️
دادهها نباید برای همیشه تو کش بمونن، چون ممکنه در دیتابیس تغییر کنن و کهنه (stale) بشن. ما باید به کش بگیم که چه زمانی این دادهها رو دور بریزه. دو تا سیاست اصلی برای این کار وجود داره:
✨️انقضای مطلق (Absolute Expiration):
یه تاریخ انقضای مشخص تعیین میکنه. مثلاً "۱۰ دقیقه دیگه، چه کسی از این داده استفاده کرد یا نکرد، حذفش کن."
.SetAbsoluteExpiration(TimeSpan.FromMinutes(10))
✨️انقضای لغزنده (Sliding Expiration):
یه بازه زمانی عدم فعالیت تعیین میکنه. مثلاً "اگه تا ۲ دقیقه کسی به این داده دست نزد، حذفش کن. ولی اگه کسی ازش استفاده کرد، عمرش رو ۲ دقیقه دیگه تمدید کن."
.SetSlidingExpiration(TimeSpan.FromMinutes(2))
🔖 هشتگها:
#ASPNETCore #Caching
🚀 کشینگ در ASP.NET Core (قسمت ۳):
الگوی حرفهای Cache-Aside
در قسمت قبل، با IMemoryCache آشنا شدیم. عالی بود، ولی یه مشکل بزرگ داشت: اگه چند تا سرور داشته باشیم، کش بینشون به اشتراک گذاشته نمیشه.
امروز میخوایم با الگوی Cache-Aside و اینترفیس IDistributedCache آشنا بشیم تا این مشکل رو حل کنیم و کدهامون رو برای کشینگ، خیلی تمیزتر و قابل استفاده مجدد کنیم.
1️⃣ الگوی Cache-Aside چیست؟ 🤓
این الگو، رایجترین و استانداردترین استراتژی برای کار با کشه. منطقش خیلی سادهست:
• اول کش رو چک کن: برنامه شما اول به کش نگاه میکنه.
• اگه تو کش بود، برش گردون: اگه داده اونجا بود (Cache Hit)، کار تمومه.
• اگه نبود، برو سراغ منبع اصلی: اگه داده تو کش نبود (Cache Miss)، برو از منبع اصلی (مثل دیتابیس) بخونش.
• کش رو آپدیت کن و برگردون: دادهای که از منبع اصلی گرفتی رو تو کش ذخیره کن تا برای دفعه بعد آماده باشه و بعد به کاربر برگردون.
2️⃣ ساخت یک ابزار حرفهای: متد توسعه GetOrCreateAsync 🛠
به جای اینکه این منطق ۴ مرحلهای رو هر بار تکرار کنیم، میتونیم یه متد توسعه (Extension Method) خفن برای IDistributedCache بنویسیم که این کار رو برامون انجام بده.
public static class DistributedCacheExtensions
{
// یه زمان انقضای پیشفرض تعریف میکنیم
public static DistributedCacheEntryOptions DefaultExpiration => new()
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
};
public static async Task<T> GetOrCreateAsync<T>(
this IDistributedCache cache,
string key,
Func<Task<T>> factory, // تابعی که قراره داده رو از منبع اصلی بگیره
DistributedCacheEntryOptions? options = null)
{
var cachedData = await cache.GetStringAsync(key);
if (cachedData is not null)
{
// اگه داده تو کش بود، از JSON برش میگردونیم
return JsonSerializer.Deserialize<T>(cachedData);
}
// اگه نبود، تابع factory رو اجرا میکنیم تا از منبع اصلی بگیره
var data = await factory();
// داده جدید رو به صورت JSON در کش ذخیره میکنیم
await cache.SetStringAsync(
key,
JsonSerializer.Serialize(data),
options ?? DefaultExpiration);
return data;
}
}
3️⃣ نحوه استفاده از ابزار جدید ✨
حالا ببینید اون کد شلوغ قبلی، با این متد توسعه چقدر تمیز و خوانا میشه:
app.MapGet("products/{id}",
async (int id, IDistributedCache cache, AppDbContext context) =>
{
// فقط کافیه متد خودمون رو صدا بزنیم!
var product = await cache.GetOrCreateAsync($"products-{id}", async () =>
{
// این تابع فقط زمانی اجرا میشه که داده تو کش نباشه
return await context.Products.FindAsync(id);
});
return Results.Ok(product);
});💡نکته: برای اینکه این کد کار کنه، باید اول سرویس IDistributedCache رو در Program.cs ثبت کنیم:
builder.Services.AddDistributedMemoryCache();
🔖 هشتگها:
#ASPNETCore #Caching
🚀 کشینگ در ASP.NET Core (قسمت ۴):
قدرت توزیعشده با Redis
در قسمت قبل، یه متد توسعه خفن برای IDistributedCache نوشتیم. اما پیادهسازی پیشفرض اون (AddDistributedMemoryCache)، هنوز هم درون-حافظهای بود و کش رو بین سرورها به اشتراک نمیذاشت.
امروز وقتشه این مشکل رو برای همیشه حل کنیم و با Redis، یکی از محبوبترین و قدرتمندترین ابزارهای کشینگ توزیعشده، آشنا بشیم.
ردیس (Redis) چیست؟ 🧠
ردیس یک ذخیرهساز داده درون-حافظهای (in-memory) فوقالعاده سریعه که اغلب به عنوان یک کش توزیعشده با پرفورمنس بالا استفاده میشه. وقتی از Redis به عنوان کش استفاده میکنید، تمام نمونههای (instances) اپلیکیشن شما به یک سرور Redis مشترک وصل میشن و دادههای کش رو از اونجا میخونن و مینویسن.
این یعنی اگه ۱۰ تا سرور داشته باشید، کش بین همهشون یکسان و هماهنگه!
1️⃣ قدم اول: نصب پکیج
برای اینکه ASP.NET Core بتونه با Redis صحبت کنه، باید پکیج مخصوصش رو نصب کنیم:
Install-Package Microsoft.Extensions.Caching.StackExchangeRedis
این پکیج به ما اجازه میده Redis رو به راحتی به عنوان پیادهسازی IDistributedCache به پروژهمون اضافه کنیم.
2️⃣ قدم دوم: پیکربندی در Program.cs
حالا باید به برنامهمون بگیم که از Redis استفاده کنه. دو تا راه رایج برای این کار وجود داره:
👍🏻روش ساده (با Connection String):
این روش برای شروع عالیه. فقط کافیه آدرس سرور Redis رو بهش بدید.
var builder = WebApplication.CreateBuilder(args);
string redisConnectionString = builder.Configuration.GetConnectionString("Redis");
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = redisConnectionString;
});
💯روش حرفهایتر (با IConnectionMultiplexer):
این روش کنترل بیشتری به شما میده و بهترین راه برای پروژههای بزرگه. شما خودتون یک نمونه از ConnectionMultiplexer رو به صورت Singleton ثبت میکنید.
string redisConnectionString = builder.Configuration.GetConnectionString("Redis");
IConnectionMultiplexer connectionMultiplexer =
ConnectionMultiplexer.Connect(redisConnectionString);
builder.Services.AddSingleton(connectionMultiplexer);
builder.Services.AddStackExchangeRedisCache(options =>
{
options.ConnectionMultiplexerFactory =
() => Task.FromResult(connectionMultiplexer);
});جادو اتفاق افتاد! ✨
تمام شد! حالا هرجایی از کدتون که IDistributedCache رو تزریق کنید، در پشت صحنه به جای کش حافظه، از Redis استفاده خواهد شد، بدون اینکه نیاز به تغییر حتی یک خط از کدهای قبلیتون (مثل متد GetOrCreateAsync) داشته باشید. این قدرت انتزاع (Abstraction) در #C هست!
🔖 هشتگها:
#ASPNETCore #Caching #Redis
🚀 کشینگ در ASP.NET Core (قسمت ۵):
مشکل Cache Stampede و آینده کشینگ در 9 Net.
تا اینجا با انواع کشینگ آشنا شدیم. اما وقتی ترافیک سیستم خیلی بالا میره، یه مشکل خطرناک و پنهان به اسم Cache Stampede (ازدحام کش) میتونه تمام زحمات ما رو به باد بده!
در قسمت آخر این سری، با این مشکل و راه حلهای مدرنش آشنا میشیم.
1️⃣ مشکل Cache Stampede چیست؟ 🔥
تصور کنید یه آیتم خیلی پرطرفدار (مثلاً صفحه اول سایت) در کش شما منقضی میشه. در یک لحظه، صدها یا هزاران درخواست همزمان میبینن که کش خالیه و همهشون با هم به سمت دیتابیس هجوم میارن تا اون داده رو بگیرن!
این هجوم ناگهانی، دیتابیس و اپلیکیشن شما رو از پا در میاره و عملاً مزیت کشینگ رو از بین میبره.
2️⃣ یک راه حل (ناقص): قفلگذاری با SemaphoreSlim 🔒
یک راه حل رایج، استفاده از قفلگذاری (Locking) هست. یعنی فقط به اولین درخواست اجازه بدیم که بره داده رو از دیتابیس بگیره و بقیه منتظر بمونن تا کش دوباره پر بشه.
میتونیم متد GetOrCreateAsync خودمون رو با SemaphoreSlim به این شکل تغییر بدیم:
public static class DistributedCacheExtensions
{
private static readonly SemaphoreSlim Semaphore = new(1, 1);
public static async Task<T> GetOrCreateAsync<T>(
this IDistributedCache cache, string key, Func<Task<T>> factory)
{
var cachedData = await cache.GetStringAsync(key);
if (cachedData is not null) return JsonSerializer.Deserialize<T>(cachedData);
try
{
await Semaphore.WaitAsync(); // منتظر قفل بمون
// دوباره کش رو چک کن، شاید درخواست قبلی پرش کرده باشه
cachedData = await cache.GetStringAsync(key);
if (cachedData is not null) return JsonSerializer.Deserialize<T>(cachedData);
// اگه هنوز خالی بود، از دیتابیس بگیر و کش رو پر کن
var data = await factory();
await cache.SetStringAsync(key, JsonSerializer.Serialize(data), ...);
return data;
}
finally
{
Semaphore.Release(); // قفل رو آزاد کن
}
}
}
🚫مشکل این راه حل: این کد از یه قفل سراسری برای تمام کلیدها استفاده میکنه. یعنی اگه ۱۰۰ تا درخواست برای ۱۰۰ تا کلید مختلف هم بیان، باز هم باید برای هم صبر کنن! این کارایی رو به شدت کم میکنه. (راه حل بهتر، قفلگذاری بر اساس key هست که پیچیدهتره).
3️⃣ آینده کشینگ: HybridCache در 9 Net. ✨
تیم داتنت برای حل این مشکلات (و مشکلات دیگه)، در 9 Net. یه ابزار جدید و خیلی قدرتمند به اسم HybridCache معرفی کرده.
این ابزار به صورت داخلی، مشکل Cache Stampede رو به روشی بهینه حل میکنه و ترکیبی از IMemoryCache (برای سرعت) و IDistributedCache (برای توزیعشدگی) رو به بهترین شکل ممکن ارائه میده.
جمعبندی سری و نظر شما 🤔
با این پست، مینی-سریال ما در مورد کشینگ به پایان میرسه. ما از مبانی شروع کردیم و به پیشرفتهترین مشکلات و جدیدترین راه حلها رسیدیم.
🔖 هشتگها:
#ASPNETCore #Caching
🏢 معماری چند-مستأجره (Multi-Tenancy) با EF Core (قسمت ۱):
یک دیتابیس برای همه اپلیکیشنهای مدرن SaaS (نرمافزار به عنوان سرویس) یک ویژگی مشترک دارن: چند-مستأجری (Multi-Tenancy).
یعنی یک اپلیکیشن به چندین مشتری (مستأجر یا tenant) سرویس میده، ولی دادههای هر مشتری کاملاً از بقیه ایزوله هست. امروز در قسمت اول از این سری، با این معماری و روش پیادهسازی "یک دیتابیس برای همه" با EF Core آشنا میشیم.
1️⃣ دو رویکرد اصلی برای چند-مستأجری
برای ایزوله کردن دادههای مشتریان، دو راه اصلی وجود داره:
🧠 ایزولهسازی منطقی (Logical Isolation):
یک دیتابیس واحد برای همه مشتریان، که دادهها با یک شناسه مثل TenantId از هم جدا میشن. (موضوع این پست)
🏬 ایزولهسازی فیزیکی (Physical Isolation):
یک دیتابیس کاملاً مجزا برای هر مشتری.
انتخاب بین این دو، به نیاز پروژه بستگی داره. برای صنایعی مثل حوزه سلامت که به ایزولهسازی بالا نیاز دارن، روش دوم الزامیه.
2️⃣ ابزار اصلی ما: EF Core Query Filters 🔍
برای پیادهسازی روش "یک دیتابیس برای همه"، ابزار اصلی ما در EF Core، فیلترهای کوئری سراسری (Global Query Filters) هست. این قابلیت به ما اجازه میده یه شرط WHERE رو به صورت خودکار به تمام کوئریهای یک انتیتی خاص اضافه کنیم. یک بار پیادهسازیش میکنیم و دیگه تقریباً فراموشش میکنیم!
3️⃣ پیادهسازی قدم به قدم
برای این کار به دو چیز نیاز داریم: ۱. راهی برای شناسایی مستأجر فعلی. ۲. راهی برای فیلتر کردن دادهها برای اون مستأجر.
• قدم اول: شناسایی مستأجر با TenantProvider
اول از همه، باید بفهمیم درخواست فعلی برای کدوم مستأجره. ما این منطق رو تو یه کلاس جدا به اسم TenantProvider کپسوله میکنیم. این کلاس، TenantId رو از هدر (X-TenantId) درخواست HTTP میخونه.
public sealed class TenantProvider
{
private const string TenantIdHeaderName = "X-TenantId";
private readonly IHttpContextAccessor _httpContextAccessor;
public TenantProvider(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public string TenantId => _httpContextAccessor
.HttpContext?
.Request
.Headers[TenantIdHeaderName];
}
💡نکته: راههای امنتر دیگری هم برای گرفتن TenantId وجود داره مثل JWT Claim یا API Key.
• قدم دوم: اعمال فیلتر سراسری در DbContext
حالا که میتونیم مستأجر فعلی رو پیدا کنیم، باید به EF Core بگیم که تمام کوئریها رو بر اساس اون فیلتر کنه. بهترین جا برای این کار، متد OnModelCreating در DbContext ماست.
public class OrdersDbContext : DbContext
{
private readonly string _tenantId;
public OrdersDbContext(
DbContextOptions<OrdersDbContext> options,
TenantProvider tenantProvider)
: base(options)
{
// TenantId رو از TenantProvider میگیریم
_tenantId = tenantProvider.TenantId;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// این فیلتر به صورت خودکار به تمام کوئریهای انتیتی Order اضافه میشه
modelBuilder
.Entity<Order>()
.HasQueryFilter(o => o.TenantId == _tenantId);
}
}
حالا هر کوئریای که روی جدول Order زده بشه، EF Core به صورت خودکار شرط WHERE TenantId = 'current_tenant_id' رو بهش اضافه میکنه!
🔖 هشتگها:
#CSharp #ASPNETCore #DotNet #MultiTenancy #EntityFrameworkCore #SoftwareArchitecture #Backend
🏢 معماری چند-مستأجره با EF Core (قسمت ۲):
هر مستأجر، دیتابیس خودش
در قسمت اول، روش ایزولهسازی منطقی (یک دیتابیس برای همه) رو با Query Filters پیادهسازی کردیم. این روش برای خیلی از پروژهها عالیه.
اما گاهی اوقات به ایزولهسازی فیزیکی نیاز داریم؛ یعنی هر مشتری، دیتابیس کاملاً مجزای خودش رو داشته باشه تا دادهها به صورت فیزیکی از هم جدا باشن. امروز با این روش پیشرفته و امن آشنا میشیم.
1️⃣ چالش جدید: مدیریت Connection String داینامیک 🔌
در این سناریو، دیگه Query Filters به کارمون نمیاد، چون با دیتابیسهای مختلفی سر و کار داریم. چالش اصلی ما اینه که به ازای هر درخواست، بتونیم DbContext رو با Connection String مخصوص همون مستأجر پیکربندی کنیم.
2️⃣ پیادهسازی قدم به قدم
قدم اول: ذخیره اطلاعات مستأجرها ⚙️
اول از همه، باید اطلاعات هر مستأجر و Connection String دیتابیسش رو یه جا ذخیره کنیم. برای مثال، در فایل appsettings.json:
"Tenants": [
{ "Id": "tenant-1", "ConnectionString": "Host=tenant1.db;Database=db1" },
{ "Id": "tenant-2", "ConnectionString": "Host=tenant2.db;Database=db2" }
]
قدم دوم: آپدیت کردن TenantProvider 🕵️♂️حالا TenantProvider رو آپدیت میکنیم تا علاوه بر TenantId، بتونه Connection String مربوط به مستأجر فعلی رو هم از تنظیمات بخونه و به ما بده.
public sealed class TenantProvider
{
// ... (Properties for HttpContextAccessor and TenantSettings)
public TenantProvider(
IHttpContextAccessor httpContextAccessor,
IOptions<TenantSettings> tenantsOptions)
{
// ...
_tenantSettings = tenantsOptions.Value;
}
public string TenantId => /* ... gets tenantId from header ... */;
public string GetConnectionString()
{
return _tenantSettings.Tenants
.Single(t => t.Id == TenantId).ConnectionString;
}
}
قدم سوم: جادوی DI (ثبت داینامیک DbContext) ✨این مهمترین و جادوییترین بخش کاره! ما DbContext رو جوری در Program.cs ثبت میکنیم که به ازای هر درخواست، اول TenantProvider رو اجرا کنه، Connection String رو بگیره و بعد DbContext رو با اون پیکربندی کنه.
builder.Services.AddDbContext<OrdersDbContext>((serviceProvider, options) =>
{
// TenantProvider رو از DI میگیریم
var tenantProvider = serviceProvider.GetRequiredService<TenantProvider>();
// Connection String مخصوص مستأجر فعلی رو میگیریم
var connectionString = tenantProvider.GetConnectionString();
// و DbContext رو با اون کانفیگ میکنیم
options.UseSqlServer(connectionString);
});
🔐 نکته امنیتی مهم
قرار دادن Connection Stringها به صورت مستقیم در appsettings.json برای محیط پروداکشن امن نیست. همیشه از ابزارهای مدیریت secret مثل Azure Key Vault یا NET User Secrets. برای محیط توسعه استفاده کنید.
🤔 حرف حساب و تجربه شمابا این دو روش
شما الان جعبه ابزار کاملی برای پیادهسازی هر نوع معماری چند-مستأجره در ASP.NET Core دارید.
شما تو پروژههاتون با کدوم مدل چند-مستأجری کار کردید؟ تک دیتابیس یا چند دیتابیس؟ چالشها و مزایای هر کدوم از نظر شما چیه؟
🔖 هشتگها:
#CSharp #ASPNETCore #DotNet #MultiTenancy #EntityFrameworkCore #SoftwareArchitecture #Backend
🚀 چرا Connection String رو مستقیم تو appsettings.json نگذاریم؟
آشنایی کامل با Azure Key Vault
🔐 وقتی توی یک پروژه ASP.NET Core داری کار میکنی، معمولاً Connection String و Credentialهای حساس رو توی appsettings.json میزاری.
❌ اما این کار چند تا مشکل اساسی داره:
📂 فایل پیکربندی معمولاً داخل سورس کنترل (Git) هست → پس هرکسی به مخزن دسترسی داشته باشه، به دادههای حساس هم دسترسی پیدا میکنه.
🛠 حتی اگه تو gitignore. بگذاری، باز هم روی سرور یا محیطهای مشترک ممکنه نشت کنه.
🔓 توی Production، این اطلاعات ممکنه لاگ بشه یا با خطاها لو بره.
✅ راهحل امن و استاندارد: استفاده از Azure Key Vault
🔍 حالا Azure Key Vault چیه؟
یک سرویس ابری از Azure برای ذخیرهسازی امن:
🔑 Secrets (مثل Connection String، API Keys، Tokenها)
🗝 Keys (کلیدهای رمزنگاری)
📜 Certificates (گواهیها)
💯مزیتها:
• مدیریت مرکزی و امن دادههای حساس.
• کنترل سطح دسترسی به کمک Microsoft Entra ID.
• پشتیبانی از Hardware Security Module (HSM).
📦 پکیجهای لازم
dotnet add package Azure.Extensions.AspNetCore.Configuration.Secrets
dotnet add package Azure.Identity
🛠 راهاندازی در حالت Development
برای محیط لوکال، از Secret Manager استفاده کن:
<PropertyGroup>
<UserSecretsId>{GUID}</UserSecretsId>
</PropertyGroup>
اضافه کردن Secret:
dotnet user-secrets set "DbConnection" "Server=.;Database=Test;User Id=sa;Password=1234"
🏭 راهاندازی در Production با Azure Key Vault
1️⃣ ساخت Resource Group و Key Vault
az group create --name "MyGroup" --location "westeurope"
az keyvault create --name "myvault123" --resource-group "MyGroup" --location "westeurope"
2️⃣ ذخیره Secrets
az keyvault secret set --vault-name "myvault123" --name "DbConnection" --value "Server=sql.example.com;Database=Prod;..."
🗂 اتصال ASP.NET Core به Key Vault
روش ۱: با گواهی X.509
builder.Configuration.AddAzureKeyVault(
new Uri($"https://{builder.Configuration["KeyVaultName"]}.vault.azure.net/"),
new ClientCertificateCredential(
builder.Configuration["AzureADDirectoryId"],
builder.Configuration["AzureADApplicationId"],
x509Certificate));
روش 2️⃣ : با Managed Identity (پیشنهادی در Azure)
builder.Configuration.AddAzureKeyVault(
new Uri($"https://{builder.Configuration["KeyVaultName"]}.vault.azure.net/"),
new DefaultAzureCredential());
📌 بهترین نکات امنیتی
🔒 هر محیط (Dev/Prod) باید Key Vault جدا داشته باشه.
📛 از نامگذاری استاندارد برای Secrets استفاده کن (برای بخشها از -- به جای :).
🔄 برای تغییرات حساس، ReloadInterval رو تنظیم کن تا مقادیر بهروزرسانی بشن.
🛑 و Secrets رو هرگز در لاگها چاپ نکن.
🎯 نتیجه
با این روش:
• اطلاعات حساس هرگز در سورسکد یا فایلهای config ذخیره نمیشن.
• در صورت نشت سورس یا فایلها، اطلاعات Production در امانه.
• مدیریت و تغییر مقادیر حساس از طریق Azure Portal یا CLI انجام میشه، بدون نیاز به Deploy دوباره اپلیکیشن.
🔖 هشتگها:
#CSharp #ASPNetCore #Azure #KeyVault #CodeSafety #MicrosoftAzure #ConnectionString
زمانبندی Jobهای تکرارشونده 🔄
برای jobهای پسزمینه تکرارشونده، میتوانید از زمانبندی cron استفاده کنید:
app.MapPost("/api/reminders/schedule/recurring", async (
ISchedulerFactory schedulerFactory,
RecurringReminderRequest request) =>
{
// ... (Job creation is the same) ...
var trigger = TriggerBuilder.Create()
.WithIdentity($"recurring-trigger-{Guid.NewGuid()}", "recurring-reminders")
.WithCronSchedule(request.CronExpression)
.Build();
await scheduler.ScheduleJob(job, trigger);
return Results.Ok();
});تریگرهای Cron قدرتمندتر از تریگرهای ساده هستند. آنها به شما اجازه میدهند زمانبندیهای پیچیدهای مانند "هر روز کاری ساعت ۱۰ صبح" یا "هر ۱۵ دقیقه" را تعریف کنید.
راهاندازی پایداری Job (Job Persistence) 💾
بهطور پیشفرض، Quartz از ذخیرهسازی درون-حافظهای استفاده میکند، که یعنی jobهای شما با ریاستارت شدن اپلیکیشن از بین میروند. برای محیطهای پروداکشن، شما به یک فروشگاه پایدار (persistent store) نیاز دارید.
بیایید ببینیم چگونه ذخیرهسازی پایدار را با ایزولهسازی اسکیمای مناسب راهاندازی کنیم:
builder.Services.AddQuartz(options =>
{
options.AddJob<EmailReminderJob>(c => c
.StoreDurably()
.WithIdentity(EmailReminderJob.Name));
options.UsePersistentStore(persistenceOptions =>
{
persistenceOptions.UsePostgres(cfg =>
{
cfg.ConnectionString = connectionString;
cfg.TablePrefix = "scheduler.qrtz_";
});
persistenceOptions.UseNewtonsoftJsonSerializer();
});
});
تنظیم TablePrefix به سازماندهی جداول Quartz در دیتابیس شما کمک میکند - در این مورد، آنها را در یک اسکیمای اختصاصی scheduler قرار میدهد.
جاب های بادوام (Durable Jobs) 📌
توجه کنید که ما EmailReminderJob را با StoreDurably پیکربندی میکنیم. این یک الگوی قدرتمند است که به شما اجازه میدهد jobهای خود را یک بار تعریف کرده و با تریگرهای مختلف از آنها استفاده مجدد کنید.
public async Task ScheduleReminder(string userId, string message, DateTime scheduledTime)
{
var scheduler = await _schedulerFactory.GetScheduler();
// Reference the stored job by its identity
var jobKey = new JobKey(EmailReminderJob.Name);
var trigger = TriggerBuilder.Create()
.ForJob(jobKey) // Reference the durable job
.WithIdentity($"trigger-{Guid.NewGuid()}")
.UsingJobData("userId", userId)
.UsingJobData("message", message)
.StartAt(scheduledTime)
.Build();
await scheduler.ScheduleJob(trigger); // Note: just passing the trigger
}
خلاصه ✅
راهاندازی صحیح Quartz در NET. شامل موارد بیشتری از صرفاً افزودن پکیج NuGet است.
به این موارد توجه کنید:
🔹 تعریف صحیح job و مدیریت داده با JobDataMap
🔹 راهاندازی زمانبندی jobهای یکباره و تکرارشونده
🔹 پیکربندی ذخیرهسازی پایدار با ایزولهسازی اسکیمای مناسب
🔹 استفاده از jobهای بادوام برای حفظ تعاریف ثابت job
هر یک از این عناصر به یک سیستم پردازش پسزمینه قابل اعتماد کمک میکند که میتواند با نیازهای اپلیکیشن شما رشد کند.
🔖 هشتگها:
#CSharp #DotNet #ASPNETCore #QuartzNet #BackgroundJobs #TaskScheduling #Observability #SystemDesign
استفاده از ProblemDetailsService 🛠
فراخوانی AddProblemDetails یک پیادهسازی پیشفرض از IProblemDetailsService را ثبت میکند. این سرویس میتواند پاسخ را برای ما بنویسد.
در اینجا نحوه استفاده از آن در CustomExceptionHandler آمده است:
public class CustomExceptionHandler(IProblemDetailsService problemDetailsService) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
var problemDetails = new ProblemDetails
{
Status = exception switch { /* ... */ },
// ...
};
return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
Exception = exception,
HttpContext = httpContext,
ProblemDetails = problemDetails
});
}
}
سفارشیسازی Problem Details 🎨
ما میتوانیم یک delegate به متد AddProblemDetails پاس دهیم تا CustomizeProblemDetails را تنظیم کنیم. شما میتوانید از این برای افزودن اطلاعات اضافی به تمام پاسخهای Problem Details استفاده کنید.
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Instance =
$"{context.HttpContext.Request.iss.onethod} {context.HttpContext.Request.Path}";
context.ProblemDetails.Extensions.TryAdd("requestId", context.HttpContext.TraceIdentifier);
Activity? activity = context.HttpContext.Features.Get<IHttpActivityFeature>()?.Activity;
context.ProblemDetails.Extensions.TryAdd("traceId", activity?.Id);
};
});
این سفارشیسازی مسیر درخواست، یک requestId و یک traceId را به هر پاسخ Problem Details اضافه میکند که قابلیت دیباگ و ردیابی خطاها را افزایش میدهد.
مدیریت استثناهای خاص (کدهای وضعیت) 🆕
حالا 9 Net. یک راه سادهتر برای مپ کردن استثناها به کدهای وضعیت معرفی میکند. شما میتوانید از StatusCodeSelector برای تعریف این مپینگها استفاده کنید.
app.UseExceptionHandler(new ExceptionHandlerOptions
{
StatusCodeSelector = ex => ex switch
{
ArgumentException => StatusCodes.Status400BadRequest,
NotFoundException => StatusCodes.Status404NotFound,
_ => StatusCodes.Status500InternalServerError
}
});
نکات پایانی ✅
پیادهسازی Problem Details در APIهای ASP.NET Core شما بیش از یک رویه بهتر است - این یک استاندارد برای بهبود تجربه توسعهدهنده مصرفکنندگان API شماست. با ارائه پاسخهای خطای یکپارچه، دقیق و با ساختار مناسب، شما درک و مدیریت سناریوهای خطا را برای کلاینتها آسانتر میکنید.
🔖 هشتگها:
#CSharp #DotNet #ASPNETCore #ErrorHandling #ExceptionHandling #WebAPI #ProblemDetails
Middleware لاگینگ درخواست (داخلی) 🌐
using Microsoft.AspNetCore.HttpLogging;
builder.Services.AddHttpLogging(o =>
{
o.LoggingFields = HttpLoggingFields.RequestMethod
| HttpLoggingFields.RequestPath
| HttpLoggingFields.ResponseStatusCode
| HttpLoggingFields.Duration;
});
var app = builder.Build();
app.UseHttpLogging();
این متد، مسیر، وضعیت و مدت زمان را ثبت میکند. از لاگ کردن bodyها خودداری کنید مگر اینکه دلیل محکمی داشته باشید.
مسیرهای پرترافیک: LoggerMessage.Define ⚡️
با پیشکامپایل کردن قالبهای پیام، از تخصیص حافظه برای فرمتدهی رشته جلوگیری کنید. سورس جنریتور کد لاگینگ بهینهای ایجاد میکند.
static class Logs
{
private static readonly Action<ILogger, string, Exception?> _cacheMiss =
LoggerMessage.Define<string>(LogLevel.Debug, new EventId(1001, "CacheMiss"),
"Cache miss for key {Key}");
public static void CacheMiss(this ILogger log, string key) => _cacheMiss(log, key, null);
}
// استفاده
log.CacheMiss(key);
لاگهای فایلی (اختیاری، با Serilog) 📂
ارائهدهندگان داخلی در فایل نمینویسند. اگر میخواهید فایلهای چرخشی (rolling files) به صورت محلی داشته باشید، Serilog را اضافه کنید:
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.File
در برنامه:
using Serilog;
var logger = new LoggerConfiguration()
.WriteTo.Console()
.WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day)
.CreateLogger();
builder.Host.UseSerilog(logger);
✅️چه چیزی را لاگ کنیم (و چه چیزی را نه)❌️
👍 لاگ کنید: رویدادهای شروع/پایان، فراخوانیهای خارجی (هدف + مدت زمان)، رویدادهای بیزینسی، هشدارها با زمینه، خطاهای مدیریت شده با stack traces.
👎 لاگ نکنید: اسرار (secrets)، body کامل درخواست/پاسخ با اطلاعات شخصی، حلقههای پرحرف، اسپم heartbeat.
اشتباهات رایج 🤦♂️
استفاده از درونیابی رشته در پیامهای لاگ. از قالبها با placeholderهای نامدار استفاده کنید.
• لاگ کردن استثناها بدون پاس دادن خود آبجکت exception.
• نداشتن ارتباط (correlation) بین لاگهای یک درخواست.
• تبدیل همه چیز به Information یا Debug و هرگز کوتاه نکردن آن.
• نوشتن تصادفی اسرار در لاگها (توکنها، پسوردها).
🔖 هشتگها:
#CSharp #DotNet #ASPNETCore #Logging #StructuredLogging #Observability #Serilog #Debugging
🔒 ملاحظات حرفهای و بهترین شیوهها (Best Practices and Considerations)
در اینجا نکات کلیدی وجود دارد که بهتر است همیشه هنگام پیادهسازی همتوانی (Idempotency) در نظر بگیریم: 👇
⏰ عمر کش (Cache Duration)
مدت زمان کش یک موضوع حساس است. ⏳ هدف من پوشش دادن پنجرههای تلاش مجدد معقول بدون نگهداری دادههای منسوخ است. یک زمان کش معقول معمولاً از چند دقیقه تا ۲۴-۴۸ ساعت متغیر است و این بسته به مورد استفاده خاص شما دارد.
🧵 مدیریت همروندی (Concurrency)
همروندی میتواند دردسرساز باشد، به ویژه در APIهایی با ترافیک بالا. 🤯 یک پیادهسازی ایمن از نظر ریسمان (thread-safe) با استفاده از قفل توزیع شده (Distributed Lock) عالی عمل میکند. این کار کنترل امور را هنگامی که چندین درخواست همزمان وارد میشوند، حفظ میکند. اما این اتفاق باید یک رخداد نادر باشد.
💾 بکاند توزیع شده: Redis
برای تنظیمات توزیع شده، Redis انتخاب من است. 🚀 این ابزار به عنوان یک کش مشترک، عالی عمل میکند و همتوانی را در تمام نمونههای (Instances) API شما سازگار نگه میدارد. علاوه بر این، Redis قابلیت قفل توزیع شده را نیز مدیریت میکند.
🚫 جلوگیری از سوء استفاده از کلید
چه اتفاقی میافتد اگر یک کلاینت، کلید همتوانی را با یک بدنه درخواست (Request Body) متفاوت مجدداً استفاده کند؟ 🧐 در این حالت، من یک خطا برمیگردانم. رویکرد من این است که بدنه درخواست را هش (Hash) کنم و آن را با کلید همتوانی ذخیره کنم. هنگامی که یک درخواست وارد میشود، هشهای بدنه درخواست را مقایسه میکنم. اگر متفاوت باشند، خطا برمیگردانم. این کار از سوء استفاده از کلیدهای همتوانی جلوگیری کرده و یکپارچگی (Integrity) API شما را حفظ میکند.
📝 جمعبندی (Summary)
پیادهسازی همتوانی در REST APIها قابلیت اطمینان و سازگاری سرویس را افزایش میدهد. 📈 این تضمین میکند که درخواستهای یکسان، نتیجهای مشابه دارند و از تکرارهای ناخواسته جلوگیری کرده و مشکلات شبکه را به خوبی مدیریت میکنند.
در حالی که پیادهسازی ما یک پایه و اساس را فراهم میکند، توصیه میکنم آن را با نیازهای خود تطبیق دهید. 🎯 بر عملیاتهای حیاتی در APIهای خود تمرکز کنید، به ویژه آنهایی که وضعیت سیستم را تغییر میدهند یا فرآیندهای مهم کسب و کار را راهاندازی میکنند.
با پذیرش همتوانی، شما در حال ساختن APIهایی قویتر و کاربرپسندتر هستید. 💪
🔖 هشتگها:
#ASPNetCore #Idempotency
🧑💻 محدودسازی نرخ کاربران بر اساس هویت (Identity)
اگر از کاربران میخواهید که در API شما احراز هویت (Authenticate) کنند، میتوانید تشخیص دهید که کاربر فعلی کیست. سپس میتوانید از هویت (Identity) کاربر به عنوان کلید پارتیشن (Partition Key) برای یک RateLimitPartition استفاده کنید. 🆔
💻 پیادهسازی محدودسازی با هویت کاربر
در اینجا نحوه ایجاد چنین سیاست محدودسازی نرخ آمده است:
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("fixed-by-user", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.User.Identity?.Name?.ToString(),
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1)
}));
});
من از مقدار User.Identity در HttpContext استفاده میکنم تا Claim مربوط به Name کاربر فعلی را به دست آورم. این معمولاً متناظر با Claim با نام sub درون یک JWT است - که همان شناسه کاربر میباشد.
🛡 اعمال Rate Limiting روی Reverse Proxy
در یک پیادهسازی قوی، شما میخواهید محدودسازی نرخ را در سطح Reverse Proxy اعمال کنید، پیش از آنکه درخواست به API شما برسد. و اگر یک سیستم توزیع شده دارید، این یک الزام است. در غیر این صورت، سیستم شما به درستی کار نخواهد کرد. 🚨
پیادهسازیهای متعددی برای Reverse Proxy وجود دارد که میتوانید از بین آنها انتخاب کنید.
YARP
یک Reverse Proxy با یکپارچگی عالی با NET. است. این امر تعجبآور نیست، زیرا YARP با #C نوشته شده است.
⚙️ اعمال Rate Limiting در تنظیمات YARP
برای پیادهسازی محدودسازی نرخ روی Reverse Proxy با استفاده از YARP، شما باید:
یک سیاست محدودسازی نرخ تعریف کنید (همانند مثالهای قبلی).
RateLimiterPolicy
را برای مسیر (Route) در تنظیمات YARP پیکربندی کنید:
"products-route": {
"ClusterId": "products-cluster",
"RateLimiterPolicy": "sixty-per-minute-fixed",
"Match": {
"Path": "/products/{**catch-all}"
},
"Transforms": [
{ "PathPattern": "{**catch-all}" }
]
}توجه به حافظه: Middleware محدودسازی نرخ داخلی، از یک حافظه in-memory برای ردیابی تعداد درخواستها استفاده میکند. اگر میخواهید Reverse Proxy خود را در یک راهاندازی با در دسترس بودن بالا (High-Availability) اجرا کنید، به استفاده از یک Distributed Cache نیاز خواهید داشت. استفاده از یک Redis backplane برای محدودسازی نرخ، یک گزینه خوب برای بررسی است. 💾
📝 سخن پایانی
با استفاده از PartitionedRateLimiter میتوانید به راحتی سیاستهای محدودسازی نرخ جزئی (Granular) ایجاد کنید.
دو رویکرد رایج عبارتند از:
محدودسازی نرخ بر اساس آدرس IP 🌐
محدودسازی نرخ بر اساس شناسه کاربر (User Identifier) 👤
تیم NET. محدودسازی نرخ را ارائه کرد که بسیار شگفت انگیز است. اما پیادهسازی فعلی کاستیهایی دارد. مشکل اصلی این است که فقط به صورت in-memory کار میکند. برای یک راهکار توزیع شده (distributed)، شما باید خودتان چیزی پیادهسازی کنید یا از یک کتابخانه خارجی استفاده نمایید.
شما میتوانید از Reverse Proxy YARP برای ساختن سیستمهای توزیع شده قوی و مقیاسپذیر استفاده کنید. و اضافه کردن Rate Limiting در سطح Reverse Proxy تنها به چند خط کد نیاز دارد. بهتر است که به طور گستردهای از آن در سیستمهایمان استفاده میکنیم.
از اینکه این مقاله را خواندید، متشکرم. و فوقالعاده بمانید! ✨
🔖 هشتگها:
#DotNet #RateLimiting #Security #ASPNETCore #ReverseProxy #IPAddress
⚙️ کار با منابع مختلف کانفیگ
سیستم کانفیگ ASP.NET Core از چندین منبع پشتیبانی میکند. هنگام استفاده از Options Pattern با FluentValidation، به یاد داشته باشید که اعتبارسنجی صرفنظر از منبع کار میکند:
🔹️متغیرهای محیطی (Environment variables)
🔹️Azure Key Vault
🔹️User secrets
🔹️فایلهای JSON
🔹️کانفیگ در حافظه (In-memory configuration)
این ویژگی مخصوصاً برای برنامههای containerized مفید است که کانفیگ از طریق متغیرهای محیطی یا secretهای mount شده میآید.
🧪 تست Validatorهای خود
یکی از مزایای استفاده از FluentValidation این است که Validatorها بسیار آسان تست میشوند:
// استفاده از متدهای کمکی FluentValidation.TestHelper
[Fact]
public void GitHubSettings_WithMissingAccessToken_ShouldHaveValidationError()
{
// Arrange
var validator = new GitHubSettingsValidator();
var settings = new GitHubSettings { RepositoryName = "test-repo" };
// Act
TestValidationResult<GitHubSettings>? result = await validator.TestValidate(settings);
// Assert
result.ShouldNotHaveAnyValidationErrors();
}
✅ خلاصه
با ترکیب FluentValidation با Options Pattern و ()ValidateOnStart، یک سیستم اعتبارسنجی قدرتمند ایجاد میکنیم که تضمین میکند کانفیگ برنامه درست باشد از همان ابتدا.
مزایای این روش:
• قوانین اعتبارسنجی بیانگراتر و انعطافپذیرتر نسبت به Data Annotations
• جداسازی منطق اعتبارسنجی از مدلهای کانفیگ
• کشف خطاهای کانفیگ در زمان startup برنامه
• پشتیبانی از سناریوهای اعتبارسنجی پیچیده
• قابلیت تست آسان
این الگو به ویژه در معماریهای میکروسرویس یا برنامههای containerized ارزشمند است، جایی که خطاهای کانفیگ باید فوراً تشخیص داده شوند و نه در زمان اجرا.
به یاد داشته باشید که Validatorهای خود را به درستی ثبت کنید و از ()ValidateOnStart استفاده کنید تا اعتبارسنجی در زمان شروع برنامه اجرا شود.
🏷 هشتگها:
#ASPNetCore #FluentValidation #OptionsPattern #Validation
✨ Server-Side Permission Resolution در ASP.NET Core
در بسیاری از پروژهها، توسعهدهندگان تمام Permissionها را درون JWT Token ذخیره میکنند.
اما این روش باعث افزایش حجم Token و کاهش امنیت میشود.
راه بهتر این است که مجوزها را در سمت سرور (Server-Side) واکشی کنیم. ⚙️
🧠 استفاده از IClaimsTransformation برای افزودن Permissionها در سمت سرور
public class PermissionClaimsTransformation(IPermissionService permissionService)
: IClaimsTransformation
{
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
if (principal.Identity?.IsAuthenticated != true)
{
return principal;
}
var userId = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userId == null)
{
return principal;
}
// واکشی مجوزها از دیتابیس و سپس ذخیره در Cache
// نکته مهم: حتماً نتایج را کش کنید تا در هر درخواست کوئری تکراری به دیتابیس نرود
var permissions = await permissionService.GetUserPermissionsAsync(userId);
var claimsIdentity = (ClaimsIdentity)principal.Identity;
foreach (var permission in permissions)
{
claimsIdentity.AddClaim(new Claim(CustomClaimTypes.Permission, permission));
}
return principal;
}
}
📦 سپس این کلاس را در DI Container ثبت میکنیم:
builder.Services.AddScoped<IClaimsTransformation, PermissionClaimsTransformation>();
🔹 با این روش، JWT شما سبک و امن باقی میماند،
در حالی که Authorization همچنان سریع و مبتنی بر Claims انجام میشود. ⚡️
🧩 جمعبندی (Takeaway)
الگوی RBAC (Role-Based Access Control)
فرآیند Authorization را از یک دردسر نگهداری به یک سیستم منعطف و مقیاسپذیر تبدیل میکند. 🚀
✅ از Permissions شروع کنید، نه Roles
تعریف کنید کاربر چه عملیاتی میتواند انجام دهد، نه اینکه چه نقشی دارد.
✅ Custom Authorization Handlerها
کنترل کامل روی نحوهٔ اعتبارسنجی مجوزها به شما میدهند.
✅ Extension Methodها
کد را تمیز، منسجم و خوانا میکنند.
✅ Type-Safe Enumها + Server-Side Permission Resolution
کد را پایدارتر، Tokenها را سبکتر و سیستم را قابل نگهداریتر میکنند.
✨ نتیجه؟
یک سیستم Authorization تمیز، تستپذیر، و منعطف
که بهسادگی با رشد برنامهٔ شما سازگار میشود. 💪
🔖هشتگها:
#ASPNetCore #RBAC #Authorization #DotNet #CleanArchitecture #CSharp
ءPostConfigure برای Named Options نیز قابل استفاده است:
برای post-config کردن تمام نمونههای تنظیمات از PostConfigureAll استفاده میشود:
برای دسترسی به <IOptions<TOptions یا <IOptionsMonitor<TOptions در Program.cs، باید از GetRequiredService روی WebApplication.Services استفاده کنید:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services.Configure<TopItemSettings>(TopItemSettings.Month,
builder.Configuration.GetSection("TopItem:Month"));
builder.Services.Configure<TopItemSettings>(TopItemSettings.Year,
builder.Configuration.GetSection("TopItem:Year"));
builder.Services.PostConfigure<TopItemSettings>("Month", myOptions =>
{
myOptions.Name = "post_configured_name_value";
myOptions.Model = "post_configured_model_value";
});
var app = builder.Build();
برای post-config کردن تمام نمونههای تنظیمات از PostConfigureAll استفاده میشود:
using OptionsValidationSample.Configuration;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddOptions<MyConfigOptions>()
.Bind(builder.Configuration.GetSection(MyConfigOptions.MyConfig));
builder.Services.PostConfigureAll<MyConfigOptions>(myOptions =>
{
myOptions.Key1 = "post_configured_key1_value";
});
Access options in Program.cs 🔍
برای دسترسی به <IOptions<TOptions یا <IOptionsMonitor<TOptions در Program.cs، باید از GetRequiredService روی WebApplication.Services استفاده کنید:
var app = builder.Build();
var option1 = app.Services.GetRequiredService<IOptionsMonitor<MyOptions>>()
.CurrentValue.Option1;
🔖هشتگها:
#aspnetcore #dotnet #csharp #optionspattern #configuration #softwarearchitecture #cleanarchitecture