C# Geeks (.NET)
414 subscribers
155 photos
4 videos
159 links
Download Telegram
حل مشکل Invalidating کش توزیع‌شده با Redis و HybridCache 🧠🚀

سیستم‌های توزیع‌شده برای مقیاس‌پذیری عالی هستند، اما یک کلاس کاملاً جدید از مشکلات را هم معرفی می‌کنند. یکی از سخت‌ترین این مشکلات، cache invalidation است. ⚠️

در NET 9.، مایکروسافت کتابخانه‌ای به نام HybridCache معرفی کرد تا فرآیند caching را ساده‌تر کند. این یک کتابخانه فوق‌العاده است که سرعت کش در حافظه (L1) را با پایداری کش توزیع‌شده (L2) مثل Redis ترکیب می‌کند. همچنین به‌صورت پیش‌فرض از محافظت در برابر cache stampede پشتیبانی می‌کند. 🛡

اما یک نکته وجود دارد. ❗️

وقتی چندین instance از اپلیکیشن خود را اجرا می‌کنید، HybridCache به‌صورت خودکار کش محلی L1 را بین تمام نودها همگام‌سازی نمی‌کند. اگر داده‌ای روی Node A به‌روزرسانی شود، Node B همچنان داده‌ی قدیمی را از کش درون‌حافظه‌ای خودش برمی‌گرداند تا زمانی که آن entry منقضی شود.

در حالی که HybridCache یک گام بزرگ رو به جلو است، نبود یک backplane داخلی برای invalidation یک محدودیت شناخته‌شده است. در واقع، یک بحث فعال در ریپازیتوری GitHub مربوط به dotnet/extensions وجود دارد که دقیقاً همین feature request را دنبال می‌کند. تا زمانی که این قابلیت ارائه شود، مجبوریم خودمان راه‌حل بسازیم. 🛠

در این خبرنامه بررسی می‌کنیم:

• معضل کش توزیع‌شده 🌍
• چرا HybridCache به‌تنهایی این مشکل را حل نمی‌کند 🤔
• استفاده از Redis Pub/Sub به‌عنوان backplane 📡
• پیاده‌سازی invalidation بلادرنگ کش ⚡️

بیایید شروع کنیم. 👇

معضل کش توزیع‌شده (The Distributed Caching Dilemma)

بیایید یک سناریوی معمول در محیط production را تصور کنیم. شما یک API دارید که روی چندین سرور (یا pod) پشت یک load balancer اجرا می‌شود. ⚙️

برای بهبود performance، از caching استفاده می‌کنید. سرعت حافظه محلی را می‌خواهید، پس از HybridCache استفاده می‌کنید. 🚀

سناریوی شکست به این صورت است:

• کاربر A پروفایل خود را روی Server 1 به‌روزرسانی می‌کند. 👤

• ءServer 1 دیتابیس را آپدیت می‌کند و کش محلی خودش را پاک می‌کند. 🗑

• کاربر A (یا کاربر B) به Server 2 درخواست می‌زند. 🔁

• ءServer 2 هنوز داده‌ی قدیمی پروفایل را در HybridCache محلی خودش نگه داشته است. 🧊

• کاربر اطلاعات قدیمی را می‌بیند، چون کش محلی invalidate نشده است. 😕
چرا فقط مدت زمان کش را کوتاه نکنیم؟ ⏱️🤔

یک راه‌حل «هکی» و رایج برای حل این مشکل این است که مدت زمان کش L1 (TTL) را خیلی کم کنیم. مثلاً تنظیم کنیم که کش محلی هر ۱۰ ثانیه منقضی شود.

در حالی که این کار بازه‌ی ناسازگاری را کمتر می‌کند، اما واقعاً مشکل را حل نمی‌کند؛ فقط آن را پنهان می‌کند. 🎭

این رویکرد دو مشکل جدید ایجاد می‌کند:

1️⃣ افزایش Latency 🚦: حالا اپلیکیشن شما خیلی بیشتر مجبور است به کش توزیع‌شده L2 (مثل Redis) یا حتی دیتابیس درخواست بزند.

2️⃣ از دست رفتن Efficiency 📉: مزیت اصلی کش L1 این است که کلاً از درخواست شبکه جلوگیری می‌کند. اگر داده‌ها خیلی سریع منقضی شوند، برای بخش عمده‌ای از ترافیک، این مزیت performance را از دست می‌دهید.

برای چیزهایی مثل مجوزهای کاربر (user permissions)، feature flagها یا قیمت‌گذاری، «تقریباً درست» معمولاً کافی نیست. شما به سازگاری فوری (immediate consistency) نیاز دارید. ⚡️

راه‌حل: Redis Pub/Sub به‌عنوان Backplane 📡🧩

برای حل این مشکل، به یک backplane نیاز داریم.Backplane یک کانال ارتباطی است که تمام نودهای اپلیکیشن ما را به هم وصل می‌کند.

وقتی یک cache entry روی یک نود حذف یا به‌روزرسانی می‌شود، یک پیام روی backplane منتشر می‌کنیم. تمام نودهای دیگر مشترک (subscribe) این کانال هستند و به محض دریافت پیام، کلید مربوطه را از کش محلی خودشان حذف می‌کنند. 🗑

ءRedis از قبل انتخاب محبوبی برای کش L2 است، بنابراین کاملاً منطقی است که از قابلیت Pub/Sub آن برای این مکانیزم سیگنال‌دهی استفاده کنیم. 🔔

این فرآیند به این شکل کار می‌کند:

🔸️ءPublisher 🧑‍💻: نودی که داده را تغییر می‌دهد، یک پیام invalidation شامل cache key منتشر می‌کند.

🔹️ءSubscriber 👂: تمام نودها روی این کانال گوش می‌دهند.

🔸️ءAction ⚙️: وقتی پیام می‌رسد، متد HybridCache.RemoveAsync(key) را صدا می‌زنند.
پیاده‌سازی راه‌حل ⚙️

ما به کتابخانه‌ی StackExchange.Redis برای مدیریت پیام‌رسانی نیاز داریم.

بیایید با تعریف یک سرویس ساده برای انتشار (publishing) شروع کنیم. این سرویس مسئول اطلاع دادن به بقیه‌ی سیستم است که یک کلید تغییر کرده است. 🔔
public interface ICacheInvalidator
{
Task InvalidateAsync(string key, CancellationToken cancellationToken = default);
}

public class RedisCacheInvalidator(
IConnectionMultiplexer connectionMultiplexer,
ILogger<RedisCacheInvalidator> logger)
: ICacheInvalidator
{
private const RedisChannel Channel = RedisChannel.Literal("cache-invalidation");

public async Task InvalidateAsync(string key, CancellationToken cancellationToken = default)
{
var subscriber = connectionMultiplexer.GetSubscriber();

await subscriber.PublishAsync(Channel, new RedisValue(key));

logger.LogInformation("Published invalidation for key: {Key}", key);
}
}

حالا هر زمان که یک entity را در Command Handler یا Service آپدیت می‌کنید، کافی است متد ICacheInvalidator.InvalidateAsync را صدا بزنید. 🚀
public class UpdateUserProfileHandler(
AppDbContext dbContext,
ICacheInvalidator cacheInvalidator,
ILogger<UpdateUserProfileHandler> logger)
{
public async Task Handle(int userId, string newName, CancellationToken ct)
{
// 1. Update the database
var user = await dbContext.Users.FindAsync([userId], ct);
if (user is null)
{
return;
}

user.Name = newName;
await dbContext.SaveChangesAsync(ct);

// 2. Invalidate the cache (Distributed)
var cacheKey = $"user:{userId}";
await cacheInvalidator.InvalidateAsync(cacheKey, ct);

logger.LogInformation("Updated user and invalidated cache for {UserId}", userId);
}
}


Background Listener 🧵

در مرحله‌ی بعد، به یک background service نیاز داریم که روی هر نود اجرا شود. این سرویس روی کانال Redis مشترک می‌شود و کلیدها را از HybridCache محلی حذف می‌کند.

یک نکته‌ی سریع درباره‌ی self-publishing:
چون Redis Pub/Sub پیام را برای همه‌ی subscriberها ارسال می‌کند، نودی که خودش پیام invalidation را منتشر کرده هم دوباره همان پیام را دریافت می‌کند. در این پیاده‌سازی، ما دوباره همان کلید را حذف می‌کنیم. این کار تکراری ولی بی‌ضرر است و کد را ساده نگه می‌دارد. 🔁

توجه کنید که ما مستقیماً HybridCache را داخل background service تزریق کرده‌ایم. یک جایگزین این است که از IMemoryCache استفاده کنیم، چون همان L1 cache داخل HybridCache است.
public class CacheInvalidationService(
IConnectionMultiplexer connectionMultiplexer,
HybridCache hybridCache,
ILogger<CacheInvalidationService> logger)
: BackgroundService
{
private const RedisChannel Channel = RedisChannel.Literal("cache-invalidation");

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var subscriber = connectionMultiplexer.GetSubscriber();

await subscriber.SubscribeAsync(Channel, (channel, value) =>
{
string key = value.ToString();

logger.LogInformation("Invalidating local cache for: {Key}", key);

// This removes the item from the local L1 cache
var task = hybridCache.RemoveAsync(key, stoppingToken);

if (!task.IsCompleted)
{
task.GetAwaiter().GetResult();
}
});
}
}
وصل کردن همه‌چیز به هم (Wiring It All Together) 🔌

در نهایت باید این سرویس‌ها را داخل DI container ثبت کنیم:
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
ConnectionMultiplexer.Connect("<REDIS_CONNECTION_STRING>"));

// Register HybridCache (defaults generally work fine for L1)
builder.Services.AddHybridCache();

// Register our invalidation services
builder.Services.AddSingleton<ICacheInvalidator, RedisCacheInvalidator>();
builder.Services.AddHostedService<CacheInvalidationService>();

حالا وقتی Node A متد
InvalidateAsync("user:123")
را صدا بزند، Redis این پیام را به Node B، Node C و بقیه می‌فرستد. همه‌ی آن‌ها متد
hybridCache.RemoveAsync("user:123")
را اجرا می‌کنند و تضمین می‌شود که درخواست بعدی داده‌ی تازه را از منبع اصلی (یا از L2 مشترک) بگیرد. 🔄

یک راه بهتر: FusionCache 🧠🔥

اگر ساختن backplane اختصاصی حس «reinventing the wheel» به شما می‌دهد، بهتر است نگاهی به FusionCache بیندازید.

ءFusionCache یک کتابخانه‌ی بالغ و battle-tested است که سال‌هاست دقیقاً همین مشکل را حل کرده. این کتابخانه یک backplane داخلی دارد که به صورت خودکار Pub/Sub را مدیریت می‌کند.

حتی بهتر از آن: FusionCache اخیراً یک پیاده‌سازی از abstract class مربوط به HybridCache اضافه کرده است. یعنی می‌توانید تقریباً بدون تغییر زیاد در کد فعلی، آن را جایگزین کنید. 😍
// Using FusionCache's implementation of HybridCache
builder.Services.AddFusionCache()
.WithBackplane(
new RedisBackplane(new RedisBackplaneOptions { Configuration = "<REDIS_CONNECTION_STRING>" }))
.AsHybridCache();

جمع‌بندی 📝

ءHybridCache یک اضافه‌شدن بسیار قدرتمند به اکوسیستم NET. است و عملاً مزایای IMemoryCache و IDistributedCache را با هم ترکیب می‌کند. اما در سناریوهای multi-node که نیاز به consistency بالا دارند، هنوز به یک مکانیزم برای همگام‌سازی کش‌های محلی نیاز دارید.

ءRedis Pub/Sub یک راه‌حل سبک و مؤثر برای این مشکل ارائه می‌دهد. 📡

با پیاده‌سازی یک «bus» ساده برای پیام‌های invalidation، به بهترین حالت ممکن می‌رسید:
هم performance فوق‌العاده‌ی کش محلی را دارید، هم consistency سیستم توزیع‌شده را. ⚡️🌍

موفق باشید😉🚀

🔖هشتگ‌ها:
#DotNet #HybridCache #Redis #DistributedSystems #Caching #SystemDesign #FusionCache
Forwarded from DotNet | دات نت
🔥 نکته حیاتی در Hangfire: جنگ با ساعت و تایم‌زون‌ها! 🌍

اگر تا حالا براتون پیش اومده که جابی رو برای ساعت ۸ صبح تنظیم کردید ولی ساعت ۱۱:۳۰ اجرا شده، این پست برای شماست!

⚠️ ماجرا چیه؟
هنگ‌فایر (Hangfire) به صورت پیش‌فرض (Default) همه جاب‌های تکرارشونده (Recurring Jobs) رو بر مبنای ساعت UTC اجرا می‌کنه. یعنی اگر تنظیمات تایم‌زون رو بهش ندید، باید اختلاف ساعت ایران با گرینویچ رو دستی حساب کنید که اصلا جالب نیست.

حالا اگر بخوایم بگیم "به وقت ایران اجرا شو"، با یه چالش جدید روبرو می‌شیم:

🔸 ویندوز می‌گه: "Iran Standard Time"

🔸 لینوکس/داکر می‌گه: "Asia/Tehran"
اگر این تفاوت هندل نشه، روی سرور لینوکسی یا کانتینر داکر به خطای TimeZoneNotFoundException می‌خورید! 🤯

راه حل نهایی (Cross-Platform):
با این تیکه کد، هم مشکل UTC رو حل کنید و هم کدی بنویسید که روی ویندوز، لینوکس و مک بدون تغییر کار کنه:
using System.Runtime.InteropServices;

// 1. تشخیص خودکار شناسه تایم‌زون بر اساس سیستم‌عامل
// Windows -> "Iran Standard Time"
// Linux/Docker -> "Asia/Tehran"
var tehranId = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "Iran Standard Time"
: "Asia/Tehran";

// 2. تنظیم ساعت به وقت ایران
RecurringJob.AddOrUpdate(
type.FullName,
() => job.ExecuteAsync(),
attribute.CronExpression,
new RecurringJobOptions
{
// خداحافظ UTC، سلام تهران! 👋
TimeZone = TimeZoneInfo.FindSystemTimeZoneById(tehranId)
}
);

💡 با این روش، دیگه نگران جلو/عقب کشیدن ساعت‌ها یا تفاوت محیط لوکال و سرور نباشید.

🔗 بحث مرتبط در کامیونیتی هنگ‌فایر:
https://discuss.hangfire.io/t/need-local-time-instead-of-utc/279/7


🎺برای یادگیری بیشتر و دریافت مطالب مفید در زمینه .NET و برنامه‌نویسی، به کانال ما بپیوندید!

📚💻 @dotnetcode 🖥

#CSharp #DotNet #Hangfire #Docker #Backend #TimeZone #Tips
Please open Telegram to view this post
VIEW IN TELEGRAM
چالش برنامه نویسی امروز🔥
خروجی چی میشه؟
لطفا پاسخ‌تون رو کامنت کنید😊
Anonymous Quiz
14%
A B
16%
B A
16%
B B
54%
A A
🔗Chain Of Responsibility
📌برسی تفاوت Task و ValueTask

زمانیکه تصمیم میگیریم کدهای زده شده را بهینه کنیم، اکثرا دنبال راه حل‌های جدید نمیگردیم. این مورد کاملا غریزی است؛ چرا که به‌دنبال کم‌ترین انرژی و بیشترین بازدهی هستیم؛ این طبیعت انسان است. صرفا کدهای قبلی را بازبینی میکنیم و سعی میکنیم نحوه‌ی نوشتن منطق‌های موجود را بهینه کنیم. در همین راستا درک عملکرد Task و ValueTask ‌ها شاید قدمی مهم در مورد بهینه کردن کد‌ها باشد؛ چرا استفاده درست و بجای این دو مورد می‌تواند تاثیر زیادی بر روی سرعت و استفاده از مصرف حافظه داشته باشد؟ در این مقاله سعی میکنیم تا درک درستی از این دو داشته باشیم.


✨️ء<Task<T چیست؟

ءTask یک کلاس در فضای نام System.Threading.Tasks است؛ به‌طوریکه کمک میکند تا یک قسمت از برنامه به صورت مستقل از Thread اصلی اجرا شود. به‌بیان دیگر می‌تواند یک Thread Pool را ایجاد و با توجه به روند کار، از یک مرحله‌ی اجرایی به مرحله‌ای دیگر منتقل می‌کند. همچنین هر Task می‌تواند یک مقدار برگشتی نیز داشته باشد.
این درحالی‌است که می‌تواند صرفا یک فرآیند را اجرا کند، بدون اینکه خروجی داشته باشد. به‌عبارتی دیگر اگر فرآیندی داشته باشیم که در نهایت یک شناسه را برمیگرداند، از <Task<int و اگر فرآیندی داشته باشیم که صرفا فرآیند همگام سازی داده‌های قدیمی به جدید را انجام میدهد، می‌تواند از نوع Task باشد.

همانطور که اشاره شد، Task یک کلاس است که شامل متد‌ها و فیلد‌های مختلفی می‌باشد. با استفاده از این اعضا می‌توان نحوه‌ی اجرای کدها و وضعیت‌های مختلف اجرای آن را مدیریت کرد، تا در نهایت اجرای آن کامل شود.

به دلیل اینکه Task یک class است و class ‌ها از نوع ReferenceType می‌باشند، روی حافظه‌ی Heap ذخیره می‌شوند و به‌ازای هر بار فراخوانی متدی که خروجی Task دارد، شیء Task را روی Heap ذخیره میکند. این شیء وضعیت اجرای قسمتی از کد ما را که میتواند sync یا async باشد، در خود ذخیره میکند تا در نهایت اجرای آن کامل شود.


💡نحوه استفاده از <Task<T

برای درک بهتر، یک تکه کد را با بهره بردن از Task ایجاد میکنیم :
public static class DummyWeatherProvider
{
public static async Task<Weather> Get(string city)
{
await Task.Delay(10);
var weather = new Weather
{
City = city,
Date = DateTime.Now,
AvgTempratureF = new Random().Next(5, 70)
};

return weather;
}
}

همان طور که مشخص است، کلاس موجود یک متد به نام Get دارد تا اطلاعات آب و هوای شهر مورد نظر را به صورت یک Task برگرداند. حال کد زیر را جهت بررسی تغییر وضعیت‌های اجرایی این Task ایجاد می‌کنیم :
static async Task CheckTaskStatus()
{
var task = DummyWeatherProvider.Get("Stockholm");
LogTaskStatus(task.Status);
await task;
LogTaskStatus(task.Status);
}

static void LogTaskStatus(TaskStatus status)
{
Console.WriteLine($"Task Status: {Enum.GetName(typeof(TaskStatus), status)}");
}

ءTaskStatus یک enumeration است، به‌طوری‌که بیانگر وضعیت‌های مختلف یک Task در حال اجرا می‌باشد. برای مثال: WaitingForActivation, Running, RanToCompletion. در کد بالا ابتدا متد را فراخوانی می‌کنیم. سپس منتظر می‌مانیم تا متد اجرا شده، تکمیل شود. در اولین لاگ وضعیت، به WaitingForActivation و در دومین لاگ به RanToCompletion تبدیل میشود. حال‌که با Task ها و نحوه‌ی اجرای فرآیند آن آشنا شدیم، در قسمت بعدی به بررسی ValueTask ها می‌پردازیم.

✨️ء<ValueTask<T چیست؟

همانند تسک ها ، ValueTask هم برای مدیریت وضعیت فرآیند استفاده میشود؛ با این تفاوت که ValueTask ‌ها از نوع struct هستند. به‌طوریکه نحوه‌ی ذخیره سازی آن‌ها در حافظه به نسبت class ‌ها کاملا متفاوت است. از نقطه نظر سرعت، تشخیص دادن اینکه کدامیک باید استفاده شود، باید با توجه به سناریو، بررسی و انتخاب شود؛ چرا که از نظر تخصیص حافظه متفاوت عمل می‌کنند. برای درک بهتر عملکرد ValueTask ‌ها کد زیر را بررسی میکنیم :
public class WeatherService
{
private readonly ConcurrentDictionary<string, Weather> _cache;
public WeatherService()
{
_cache = new();
}

public async Task<Weather> GetWeatherTask(string city)
{
if (!_cache.ContainsKey(city))
{
var weather = await DummyWeatherProvider.Get(city);
_cache.TryAdd(city, weather);
}
return _cache[city];
}

public async ValueTask<Weather> GetWeatherValueTask(string city)
{
if (!_cache.ContainsKey(city))
{
var weather = await DummyWeatherProvider.Get(city);
_cache.TryAdd(city, weather);
}
return _cache[city];
}

کلاس WeatherService شامل یک فیلد private از نوع collection و دو متد است. ما از _cache جهت نگهداری اطلاعاتی که قبلا دریافت شده، استفاده می‌کنیم و به نوعی in-memory cache را پیاده سازی میکنیم. پیاده سازی منطق هر دو متد GetWeatherTask و GetWeatherValueTask کاملا شبیه به هم است؛ به‌طوری‌که اول بررسی میکنیم اطلاعات آب و هوای شهر مورد نظر در _cache وجود دارد یا خیر؟ اگر وجود داشت، اطلاعات به صورت مستقیم برگشت داده می‌شود؛ در غیر این صورت ()DummyWeatherProvider.Get فراخوانی خواهد شد.

در قدم بعدی اطلاعات به‌دست آمده را در cache_ ذخیره می‌کنیم. سپس مقدار ذخیره شده را برگشت میدهیم. در واقع تنها تفاوت دو متد ذکر شده، نوع خروجی آن می‌باشد؛ یکی از Taskو دیگری از ValueTask استفاده می‌کند.


برای مقایسه‌ی مصرف حافظه‌ی این دو روی هر دو متد، Benchmark میگیریم. برای پیاده سازی نیار به کد‌های زیر داریم :
csharp
[MemoryDiagnoser]
public class TaskAndValueTaskBenchmark
{
private readonly WeatherService _weatherService;
public TaskAndValueTaskBenchmark()
{
_weatherService = new();
}

[Benchmark]
[Arguments("Denver")]
public async Task<Weather> TaskBenchmark(string city)
{
return await _weatherService.GetWeatherTask(city);
}

[Benchmark]
[Arguments("London")]
public async ValueTask<Weather> ValueTaskBenchmark(string city)
{
return await _weatherService.GetWeatherValueTask(city);
}
}

نتیجه به دست آمده به شرح زیر است :
TaskBenchmark
{
"Gen0" : "0.0229",
"Allocated" : "144 B"
}

ValueTaskBenchmark
{
"Gen0" : "----",
"Allocated" : "------"
}

با توجه به نتیجه به‌دست آمده، متدی که خروجی ValueTask دارد، حافظه‌ای را تخصیص نداده‌است؛ این دقیقا مزیت مهم ValueTask نسبت به Task می‌باشد.

🛠مزیت <ValueTask<T

به‌دلیل اینکه از نوع struct هستند، بر روی حافظه، در قسمت Stack ذخیره می‌شوند و به صورت خودکار بعد از اینکه نیازی به آنها نباشد، از حافظه حذف می‌شوند . به همین دلیل به شکل قابل توجهی، فشار را از روی GC کاهش می‌دهد .

علاوه بر این، در سناریویی که اکثر کدها به صورت sync اجرا می‌شوند، در این مواقع استفاده از ValueTask، بهتر از Task می‌باشد .

این سری متد GetWeatherValueTask را جهت تشخص اینکه اغلب کدها به صورت sync یا async اجرا می‌شوند، بررسی می‌کنیم. در متد ذکر شده اگر اطلاعات شهر مورد نظر وجود داشته باشد، کار به صورت sync اجرا می‌شود و اگر شهر وجود نداشته باشد، کار به صورت async اجرا می‌شود. با بررسی دقیق‌تر متوجه می‌شویم اکثر مواقع در این متد کار به صورت sync اجرا می‌شود؛ چرا که بعد ازدریافت اطلاعات، مجدد آن را دریافت نمیکند، بلکه از حافظه میخواند (همان cache_ ) .



🔸️محدودیت‌های استفاده از <ValueTask<T

1️⃣ در اینجا تنها یکبار امکان استفاده از await وجود دارد. وقتی یکبار valueTask را await می‌کنیم، بهتر است کار دیگری بر روی آن انجام ندهیم؛ چراکه ممکن است از حافظه پاک شده باشد.

2️⃣ اگر در سناریویی لازم دارید چندین بار await را بر روی valueTask اجرا کنید، لازم است ابتدا آن را به Task تبدیل کنیم. برای این کار متد AsTask را فراخوانی میکنیم (بهتر است صرفا یکبار متد AsTask را فراخوانی کنیم).

3️⃣ نمیتوانیم به یک ValueTask به صورت هم زمان در حالت MultiThreads دسترسی داشته باشیم.

4️⃣ به صورت پیش فرض خروجی عملیات async، نوع Task می‌باشد؛ مگر اینکه اغلب مراحل کار به صورت sync اجرا شود، مانند مثالی که بالاتر اشاره شد.

🔖هشتگ‌ها:
#Task #ValueTask
یک دموی عملی از مهاجرت بدون داون‌تایم با استفاده از Password Hashing 🔐

نیازهای امنیتی به مرور زمان تغییر می‌کنند. چیزی که پنج سال پیش «به اندازه‌ی کافی امن» محسوب می‌شد، ممکن است امروز حتی از یک ممیزی امنیتی هم رد نشود. ⚠️

شما باید به یک الگوریتم مدرن مثل Argon2 یا Bcrypt مهاجرت کنید.
اما اینجا یک مشکل اساسی وجود دارد: هش کردن یک عملیات یک‌طرفه است. شما نمی‌توانید هش‌های قبلی را برعکس کنید تا آن‌ها را «ارتقا» دهید. 🔁

اگر به سادگی پیاده‌سازی IPasswordHasher را عوض کنید، برنامه را می‌شکنید.
تمام کاربران فعلی که تلاش می‌کنند لاگین کنند با خطا مواجه می‌شوند، چون hasher جدید فرمت قدیمی را نمی‌فهمد. 🚫

در این مقاله می‌خواهم مفهوم zero-downtime migration را به صورت عملی نشان بدهم. 🛠

سیستم‌های واقعی محدودیت‌های بیشتری دارند (و شما نباید سیستم احراز هویت را از صفر خودتان بنویسید).
اما این یک مثال تمیز از یک الگو است که می‌توانید برای مهاجرت دیتابیس دوباره استفاده کنید:

• مهاجرت از فرمت قدیمی به فرمت جدید
• حفظ رفتار قبلی سیستم
• مهاجرت تدریجی داده‌ها
• حذف legacy فقط وقتی کار تمام شد
بیایید شروع کنیم. 🚀

The Naive Approach and Why It Fails
رویکرد ساده‌لوحانه و اینکه چرا شکست می‌خورد

فرض کنید یک سیستم احراز هویت ساده دارید.
می‌خواهید hasher قدیمی PBKDF2 را با یک پیاده‌سازی استاندارد Argon2 جایگزین کنید.

ممکن است فکر کنید:
«فقط پیاده‌سازی جدید را در dependency injection ثبت می‌کنم.»
// Switching from LegacyHasher to ModernHasher
builder.Services.AddSingleton<IPasswordHasher, ModernHasher>();

سناریوی شکست این است:

🔸️کاربران جدید:

ثبت‌نام می‌کنند و بدون مشکل لاگین می‌شوند. پسورد آن‌ها از روز اول با Argon2 هش شده است.

🔸️کاربران قدیمی:

کاربر پسورد درست را وارد می‌کند. سیستم هش قدیمی PBKDF2 را از دیتابیس می‌گیرد. 📦

🔸️فاجعه:

ءModernHasher تلاش می‌کند هش PBKDF2 را verify کند. فوراً fail می‌شود و 401 Unauthorized برمی‌گرداند. 💥

شما ناخواسته کل کاربران فعلی سیستم را لاک کرده‌اید.
ما به راهی نیاز داریم که هر دو الگوریتم همزمان پشتیبانی شوند، بدون اینکه کد لاگین به یک آشغال غیرقابل نگهداری تبدیل شود. 🧨

The Solution: Migration on Login
راه‌حل: مهاجرت در زمان لاگین 🔄

استراتژی ساده است:
ما دیتابیس را با یک batch job مهاجرت نمی‌دهیم.
ما کاربران را lazy و زمانی که هویت خودشان را ثابت می‌کنند مهاجرت می‌دهیم.

جریان کار به این شکل است:

Attempt 1:
تلاش کن پسورد را با الگوریتم جدید verify کنی. 🥇

Attempt 2 (Fallback):
اگر شکست خورد، بررسی کن ببین الگوریتم قدیمی می‌تواند آن را verify کند یا نه. 🥈

The Migration:
اگر verification با الگوریتم قدیمی موفق بود:

• کاربر را لاگین کن (موفقیت)
• بلافاصله پسورد را با الگوریتم جدید دوباره هش کن 🔐
• رکورد دیتابیس را آپدیت کن 🗄

از این به بعد، لاگین‌های بعدی این کاربر از مسیر استاندارد جدید عبور می‌کنند. 🎯
Implementation with .NET Keyed Services
پیاده‌سازی با استفاده از Keyed Services در NET. 🧩
در NET 8.، مایکروسافت قابلیتی به نام Keyed Services معرفی کرد که برای این سناریو کاملاً ایده‌آل است. این قابلیت به ما اجازه می‌دهد چند پیاده‌سازی از یک interface یکسان را ثبت کنیم و بر اساس نام (کلید) آن‌ها را دریافت کنیم. 🔑

1️⃣ Registering the Services

ما هر دو hasher را در فایل Program.cs ثبت می‌کنیم و به هرکدام یک کلید منحصربه‌فرد می‌دهیم:
// Register the implementations with specific keys
builder.Services.AddKeyedSingleton<IPasswordHasher, Pbdkf2PasswordHasher>("legacy");
builder.Services.AddKeyedSingleton<IPasswordHasher, Argon2PasswordHasher>("modern");

// (Optional) Register the modern one as the default for other services
builder.Services.AddSingleton<IPasswordHasher, Argon2PasswordHasher>();


2️⃣ The Login Command Handler

حالا منطق مهاجرت را پیاده‌سازی می‌کنیم. هر دو hasher را با استفاده از اتریبیوت [FromKeyedServices] تزریق می‌کنیم. 🧪
public class LoginCommandHandler(
IUserRepository userRepository,
[FromKeyedServices("modern")] IPasswordHasher newHasher,
[FromKeyedServices("legacy")] IPasswordHasher legacyHasher)
{
public async Task<AuthenticationResult> Handle(LoginCommand command)
{
var user = await userRepository.GetByEmailAsync(command.Email);
if (user is null)
{
return AuthenticationResult.Fail();
}

// 1. Try the new algorithm first (Happy Path)
if (newHasher.Verify(user.PasswordHash, command.Password))
{
return AuthenticationResult.Success(user);
}

// 2. Fallback: Check if it's a legacy hash
if (legacyHasher.Verify(user.PasswordHash, command.Password))
{
// 3. MIGRATION STEP: Re-hash and save
var newHash = newHasher.Hash(command.Password);

user.UpdatePasswordHash(newHash);
await userRepository.SaveChangesAsync();

return AuthenticationResult.Success(user);
}

return AuthenticationResult.Fail();
}
}

این کد تضمین می‌کند که کاربران فعال به صورت خودکار ارتقا پیدا کنند.
بعد از چند ماه، بخش عمده‌ای از کاربران شما روی الگوریتم جدید خواهند بود. 📈

Real-World Improvements
بهبودهای دنیای واقعی 🌍

در حالی که پیاده‌سازی بالا کار می‌کند، دو بهبود وجود دارد که آن را production-ready می‌کند.

1️⃣ Algorithm Prefixes
پیشوند الگوریتم‌ها

اتکا به روش «trial and error» برای verify کردن کار می‌کند، اما تمیزتر این است که دقیقاً بدانیم هر هش با چه الگوریتمی ساخته شده است.
الگوریتم‌های استاندارد معمولاً یک prefix دارند
(مثلاً Bcrypt با $2a$ یا $2b$ شروع می‌شود).
می‌توان از این موضوع برای مسیریابی بهینه استفاده کرد:
public bool IsLegacyHash(string hash)
{
// This assumes we're storing a prefix for PBKDF2 hashes. Something to consider.
return hash.StartsWith("pbkdf2$");
}

مزیت دیگر این کار این است که می‌توانیم مستقیماً از دیتابیس کوئری بگیریم و کاربرانی که هنوز روی فرمت قدیمی هستند را پیدا کنیم. 🔍

2️⃣ Feature Flags
فلگ‌های ویژگی 🚩

انجام یک write در دیتابیس هنگام لاگین باعث افزایش latency می‌شود. اگر ترافیک بالایی دارید، بهتر است کنترل این rollout را در دست بگیرید.
با قرار دادن منطق مهاجرت پشت یک Feature Flag، می‌توانید در صورت بالا رفتن فشار روی دیتابیس، مرحله‌ی نوشتن را غیرفعال کنید، در حالی که کاربران همچنان از مسیر fallback می‌توانند لاگین کنند.
if (await featureManager.IsEnabledAsync(FeatureFlags.MigratePasswords) &&
legacyHasher.Verify(user.PasswordHash, command.Password))
{
// Perform migration...
}


Finishing the Migration
پایان دادن به مهاجرت 🏁

بعد از مدتی (معمولاً چند ماه)، اکثر اکانت‌های فعال ارتقا پیدا می‌کنند.
سپس می‌توانید یک اسکریپت cleanup اجرا کنید تا هش‌های قدیمی باقی‌مانده را شناسایی کنید و کاربران مربوطه را مجبور کنید در لاگین بعدی پسورد خود را ریست کنند.
در این نقطه می‌توانید موارد زیر را حذف کنید:
• ثبت legacy hasher
• مسیر verification مربوط به legacy
• ءfeature flag
و مهاجرت کامل می‌شود.
جمع‌بندی 🧠

یک ارتقای «به ظاهر ساده» در hashing، در واقع یک data migration است.
این مقاله درباره‌ی الگوی مهاجرت بود، نه درباره‌ی اختراع دوباره‌ی سیستم احراز هویت.
الگوی zero-downtime به این شکل است:

🔹️فرمت جدید برای نوشتن‌های جدید
🔸️پشتیبانی از هر دو فرمت برای خواندن
🔹️مهاجرت تدریجی داده‌های قدیمی (migrate-on-login یک ترفند عالی است)
ُ🔸️قرار دادن پشت feature flag
🔹️حذف legacy وقتی کار تمام شد

با اجازه دادن به هم‌زیستی فرمت قدیمی و جدید برای مدتی، شما یک مهاجرت کاملاً seamless خواهید داشت.
وقتی مانیتورینگ نشان داد که ۹۹٪ کاربران فعال مهاجرت کرده‌اند، می‌توانید کاربران باقی‌مانده روی فرمت قدیمی را شناسایی کنید و در تلاش بعدی، آن‌ها را مجبور به reset پسورد کنید.
امیدوارم مفید بوده باشد!

🔖هشتگ‌ها:
#Security #PasswordHashing #ZeroDowntime #MigrationPatterns
🚫 Stop Using Boolean Parameters
اجتناب از Flag Argument

مثال بد:
user.SetStatus(true);

وقتی این کد را می‌خوانیم، باید مکث کنیم و فکر کنیم:

این true یعنی چی؟ Active؟ Verified؟ Deleted؟

این مسئله به نام Flag Argument شناخته می‌شود.
یعنی پارامتری که مفهومش مبهم است و خواننده را مجبور می‌کند برای فهمیدن هدف متد، به تعریف آن مراجعه کند.

راه‌حل: استفاده از متدهای صریح

به جای کد قبلی از:
user.Activate();

استفاده کنید.

مزایا:

هدف واضح است
زبان طبیعی و قابل فهم
نیاز به توضیح یا comment ندارد

💡 نکته مهم:
کد بسیار بیشتر خوانده می‌شود تا نوشته.
پس هر چه intent کد روشن‌تر باشد، فهم آن سریع‌تر و اشتباهات کمتر می‌شود.
🔖هشتگ‌ها:
#CleanCode #FlagArgument #CodeReadability
Solving Message Ordering from First Principles
حل مسئله‌ی ترتیب پیام‌ها از اصول اولیه 🧠

بیشتر سیستم‌ها به global message ordering نیاز ندارند. 🌍

آن‌ها به چیزی ساده‌تر و در عین حال کاربردی‌تر نیاز دارند:
این‌که رویدادها برای هر aggregate به صورت ترتیبی پردازش شوند. 🔄

برای هر OrderId، برای هر InvoiceId، برای هر CustomerId،یا هر مرزی که برای aggregate خود تعریف کرده‌اید.
می‌توانید این مرز را هرچقدر که لازم دارید گسترده یا محدود کنید. 🎯

این مسئله در ابتدا شبیه یک مشکل در حوزه‌ی eventing به نظر می‌رسد، اما اگر الزامات را تا نتیجه‌ی منطقی‌شان دنبال کنید، در نهایت به یک workflow می‌رسید.
و آن workflow یک نام دارد: Saga 🧩

Domain Events Feel Like the Clean Solution
ءDomain Eventها شبیه راه‌حل تمیز به نظر می‌رسند
ءDomain Eventها جذاب هستند چون از اصول اولیه می‌آیند:

• یک aggregate تغییر وضعیت می‌دهد 🔁
• ءeventهایی منتشر می‌کند که توضیح می‌دهند چه اتفاقی افتاده 📢
• ءhandlerها واکنش نشان می‌دهند و کار مفید انجام می‌دهند ⚙️

و شما یک مدل ذهنی قشنگ هم دارید:
State change → Event → Reaction 🧠➡️📨➡️⚡️

یک مثال معمول:
• OrderPlaced
• PaymentCaptured
• OrderShipped

اما یک مشکل وجود دارد… ⚠️

ءDomain Eventها وقتی می‌خواهید از آن‌ها برای integration استفاده کنید، شکننده می‌شوند.

اگر مستقیماً از داخل transaction رویداد منتشر کنید،دارید درستی بیزینس را به یک side effect غیرقابل اعتماد گره می‌زنید:

🔸️ءtransaction موفق می‌شود ولی publish شکست می‌خورد
🔹️ءpublish موفق می‌شود ولی transaction rollback می‌شود 🔙
🔸️مصرف‌کننده‌ها duplicate پردازش می‌کنند 🔁
🔹️ءretryها باعث reordering می‌شوند 🔀

پس ما مدل را نگه می‌داریم…
اما delivery را مقاوم (hardened) می‌کنیم. 🛡

The Outbox Makes Publishing Reliable (but not ordered)
ءOutbox انتشار رو قابل‌اعتماد می‌کنه (اما مرتب نه) 📦
با Outbox، ما eventهای خروجی رو در همان transaction‌ای ذخیره می‌کنیم که update روی aggregate انجام می‌شه. 🧾

بعد، یک background publisher می‌آید و Outbox رو می‌خونه و eventها رو به یک queue ارسال می‌کنه. 📤

این کار مشکل reliability رو حل می‌کنه:
• اگر transaction commit بشه، event ذخیره شده
• اگر publisher کرش کنه، می‌تونه بعداً ادامه بده 🔄

می‌تونیم با خیال راحت retry کنیم ♻️
حالا انتشار eventها قابل‌اعتماد شده. 👍

اما هنوز ترتیب (ordering) در پردازش eventها تضمین نشده. ⚠️

Competing Consumers Are Great, Until Order Matters
ءCompeting Consumerها عالی‌اند… تا وقتی ترتیب مهم نشه 🚦
به محض این‌که eventها وارد queue می‌شن،
معمولاً با ساده‌ترین راه scale می‌کنیم: competing consumers.

چندین instance از یک queue مشترک مصرف می‌کنن تا throughput بالا بره 📈

این کار جواب می‌ده…
تا زمانی که ترتیب اهمیت پیدا کنه ⛔️

دو event برای یک OrderId ممکنه هم‌زمان پردازش بشن:

• ءConsumer A رویداد PaymentCaptured رو دریافت می‌کنه 💳
• ءConsumer B رویداد OrderPlaced رو دریافت می‌کنه 🛒

ءside effectها خارج از ترتیب اجرا می‌شن 🔀

حتی اگر eventها به ترتیب publish شده باشن،retry و redelivery می‌تونن ترتیب پردازش رو به‌هم بزنن 🔁

و حالا شما با یک باگ ظریف طرف هستید
که فقط زیر load بالا خودش رو نشون می‌ده 🐛🔥

این همون نکته‌ی کلیدیه: queueها کار رو scale می‌کنن، نه invariantهای شما رو 🎯

چیزی که واقعاً می‌خوایم: ترتیب به‌ازای هر Aggregate 🔗

شما به یک خط مرتب برای همه‌چیز نیاز ندارید 🚫
شما به چند خط مرتب مستقل نیاز دارید،
یکی برای هر aggregate 🧵

این معمولاً منطقیه چون:
ءaggregateها از قبل مرزهای consistency رو مشخص می‌کنن 🧱

• ءeventها ذاتاً به ترتیب تولید می‌شن (v1، v2، v3 …) 🔢
• ترتیب «درست» همون timeline خود aggregate هست ⏱️

اگر بتونیم تضمین کنیم که
در هر لحظه فقط یک handler ،eventهای مربوط به یک aggregate خاص رو پردازش کنه، بخش بزرگی از مشکل حل می‌شه

مستقیم‌ترین راه‌حل، که در عین حال ساده‌ترین هم هست:

👉 استفاده از یک consumer واحد برای کل stream

این کار ترتیب رو enforce می‌کنه،
به شرطی که eventها به ترتیب publish شده باشن

اما این راه‌حل یک ایراد واضح داره… ⚠️
A Single Consumer Solves Ordering But Limits Scale
یک Consumer واحد ترتیب رو حل می‌کنه، اما مقیاس‌پذیری رو محدود می‌کنه ⚖️
یک consumer یعنی:
• سقف throughput (فقط یک worker) 🧱
• افزایش latency زیر load بالا 🐢
• ءscaling عمودی می‌شه، نه افقی 📏

حتی اگر eventها سبک باشن، شما به‌صورت مصنوعی کل سیستم رو bottleneck کردید. 🚧

پس ما می‌خوایم:
ترتیب به‌ازای هر aggregate 🔗
مقیاس‌پذیری افقی 🧩
قابلیت اطمینان (Outbox همچنان باقی می‌مونه) 📦

اینجاست که تیم‌ها معمولاً «مرحله‌ی بعدی» رو اختراع می‌کنن. 💡

Publish the Next Message From the Handler
پیام بعدی رو از داخل Handler منتشر کن 📤
اگه competing consumerها ترتیب رو می‌شکنن، یک ایده‌ی طبیعی اینه:
👉 نذاریم queue تصمیم بگیره پیام بعدی چیه، خودمون تصمیم بگیریم.

به‌جای این‌که همه‌ی eventها رو بریزیم تو queue و بذاریم consumerها با هم race کنن،‌می‌ریم سراغ یک مدل زنجیره‌ای:
🔹️یک پیام برای یک aggregate رو handle کن
🔹️وقتی تموم شد، پیام بعدی رو publish کن
🔹️تا step بعدی اجرا بشه

حالا سیستم برای هر aggregate در هر لحظه فقط یک پیام رو پردازش می‌کنه. 🧵

و این لحظه‌ی کلیدیه:

🚨 شما دیگه «event handler» نمی‌سازید.
شما دارید workflow می‌سازید.

و اسم اون workflow چیه؟ 👉 یک Saga.

Congratulations, You Built a Choreographed Saga
تبریک! تو یک Saga کُریوگرافی‌شده ساختی 🎭
یک choreographed saga یعنی:

• هر step به یک event واکنش نشون می‌ده
• یک کاری انجام می‌ده
• ءevent بعدی رو منتشر می‌کنه تا step بعدی شروع بشه
• هیچ coordinator مرکزی وجود نداره.

در عوض، یک زنجیره داریم:
«وقتی X اتفاق افتاد، Y رو انجام بده، بعد Z رو publish کن»

این الگو دقیقاً با نیاز جدیدت فیت می‌شه:
🔹️ترتیب به‌ازای هر aggregate حفظ می‌شه (زنجیره‌ای) 🔗
🔸️می‌تونی روی aggregateهای مختلف scale کنی (چندین زنجیره هم‌زمان) 🧩
🔹️هر step ایزوله و قابل retry هست ♻️

و یک دیسیپلین مفید هم تحمیل می‌کنه:
• «قدم بعدی چیه؟» صریح و شفاف می‌شه
• مرز بین stepها واضح‌تر می‌شه
• می‌تونی کل workflow رو به‌صورت یک sequence مشاهده کنی 👀

اما choreography یک محدودیت داره:
کنترل پخش شده است،
پس track کردن پیشرفت و مدیریت خطاها می‌تونه کثیف بشه. 🧨

پس می‌ریم سراغ قدم نهایی.

If You Want Control, Introduce a State Machine Saga
اگر کنترل می‌خوای، Saga مبتنی بر State Machine بساز 🧠
وقتی workflow مهم می‌شه، معمولاً این‌ها رو می‌خوای:

🔸️یک جای واحد که state فعلی رو بدونه 🗂
🔸️دید روی پیشرفت («کجا گیر کردیم؟») 🔍
🔸️ءtimeout و retry صریح
🔸️اکشن جبرانی وقتی چیزی fail می‌شه 🔄

اینجاست که از choreography می‌ری به سمت orchestration
با استفاده از یک state machine saga:

🔹️ءsaga وضعیت workflow رو نگه می‌داره
🔹️ءeventها transitionها رو جلو می‌برن
🔹️ءsaga تصمیم می‌گیره پیام بعدی چی باشه

تو این مدل، کنترل و observability رو به دست میاری. 🎛

و نکته‌ی مهم:
🔸️این جای Outbox رو نمی‌گیره.
🔸️📦 تو هنوز به انتشار قابل‌اعتماد نیاز داری.
🔸️تو فقط workflow رو explicit کردی.
Broker Support Helps with Ordering, not Correctness
پشتیبانی Broker به ترتیب کمک می‌کنه، نه به درستی سیستم ⚖️
بد نیست این نکته رو صریح بگیم:
همیشه لازم نیست همه‌ی این‌ها رو خودت بسازی.

خیلی از message brokerهای معروف، primitiveهای فنی برای پردازش مرتب به‌ازای هر key (یعنی aggregate ID) دارن:

🔹️ءAmazon SQS FIFO message groups (به‌ازای هر key)
🔸️ءAzure Service Bus sessions (به‌ازای هر key)
🔹️ءKafka Partitions در یک log (key → partition → ordered stream)
🔸️ءRabbitMQ با semantics مدل "single active consumer" (به‌ازای هر queue)

این قابلیت‌ها رایج‌ترین حالت خراب شدن competing consumerها رو حذف می‌کنن:
پردازش هم‌زمان پیام‌ها برای یک aggregate واحد. 🚫

اما حتی با ترتیب بی‌نقص به‌ازای هر aggregate،باز هم برای درست کار کردن سیستم به الگوهای دیگه نیاز داری:

🔹️ءOutbox برای انتشار قابل‌اعتماد (ترتیب بی‌فایده است اگر eventها گم بشن) 📦
🔸️ءConsumerهای idempotent / الگوی Inbox چون retry و duplicate همچنان اتفاق می‌افته ♻️
🔹️مرزهای consistency (چی رو می‌شه داخل transaction انجام داد و چی رو نه) 🔒
🔸️ءTimeout + compensation وقتی «دنباله‌ی مرتب» در واقع یک workflow تجاریه که ممکنه نیمه‌کاره fail بشه

پس ordering در سطح broker یک پایه‌ی عالیه.
پیچیدگی تصادفی رو کم می‌کنه.
اما نیاز به مدل‌سازی صریح کارهای طولانی‌مدت رو حذف نمی‌کنه، وقتی بیزنس بهش نیاز داره. 🧩

جمع‌بندی 🧠

اگه مسئله رو از first principles دنبال کنی:

🔹️ءaggregateها مرزی هستن که ترتیب داخلشون مهمه
🔸️ءOutbox انتشار event رو قابل‌اعتماد می‌کنه
🔹️ءcompeting consumerها ترتیب per-aggregate رو می‌شکنن
🔸️یک consumer واحد ترتیب رو برمی‌گردونه ولی throughput رو محدود می‌کنه
🔹️انتشار «پیام بعدی» باعث پیشرفت ترتیبی برای هر aggregate می‌شه
🔸️اون پیشرفت ترتیبی یک Saga است
(اول choreographed، و وقتی کنترل خواستی state machine)

پس تو به‌صورت تصادفی چیزی رو دوباره اختراع نکردی.

تو کشف کردی که:
«پردازش مرتب به‌ازای هر aggregate در مقیاس بالا»
یک feature از queue نیست.

این یک workflow است.
و Saga مدلی است که ما برای workflow در سیستم‌های توزیع‌شده استفاده می‌کنیم. 🌐

وقتی این رو ببینی،
دیگه با queueها سر ordering دعوا نمی‌کنی.

تو workflowی که بیزنس واقعاً نیاز داره رو طراحی می‌کنی. 🎯
امیدوارم مفید بوده باشه! 🚀
ASP.NET Core Middleware 🧩

ءMiddleware نرم‌افزاری است که در یک pipeline برنامه کنار هم قرار می‌گیرد تا درخواست‌ها و پاسخ‌ها را مدیریت کند. هر کامپوننت:
تصمیم می‌گیرد که آیا درخواست را به کامپوننت بعدی در pipeline ارسال کند یا نه. 🔀

می‌تواند قبل و بعد از کامپوننت بعدی، پردازش انجام دهد. ⏱️

برای ساختن request pipeline از Request Delegate‌ها استفاده می‌شود. این delegateها هر درخواست HTTP را مدیریت می‌کنند.

ءRequest delegateها با استفاده از متدهای extension زیر پیکربندی می‌شوند:
• Run
• Map
• Use

یک request delegate می‌تواند:
• به‌صورت in-line و با یک متد anonymous تعریف شود (که به آن in-line middleware می‌گویند)،
• یا در قالب یک کلاس قابل استفاده مجدد تعریف شود.

این کلاس‌های reusable و متدهای anonymous در واقع همان middleware یا middleware component هستند.
هر middleware در pipeline مسئول است یا کامپوننت بعدی را فراخوانی کند، یا pipeline را short-circuit کند.

وقتی یک middleware pipeline را short-circuit می‌کند، به آن terminal middleware گفته می‌شود،
چون جلوی ادامه‌ی پردازش توسط middlewareهای بعدی را می‌گیرد.

ساخت middleware pipeline با WebApplication 🛠

ءrequest pipeline در ASP.NET Core شامل یک دنباله از request delegateها است که یکی پس از دیگری فراخوانی می‌شوند.

⚡️ ASP.NET Core Request Delegates و Pipeline

هر delegate می‌تواند قبل و بعد از delegate بعدی عملیات انجام دهد.

توصیه: exception-handling delegateها را اوایل pipeline قرار دهید تا بتوانند خطاهای رخ داده در مراحل بعدی را بگیرند.

ساده‌ترین اپلیکیشن ASP.NET Core 🟢

در ساده‌ترین حالت، یک single request delegate تعریف می‌شود که همه درخواست‌ها را هندل می‌کند.

در این حالت، pipeline واقعی وجود ندارد.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.Run(async context =>
{
await context.Response.WriteAsync("Hello world!");
});

app.Run();


زنجیره کردن چند delegate با Use 🔗

ءnext نمایانگر delegate بعدی در pipeline است.
می‌توان pipeline را با عدم فراخوانی next کوتاه کرد (Short-circuit).

معمولاً می‌توان کارها را قبل و بعد از next انجام داد:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.Use(async (context, next) =>
{
// کاری که می‌تواند Response بنویسد
await next.Invoke();
// کارهای logging یا دیگر عملیات غیر از نوشتن Response
});

app.Run(async context =>
{
await context.Response.WriteAsync("Hello from 2nd delegate.");
});

app.Run();


Short-Circuiting Pipeline ⚡️

اگر یک delegate درخواست را به delegate بعدی ندهد، pipeline short-circuit می‌شود.

کاربرد: جلوگیری از کار غیرضروری

مثال: Static File Middleware می‌تواند terminal middleware باشد و پس از پردازش فایل، pipeline را کوتاه کند.

توجه: Middlewareهایی که قبل از terminal middleware آمده‌اند، هنوز بعد از next.Invoke کد اجرا می‌کنند.

⚠️ هشدار مهم

بعد یا هنگام ارسال Response به کلاینت، next.Invoke را فراخوانی نکنید!

تغییر header یا status code بعد از شروع Response → Exception

نوشتن به body بعد از next ممکن است:
باعث violation پروتکل شود (مثلاً نوشتن بیشتر از Content-Length)

فرمت body خراب شود (مثلاً HTML footer در فایل CSS)

نکته: HasStarted می‌تواند کمک کند بررسی کنید که آیا headers یا body قبلاً ارسال شده‌اند یا نه.

Run delegates🏁

ءRun delegateها پارامتر next دریافت نمی‌کنند.
اولین Run delegate همیشه terminal است و pipeline را خاتمه می‌دهد.
ءRun یک convention است. بعضی از middleware componentها ممکن است متدهایی مثل Run[Middleware] ارائه دهند که در انتهای pipeline اجرا می‌شوند:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.Use(async (context, next) =>
{
    // Do work that can write to the Response.
    await next.Invoke();
    // Do logging or other work that doesn't write to the Response.
});

app.Run(async context =>
{
    await context.Response.WriteAsync("Hello from 2nd delegate.");
});

app.Run();

در مثال بالا، Run delegate عبارت "Hello from 2nd delegate." را در response می‌نویسد و سپس pipeline را خاتمه می‌دهد.
اگر بعد از Run delegate، یک Use یا Run دیگر اضافه شود، دیگر فراخوانی نخواهد شد. 🛑
ترتیب middlewareها 🧱

در این دیاگرام کل pipeline پردازش درخواست در ASP.NET Core MVC و Razor Pages را نشان می‌دهد.
می‌توانید ببینید که در یک اپلیکیشن معمولی، middlewareهای موجود چه ترتیبی دارند و middlewareهای سفارشی کجا اضافه می‌شوند.
شما کنترل کامل دارید که middlewareهای موجود را جابه‌جا کنید یا middleware جدید تزریق کنید، متناسب با سناریوهای خودتان. 🔧

ءEndpoint middleware در دیاگرام بالا، filter pipeline مربوط به نوع اپلیکیشن (MVC یا Razor Pages) را اجرا می‌کند.

ءRouting middleware در دیاگرام بالا بعد از Static Files نشان داده شده است.
این همان ترتیبی است که قالب‌های پیش‌فرض پروژه با فراخوانی صریح app.UseRouting پیاده‌سازی می‌کنند.
اگر app.UseRouting را صدا نزنید، Routing middleware به‌صورت پیش‌فرض در ابتدای pipeline اجرا می‌شود.
برای اطلاعات بیشتر، بخش Routing را ببینید.

ترتیبی که middlewareها در فایل Program.cs اضافه می‌شوند، دقیقاً ترتیب اجرای آن‌ها روی request و ترتیب معکوس برای response را مشخص می‌کند.
این ترتیب برای امنیت، کارایی و عملکرد صحیح کاملاً حیاتی است. ⚠️

ترتیب پیشنهادی middlewareهای امنیتی

کد زیر در Program.cs middlewareهای مربوط به امنیت را در ترتیب توصیه‌شده اضافه می‌کند:
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using WebMiddleware.Data;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
.AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddRazorPages();
builder.Services.AddControllersWithViews();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
app.UseMigrationsEndPoint();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
// app.UseCookiePolicy();

app.UseRouting();
// app.UseRateLimiter();
// app.UseRequestLocalization();
// app.UseCors();

app.UseAuthentication();
app.UseAuthorization();
// app.UseSession();
// app.UseResponseCompression();
// app.UseResponseCaching();

app.MapRazorPages();
app.MapDefaultControllerRoute();

app.Run();

در کد بالا: middlewareهایی که هنگام ساخت یک وب‌اپ با حساب کاربری فردی اضافه نمی‌شوند، کامنت شده‌اند.

همه‌ی middlewareها دقیقاً در همین ترتیب ظاهر نمی‌شوند، اما بسیاری از آن‌ها همین‌طور هستند. برای مثال:

قوانین مهم ترتیب middlewareها 📌

ءUseCors، UseAuthentication و UseAuthorization باید دقیقاً به همین ترتیب باشند.

ءUseCors در حال حاضر باید قبل از UseResponseCaching قرار بگیرد. این الزام در GitHub issue شماره dotnet/aspnetcore #23218 توضیح داده شده است.

ءUseRequestLocalization باید قبل از هر middlewareای باشد که ممکن است culture درخواست را بررسی کند، مثلاً ()app.UseStaticFiles

وقتی rate limiting به‌صورت endpoint-specific استفاده می‌شود (مثلاً با [EnableRateLimiting])، باید UseRateLimiter بعد از UseRouting صدا زده شود.

اگر فقط global limiterها استفاده شوند، می‌توان UseRateLimiter را قبل از UseRouting هم صدا زد.

ترتیب‌های جایگزین در برخی سناریوها

در بعضی سناریوها ترتیب middleware متفاوت است.
مثلاً ترتیب caching و compression وابسته به سناریو است و چندین ترتیب معتبر وجود دارد. برای مثال:
app.UseResponseCaching();
app.UseResponseCompression();

در این حالت، مصرف CPU ممکن است کاهش پیدا کند چون response فشرده‌شده cache می‌شود،
اما ممکن است چند نسخه‌ی مختلف از یک resource با الگوریتم‌های فشرده‌سازی متفاوت مثل Gzip یا Brotli در cache ذخیره شوند. 📦