C# Geeks (.NET) – Telegram
📖 سری آموزشی کتاب C# 12 in a Nutshell

🤯 تله‌ی کستینگ در جنریک‌ها: چرا کامپایلر گیج می‌شود و راه حل چیست؟

به این کد نگاه کنید. به نظر کاملاً منطقی میاد، ولی کامپایل نمیشه! چرا؟
StringBuilder Foo<T>(T arg)
{
if (arg is StringBuilder)
return (StringBuilder)arg; // خطای زمان کامپایل!
// ...
}

امروز می‌خوایم یه نکته خیلی عمیق و فنی در مورد کست کردن پارامترهای جنریک (T) رو یاد بگیریم.

1️⃣ مشکل کجاست؟ ابهام در نوع تبدیل

مشکل اینه که کامپایلر در زمان کامپایل، نمی‌دونه T قراره چه نوعی باشه. وقتی شما می‌نویسید (SomeType)arg، کامپایلر نمی‌دونه منظور شما کدوم یکی از ایناست:

🔹 تبدیل عددی (Numeric)

🔹 تبدیل رفرنس (Reference)

🔹 تبدیل Boxing/Unboxing

🔹 تبدیل سفارشی (Custom)

این ابهام باعث میشه کامپایلر جلوی شما رو بگیره تا از خطاهای پیش‌بینی نشده جلوگیری کنه.

2️⃣ راه حل‌ها: شفاف‌سازی برای کامپایلر

برای رفع این ابهام، باید به کامپایلر بگیم دقیقاً منظورمون چه نوع تبدیلیه. دو راه حل اصلی وجود داره:

راه حل اول: استفاده از اپراتور as (برای Reference Typeها)

اپراتور as فقط و فقط برای تبدیل‌های رفرنس استفاده میشه. چون ابهامی نداره، کامپایلر قبولش می‌کنه. اگه تبدیل شکست بخوره، null برمی‌گردونه.
StringBuilder Foo<T>(T arg)
{
StringBuilder sb = arg as StringBuilder;
if (sb != null)
return sb;
// ...
}


راه حل دوم: کست کردن به object (راه حل عمومی) 🚀

راه حل عمومی‌تر که برای Value Typeها (Unboxing) هم کار می‌کنه، اینه که اول متغیر رو به object کست کنید. این کار به کامپایلر میگه "منظور من یه تبدیل رفرنس یا unboxing هست، نه عددی یا سفارشی".

برای Reference Type:
// کامپایلر می‌فهمه که منظور، تبدیل رفرنس است
return (StringBuilder)(object)arg;
برای Value Type:
______

// کامپایلر می‌فهمه که منظور، Unboxing است
int Foo<T>(T x) => (int)(object)x;


🤔 حرف حساب و تجربه شما

این یه نکته ظریف ولی خیلی مهمه که نشون میده کامپایلر #C چقدر به ایمنی نوع و رفع ابهام اهمیت میده.

🔖 هشتگ‌ها:
#CSharp #DotNet #OOP #Generics #AdvancedCSharp
📖 سری آموزشی کتاب C# 12 in a Nutshell

🧬 جنریک‌های پیشرفته: Covariance و راز تبدیل <IEnumerable<string به <IEnumerable<object
تا حالا براتون سوال شده چرا این کد در #C درسته:
IEnumerable<object> list = new List<string>();

ولی این کد خطا میده:
List<object> list = new List<string>();

جواب این سوال تو یه مفهوم پیشرفته و خیلی مهم به اسم Covariance نهفته‌ست.

1️⃣ Covariance چیست؟

به زبان ساده، Covariance میگه شما می‌تونید یه نوع جنریک با پارامتر فرزند (مثل <string>) رو به همون نوع جنریک با پارامتر پدر (مثل <object>) تبدیل کنید. (چون string یک نوع از object است).

این قابلیت در #C فقط برای اینترفیس‌ها و دلیگیت‌ها فعاله، نه برای کلاس‌ها.

2️⃣ چرا کلاس‌ها Covariant نیستن؟ (تله‌ی Type Safety) ⚠️

چرا نمی‌تونیم یه <List<Bear رو به <List<Animal تبدیل کنیم؟ چون اگه این کار مجاز بود، می‌تونستیم یه فاجعه منطقی به بار بیاریم و ایمنی نوع (Type Safety) رو که #C بهش افتخار می‌کنه، از بین ببریم:
class Animal { }
class Bear : Animal { }
class Camel : Animal { }
// لیستی از خرس‌ها
List bears = new List();

// خطای زمان کامپایل! اگر این خط مجاز بود...
// List animals = bears;

// ...اونوقت می‌تونستیم یه شتر رو تو لیست خرس‌ها بریزیم!
// animals.Add(new Camel()); // 💥 فاجعه در زمان اجرا!


برای جلوگیری از این خطای منطقی، کلاس‌های جنریک مثل <List<T که متدهای Add دارن (یعنی "نوشتنی" هستن)، به صورت پیش‌فرض Covariant نیستن. اما اینترفیسی مثل <IEnumerable<T که فقط "خواندنی" هست، می‌تونه Covariant باشه.

3️⃣ پس چطور با این محدودیت کار کنیم؟

حالا فرض کنید یه متد Wash داریم که می‌خواد یه لیستی از حیوانات رو بشوره. اگه ورودی رو <List<Animal بذاریم، نمی‌تونیم بهش <List<Bear پاس بدیم.

راه حل، جنریک کردن خود متد با استفاده از قیدهاست:
public class ZooCleaner
{
// این متد، هر نوع لیستی رو قبول می‌کنه که T اون از Animal ارث برده باشه
public static void Wash<T>(List<T> animals) where T : Animal
{
foreach (T animal in animals)
{
Console.WriteLine($"Washing a {animal.GetType().Name}");
}
}
}
// --- نحوه استفاده ---
List bears = new List();
ZooCleaner.Wash(bears); // حالا درسته!


🤔 حرف حساب و تجربه شما

Covariance
یه مفهوم عمیقه که اساس کارکرد خیلی از اینترفیس‌های مهم دات‌نت مثل <IEnumerable<T هست. درک اون، شما رو به درک عمیق‌تری از سیستم تایپینگ #C می‌رسونه.

🔖 هشتگ‌ها:
#CSharp #DotNet #OOP #Generics #AdvancedCSharp
Forwarded from thisisnabi.dev [Farsi]
خودش serialize میکنه براتون.
مصرف‌کننده Idempotent - مدیریت پیام‌های تکراری 🛡


چه اتفاقی می‌افتد وقتی یک پیام دوباره تلاش (retry) می‌شود در یک سیستم رویداد-محور (event-driven)؟
این اتفاق بیشتر از آن چیزی که فکر می‌کنید، رخ می‌دهد.

بدترین سناریو 😱 این است که پیام دو بار پردازش شود، و عوارض جانبی (side effects) نیز می‌توانند بیش از یک بار اعمال شوند.

آیا می‌خواهید حساب بانکی شما دو بار شارژ شود؟ 💳
من فرض می‌کنم پاسخ منفی است، البته.

شما می‌توانید از الگوی مصرف‌کننده Idempotent برای حل این مشکل استفاده کنید.

در این مقاله به شما نشان خواهم داد:

🔹 الگوی مصرف‌کننده Idempotent چگونه کار می‌کند

🔹 چگونه یک مصرف‌کننده Idempotent را پیاده‌سازی کنیم

🔹 مزایا و معایبی که باید در نظر بگیرید

بیایید ببینیم چرا الگوی مصرف‌کننده Idempotent ارزشمند است.

الگوی مصرف‌کننده Idempotent چگونه کار می‌کند؟ 🤔
ایده پشت الگوی مصرف‌کننده Idempotent چیست؟

یک عملیات idempotent عملیاتی است که اگر بیش از یک بار با همان پارامترهای ورودی فراخوانی شود، هیچ تأثیر اضافی ندارد.

ما می‌خواهیم از مدیریت یک پیام یکسان بیش از یک بار، اجتناب کنیم.
این امر نیازمند تضمین تحویل پیام دقیقاً-یک‌باره (Exactly-once) از سیستم پیام‌رسانی ما خواهد بود. و این یک مشکل واقعاً سخت برای حل کردن در سیستم‌های توزیع‌شده است.

یک تضمین تحویل سست‌تر، حداقل-یک‌باره (At-least-once) است، که در آن ما آگاه هستیم که تلاش مجدد می‌تواند اتفاق بیفتد و می‌توانیم یک پیام یکسان را بیش از یک بار دریافت کنیم.

الگوی مصرف‌کننده Idempotent با تحویل پیام حداقل-یک‌باره به خوبی کار می‌کند و مشکل پیام‌های تکراری را حل می‌کند.

در اینجا الگوریتم از لحظه‌ای که ما یک پیام دریافت می‌کنیم، به این شکل است: ⚙️

1️⃣ آیا پیام قبلاً پردازش شده است؟

2️⃣ اگر بله، این یک پیام تکراری است و کاری برای انجام دادن وجود ندارد.

3️⃣ اگر نه، ما باید پیام را مدیریت کنیم.

4️⃣ ما همچنین باید شناسه پیام را ذخیره کنیم.
ما به یک شناسه منحصر به فرد 🆔 برای هر پیامی که دریافت می‌کنیم، و یک جدول در دیتابیس برای ذخیره پیام‌های پردازش شده نیاز داریم.

با این حال، جالب است که چگونه ما مدیریت پیام و ذخیره‌سازی شناسه پیام پردازش شده را پیاده‌سازی می‌کنیم.

شما می‌توانید مصرف‌کننده idempotent را به عنوان یک دکوراتور (decorator) 🎨 در اطراف یک message handler معمولی پیاده‌سازی کنید.

من دو پیاده‌سازی را به شما نشان خواهم داد:

🔹 مصرف‌کننده idempotent تنبل (Lazy)

🔹 مصرف‌کننده idempotent مشتاق (Eager)

مصرف‌کننده Idempotent تنبل (Lazy) 😴

مصرف‌کننده idempotent تنبل با جریانی که در الگوریتم بالا نشان داده شد، مطابقت دارد.
"تنبل" به نحوه ذخیره‌سازی شناسه پیام برای علامت‌گذاری آن به عنوان پردازش شده، اشاره دارد.

در مسیر خوشحال (happy path) ، ما پیام را مدیریت کرده و شناسه پیام را ذخیره می‌کنیم.
اگر message handler یک استثنا پرتاب کند 💥، ما هرگز شناسه پیام را ذخیره نمی‌کنیم و مصرف‌کننده می‌تواند دوباره اجرا شود.

در اینجا شکل پیاده‌سازی آمده است: 👇
public class IdempotentConsumer<T> : IHandleMessages<T>
where T : IMessage
{
private readonly IMessageRepository _messageRepository;
private readonly IHandleMessages<T> _decorated;

public IdempotentConsumer(
IMessageRepository messageRepository,
IHandleMessages<T> decorated)
{
_messageRepository = messageRepository;
_decorated = decorated;
}

public async Task Handle(T message)
{
if (_messageRepository.IsProcessed(message.Id))
{
return;
}

await _decorated.Handle(message);

_messageRepository.Store(message.Id);
}
}
مصرف‌کننده Idempotent مشتاق (Eager) 💪

مصرف‌کننده idempotent مشتاق کمی با پیاده‌سازی تنبل متفاوت است، اما نتیجه نهایی یکسان است.

در این نسخه، ما مشتاقانه شناسه پیام را در دیتابیس ذخیره کرده و سپس به مدیریت پیام ادامه می‌دهیم.

اگر handler یک استثنا پرتاب کند، ما باید در دیتابیس پاک‌سازی انجام داده و شناسه پیام ذخیره شده مشتاقانه را حذف کنیم.
در غیر این صورت، ما با ریسک رها کردن سیستم در یک وضعیت ناسازگار مواجه می‌شویم، زیرا پیام هرگز به درستی مدیریت نشده است.

در اینجا شکل پیاده‌سازی آمده است:
public class IdempotentConsumer<T> : IHandleMessages<T>
where T : IMessage
{
private readonly IMessageRepository _messageRepository;
private readonly IHandleMessages<T> _decorated;

public IdempotentConsumer(
IMMessageRepository messageRepository,
IHandleMessages<T> decorated)
{
_messageRepository = messageRepository;
_decorated = decorated;
}

public async Task Handle(T message)
{
try
{
if (_messageRepository.IsProcessed(message.Id))
{
return;
}

_messageRepository.Store(message.Id);

await _decorated.Handle(message);
}
catch (Exception e)
{
_messageRepository.Remove(message.Id);
throw;
}
}
}


به طور خلاصه 📝

Idempotency
یک مشکل جالب برای حل کردن در یک سیستم نرم‌افزاری است.
برخی عملیات به طور طبیعی idempotent هستند و ما به سربار (overhead) الگوی مصرف‌کننده Idempotent نیازی نداریم.
با این حال، برای آن دسته از عملیاتی که به طور طبیعی idempotent نیستند، مصرف‌کننده Idempotent یک راه‌حل عالی است.

الگوریتم سطح بالا ساده است و شما می‌توانید دو رویکرد را در پیاده‌سازی در پیش بگیرید:

🔹 ذخیره‌سازی تنبل (Lazy) شناسه‌های پیام

🔹 ذخیره‌سازی مشتاق (Eager) شناسه‌های پیام

من ترجیح می‌دهم از رویکرد تنبل استفاده کنم، و فقط شناسه پیام را در دیتابیس زمانی ذخیره کنم که handler با موفقیت کامل شود.
استدلال در مورد آن آسان‌تر است و یک فراخوانی کمتر به دیتابیس وجود دارد.

ممنون برای مطالعه.
امیدوارم که مفید بوده باشد. 👋
حساسیت خوبه،ولی به موقع‌ش👍🏻
قفل‌گذاری توزیع‌شده (Distributed Locking) در NET.:
هماهنگ‌سازی کار در چندین نمونه (Instance) 🔐
وقتی شما اپلیکیشن‌هایی می‌سازید که روی چندین سرور یا فرآیند اجرا می‌شوند، در نهایت با مشکل دسترسی همزمان مواجه می‌شوید. چندین worker سعی می‌کنند یک منبع یکسان را در یک زمان به‌روز کنند، و شما با شرایط رقابتی (race conditions)، کار تکراری، یا داده‌های خراب مواجه می‌شوید. 💥

دات‌نت اصول اولیه کنترل همزمانی (concurrency control primitives) عالی برای سناریوهای تک-فرآیندی، مانند lock، SemaphoreSlim، و Mutex فراهم می‌کند. اما وقتی اپلیکیشن شما در چندین نمونه مقیاس‌بندی (scale out) می‌شود، این اصول اولیه دیگر کار نمی‌کنند.

اینجاست که قفل‌گذاری توزیع‌شده وارد می‌شود.


قفل‌گذاری توزیع‌شده با تضمین اینکه در هر لحظه فقط یک گره (node) (نمونه اپلیکیشن) می‌تواند به یک بخش بحرانی (critical section) دسترسی داشته باشد، راه‌حلی ارائه می‌دهد، که از شرایط رقابتی جلوگیری کرده و سازگاری داده را در سراسر سیستم توزیع‌شده شما حفظ می‌کند.

چرا و چه زمانی به قفل‌گذاری توزیع‌شده نیاز دارید؟ 🤔

در یک اپلیکیشن تک-فرآیندی، شما فقط می‌توانید از lock یا کلاس جدید Lock در NET 10. استفاده کنید. اما هنگامی که مقیاس‌بندی می‌کنید، این کافی نیست، زیرا هر فرآیند فضای حافظه خود را دارد.

چند مورد استفاده رایج که در آن‌ها قفل‌های توزیع‌شده ارزشمند هستند:

🔹 Background jobs:
تضمین اینکه در هر لحظه فقط یک worker یک job یا منبع خاص را پردازش می‌کند.

🔹 Leader election (انتخاب رهبر):
انتخاب یک فرآیند واحد برای انجام کارهای دوره‌ای (مانند اعمال async database projections).

🔹 جلوگیری از اجرای دوباره: تضمین اینکه تسک‌های زمان‌بندی‌شده هنگام دیپلوی در چندین نمونه، چندین بار اجرا نشوند.

🔹 هماهنگ‌سازی منابع مشترک: به عنوان مثال، فقط یک نمونه سرویس در هر لحظه یک مایگریشن یا پاک‌سازی را انجام دهد.

🔹 جلوگیری از Cache stampede: تضمین اینکه هنگام منقضی شدن یک کلید کش مشخص، فقط یک نمونه کش را رفرش کند.

ارزش کلیدی 🔑: سازگاری و ایمنی در سراسر محیط‌های توزیع‌شده.

بدون این، شما با ریسک عملیات تکراری، وضعیت خراب، یا بار غیرضروری مواجه می‌شوید.

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


قفل‌گذاری توزیع‌شده DIY با Advisory Locks در PostgreSQL 🛠

بیایید ساده شروع کنیم. PostgreSQL قابلیتی به نام advisory locks دارد که برای قفل‌گذاری توزیع‌شده عالی است. برخلاف قفل‌های جدول، این‌ها با داده‌های شما تداخلی ندارند - آن‌ها صرفاً برای هماهنگی هستند.

در اینجا یک مثال آمده است: 👇
public class NightlyReportService(NpgsqlDataSource dataSource)
{
public async Task ProcessNightlyReport()
{
await using var connection = dataSource.OpenConnection();
var key = HashKey("nightly-report");

var acquired = await connection.ExecuteScalarAsync<bool>(
"SELECT pg_try_advisory_lock(@key)",
new { key });

if (!acquired)
{
throw new ConflictException("Another instance is already processing the nightly report");
}

try
{
await DoWork();
}
finally
{
await connection.ExecuteAsync(
"SELECT pg_advisory_unlock(@key)",
new { key });
}
}

private static long HashKey(string key) =>
BitConverter.ToInt64(SHA256.HashData(Encoding.UTF8.GetBytes(key)), 0);

private static Task DoWork() => Task.Delay(5000); // Your actual work here
}


در اینجا آنچه در پشت صحنه اتفاق می‌افتد، آمده است. ⚙️


ابتدا، ما نام قفل خود را به یک عدد تبدیل می‌کنیم. advisory lockهای PostgreSQL به کلیدهای عددی نیاز دارند، بنابراین ما nightly-report را به یک عدد صحیح ۶۴ بیتی هش می‌کنیم. هر گره (نمونه اپلیکیشن) باید برای رشته یکسان، عدد یکسانی تولید کند، در غیر این صورت این کار نخواهد کرد.

سپس، ()pg_try_advisory_lock تلاش می‌کند تا یک قفل انحصاری (exclusive lock) روی آن عدد بگیرد. اگر موفقیت‌آمیز باشد true برمی‌گرداند، و اگر اتصال دیگری از قبل آن را در اختیار داشته باشد false برمی‌گرداند. این فراخوانی مسدود (block) نمی‌شود - بلافاصله به شما می‌گوید که آیا قفل را به دست آورده‌اید یا نه.

اگر قفل را به دست آوریم، کار خود را انجام می‌دهیم. اگر نه، یک پاسخ تداخل (conflict) برمی‌گردانیم و اجازه می‌دهیم نمونه دیگر آن را مدیریت کند.
بلوک finally 🛡 تضمین می‌کند که ما همیشه قفل را آزاد کنیم، حتی اگر مشکلی پیش بیاید. PostgreSQL همچنین به طور خودکار advisory lockها را هنگام بسته شدن اتصالات آزاد می‌کند، که یک شبکه ایمنی خوب است.

💡SQL Server
یک قابلیت مشابه با sp_getapplock دارد.

بررسی کتابخانه DistributedLock 🔍

در حالی که رویکرد DIY کار می‌کند، اپلیکیشن‌های پروداکشن به ویژگی‌های پیچیده‌تری نیاز دارند. کتابخانه DistributedLock موارد استثنایی (edge cases) را مدیریت کرده و چندین گزینه بک‌اند (Postgres, Redis, SqlServer و غیره) را فراهم می‌کند. شما می‌دانید که من طرفدار اختراع مجدد چرخ نیستم، بنابراین این یک انتخاب عالی است.

پکیج را نصب کنید: 📦
Install-Package DistributedLock

من از رویکردی با IDistributedLockProvider استفاده خواهم کرد که به خوبی با DI کار می‌کند. شما می‌توانید یک قفل را بدون اینکه چیزی در مورد زیرساخت زیرین بدانید، به دست آورید. تمام کاری که باید انجام دهید این است که یک پیاده‌سازی از lock provider را در کانتینر DI خود ثبت کنید.

برای مثال، با استفاده از Postgres: 🔧
// Register the distributed lock provider
builder.Services.AddSingleton<IDistributedLockProvider>(
(_) =>
{
return new PostgresDistributedSynchronizationProvider(
builder.Configuration.GetConnectionString("distributed-locking")!);
});

یا اگر می‌خواهید از Redis با الگوریتم Redlock استفاده کنید:
// Requires StackExchange.Redis
builder.Services.AddSingleton<IConnectionMultiplexer>(
(_) =>
{
return ConnectionMultiplexer.Connect(
builder.Configuration.GetConnectionString("redis")!);
});

// Register the distributed lock provider
builder.Services.AddSingleton<IDistributedLockProvider>(
(sp) =>
{
var connectionMultiplexer = sp.GetRequiredService<IConnectionMultiplexer>();
return new RedisDistributedSynchronizationProvider(connectionMultiplexer.GetDatabase());
});

نحوه استفاده سرراست است: 👨‍💻
// شما همچنین می‌توانید یک تایم‌اوت پاس دهید، که در آن provider به تلاش برای به دست آوردن قفل
// تا رسیدن به تایم‌اوت ادامه خواهد داد.
IDistributedSynchronizationHandle? distributedLock = distributedLockProvider
.TryAcquireLock("nightly-report");

// اگر قفل را به دست نیاوردیم، آبجکت null خواهد بود
if (distributedLock is null)
{
return Results.Conflict();
}

// مهم است که قفل را در یک دستور using بپیچید تا اطمینان حاصل شود که به درستی آزاد می‌شود
using (distributedLock)
{
await DoWork();
}


این کتابخانه تمام بخش‌های دشوار را مدیریت می‌کند 👍: تایم‌اوت‌ها، تلاش‌های مجدد، و تضمین اینکه قفل‌ها حتی در سناریوهای شکست نیز آزاد می‌شوند.
این همچنین از بسیاری از بک‌اندها (SQL Server, Azure, ZooKeeper و غیره) پشتیبانی می‌کند، که آن را به یک انتخاب محکم برای بارهای کاری پروداکشن تبدیل می‌کند.

جمع‌بندی 📝

قفل‌گذاری توزیع‌شده چیزی نیست که هر روز به آن نیاز داشته باشید. اما وقتی به آن نیاز پیدا می‌کنید، شما را از باگ‌های ظریف و دردناکی که فقط تحت بار یا در پروداکشن ظاهر می‌شوند، نجات می‌دهد.

🔹 ساده شروع کنید: اگر از قبل از Postgres استفاده می‌کنید، advisory locks یک ابزار قدرتمند است.

🔹 برای یک تجربه توسعه‌دهنده تمیزتر، به سراغ کتابخانه DistributedLock بروید.

🔹 بک‌اندی را انتخاب کنید که متناسب با زیرساخت شما باشد (Postgres, Redis, SQL Server و غیره).

قفل مناسب در زمان مناسب تضمین می‌کند که سیستم شما سازگار، قابل اعتماد و انعطاف‌پذیر باقی بماند، حتی در چندین فرآیند و سرور.(منبع)
چگونه استثناها (Exceptions) را با الگوی Result در NET. جایگزین کنیم

در توسعه نرم‌افزار مدرن، مدیریت باظرافت خطاها و سناریوهای استثنایی برای ساخت اپلیکیشن‌های قوی، حیاتی است. 🛡
در حالی که استثناها یک مکانیزم رایج در .NET برای مدیریت خطا هستند، آن‌ها می‌توانند سربار عملکردی (performance overhead) ایجاد کرده و جریان کد را پیچیده کنند.

امروز ما بررسی خواهیم کرد که چگونه استثناها را با الگوی Result در NET. جایگزین کنیم، و خوانایی، قابلیت نگهداری و عملکرد کد را افزایش دهیم.

🖊مقدمه‌ای بر مدیریت استثنا در NET.

مدیریت استثنا یک مفهوم بنیادی در برنامه‌نویسی NET. است که به توسعه‌دهندگان اجازه می‌دهد خطاهای زمان اجرا را باظرافت مدیریت کنند. رویکرد معمول شامل استفاده از بلوک‌های try, catch و finally برای گرفتن و مدیریت استثناها است.

بیایید اپلیکیشنی را بررسی کنیم که یک Shipment ایجاد می‌کند و از استثناها برای کنترل جریان استفاده می‌کند: 👇
public async Task<ShipmentResponse> CreateAsync(
CreateShipmentCommand request,
CancellationToken cancellationToken)
{
var shipmentAlreadyExists = await context.Shipments
.Where(s => s.OrderId == request.OrderId)
.AnyAsync(cancellationToken);

if (shipmentAlreadyExists)
{
throw new ShipmentAlreadyExistsException(request.OrderId);
}

var shipment = request.MapToShipment(shipmentNumber);

context.Shipments.Add(shipment);

await context.SaveChangesAsync(cancellationToken);

return shipment.MapToResponse();
}

در اینجا اگر یک shipment از قبل در دیتابیس وجود داشته باشد، ShipmentAlreadyExistsException پرتاب می‌شود. در endpoint Minimal API، این استثنا به صورت زیر مدیریت می‌شود:
public void MapEndpoint(WebApplication app)
{
app.MapPost("/api/v1/shipments", Handle);
}

private static async Task<IResult> Handle(
[FromBody] CreateShipmentRequest request,
IShipmentService service,
CancellationToken cancellationToken)
{
try
{
var command = request.MapToCommand();
var response = await service.CreateAsync(command, cancellationToken);
return Results.Ok(response);
}
catch (ShipmentAlreadyExistsException ex)
{
return Results.Conflict(new { message = ex.Message });
}
}

در حالی که این رویکرد کار می‌کند، معایب زیر را دارد: 👎

🔹 کد غیرقابل پیش‌بینی است، با نگاه کردن به IShipmentService.CreateAsync نمی‌توانید با اطمینان بگویید که آیا متد استثنا پرتاب می‌کند یا نه.

🔹 شما باید از دستورات catch استفاده کنید و دیگر نمی‌توانید کد را خط به خط از بالا به پایین بخوانید، باید دید خود را به عقب و جلو ببرید.

🔹 استثناها می‌توانند در برخی اپلیکیشن‌ها به مشکلات عملکردی منجر شوند زیرا بسیار کند هستند.

به یاد داشته باشید، استثناها برای شرایط استثنایی هستند. آن‌ها بهترین گزینه برای کنترل جریان نیستند. در عوض، من می‌خواهم رویکرد بهتری را با الگوی Result به شما نشان دهم. 💡
درک الگوی Result 🤔

الگوی Result یک الگوی طراحی است که نتیجه یک عملیات را کپسوله می‌کند، که می‌تواند یا موفقیت‌آمیز باشد یا یک شکست. به جای پرتاب استثناها، متدها یک آبجکت Result را برمی‌گردانند که نشان می‌دهد آیا عملیات موفق شده یا شکست خورده است، همراه با هرگونه داده یا پیام خطای مرتبط.

آبجکت Result از بخش‌های زیر تشکیل شده است:

🔹 IsSuccess/IsError:
یک مقدار بولین که نشان می‌دهد آیا عملیات موفقیت‌آمیز بوده است یا نه.

🔹 Value:
مقدار نتیجه زمانی که عملیات موفقیت‌آمیز است.

🔹 Error:
یک پیام یا آبجکت خطا زمانی که عملیات شکست می‌خورد.

بیایید یک مثال ساده از نحوه پیاده‌سازی یک آبجکت Result را بررسی کنیم: 🎁
public class Result<T>
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public T? Value { get; }
public string? Error { get; }

private Result(bool isSuccess, T? value, string? error)
{
IsSuccess = isSuccess;
Value = value;
Error = error;
}

public static Result<T> Success(T value)
{
return new Result<T>(true, value, null);
}

public static Result<T> Failure(string error)
{
return new Result<T>(false, default(T), error);
}
}


در اینجا ما یک کلاس جنریک Result تعریف کرده‌ایم که می‌تواند یا یک مقدار موفقیت‌آمیز یا یک خطا را در خود نگه دارد. برای ایجاد آبجکت Result می‌توانید از متد استاتیک Success یا Failure استفاده کنید.

بیایید پیاده‌سازی endpoint قبلی Create Shipment را با الگوی Result خود بازنویسی کنیم:
public async Task<Result<ShipmentResponse>> CreateAsync(
CreateShipmentCommand request,
CancellationToken cancellationToken)
{
var shipmentAlreadyExists = await context.Shipments
.Where(s => s.OrderId == request.OrderId)
.AnyAsync(cancellationToken);

if (shipmentAlreadyExists)
{
return Result.Failure<ShipmentResponse>(
$"Shipment for order '{request.OrderId}' is already created");
}

var shipment = request.MapToShipment(shipmentNumber);

context.Shipments.Add(shipiment);

await context.SaveChangesAsync(cancellationToken);

var response = shipment.MapToResponse();

return Result.Success(response);
}

در اینجا متد <Result<ShipmentResponse را برمی‌گرداند که ShipmentResponse را داخل یک کلاس Result می‌پیچد.

وقتی یک shipment از قبل در دیتابیس وجود داشته باشد، ما Result.Failure را با یک پیام متناظر برمی‌گردانیم. وقتی یک درخواست موفق می‌شود، ما Result.Success را برمی‌گردانیم.

در اینجا نحوه مدیریت یک آبجکت Result در endpoint آمده است: 👇
public void MapEndpoint(WebApplication app)
{
app.MapPost("/api/v1/shipments", Handle);
}

private static async Task<IResult> Handle(
[FromBody] CreateShipmentRequest request,
IShipmentService service,
CancellationToken cancellationToken)
{
var command = request.MapToCommand();
var response = await service.CreateAsync(command, cancellationToken);

return response.IsSuccess ? Results.Ok(response.Value) : Results.Conflict(response.Error);
}

شما باید بررسی کنید که آیا پاسخ موفقیت‌آمیز است یا شکست خورده و یک نتیجه HTTP مناسب را برگردانید. حالا کد قابل پیش‌بینی‌تر به نظر می‌رسد و راحت‌تر خوانده می‌شود، درست است؟ 😉
پیاده‌سازی فعلی آبجکت Result بسیار ساده شده است، در اپلیکیشن‌های واقعی شما به ویژگی‌های بیشتری در آن نیاز دارید. شما می‌توانید زمانی را صرف کرده و یکی برای خود بسازید و در تمام پروژه‌ها از آن استفاده مجدد کنید. یا می‌توانید از یک پکیج Nuget آماده استفاده کنید.

پکیج‌های Nuget زیادی با پیاده‌سازی الگوی Result وجود دارد، اجازه دهید چند مورد از محبوب‌ترین‌ها را به شما معرفی کنم: 📦

🔹 FluentResults

🔹 CSharpFunctionalExtensions

🔹 error-or

🔹 Ardalis.Result

مورد علاقه من error-or است، بیایید آن را بررسی کنیم.

الگوی Result با Error-Or

همانطور که نویسنده پکیج بیان کرده است: Error-Or یک discriminated union ساده و روان از یک خطا یا یک نتیجه است. کلاس Result در این کتابخانه <ErrorOr<T نامیده می‌شود.

در اینجا نحوه استفاده از آن برای کنترل جریان آمده است:
public async Task<ErrorOr<ShipmentResponse>> Handle(
CreateShipmentCommand request,
CancellationToken cancellationToken)
{
var shipmentAlreadyExists = await context.Shipments
.Where(s => s.OrderId == request.OrderId)
.AnyAsync(cancellationToken);

if (shipmentAlreadyExists)
{
return Error.Conflict($"Shipment for order '{request.OrderId}' is already created");
}

var shipment = request.MapToShipment(shipmentNumber);

context.Shipments.Add(shipment);

await context.SaveChangesAsync(cancellationToken);

return shipment.MapToResponse();
}

در اینجا نوع بازگشتی <ErrorOr<ShipmentResponse است که نشان می‌دهد آیا یک خطا برگردانده شده است یا ShipmentResponse.

کلاس Error خطاهای داخلی برای انواع زیر دارد، که یک متد برای هر نوع خطا فراهم می‌کند: 🏷
public enum ErrorType
{
Failure,
Unexpected,
Validation,
Conflict,
NotFound,
Unauthorized,
Forbidden,
}

این کتابخانه به شما اجازه می‌دهد در صورت نیاز خطاهای سفارشی ایجاد کنید.

در endpoint API می‌توانید خطا را مشابه کاری که قبلاً با آبجکت Result سفارشی خودمان انجام دادیم، مدیریت کنید: 👇
public void MapEndpoint(WebApplication app)
{
app.MapPost("/api/v2/shipments", Handle);
}

private static async Task<IResult> Handle(
[FromBody] CreateShipmentRequest request,
IShipmentService service,
CancellationToken cancellationToken)
{
var command = request.MapToCommand();
var response = await mediator.Send(command, cancellationToken);

if (response.IsError)
{
return Results.Conflict(response.Errors);
}

return Results.Ok(response.Value);
}

هنگام کار با Error-Or، من دوست دارم یک متد استاتیک ToProblem بسازم که خطا را به کد وضعیت (status code) مناسب تبدیل می‌کند:
public static class EndpointResultsExtensions
{
public static IResult ToProblem(this List<Error> errors)
{
if (errors.Count == 0)
{
return Results.Problem();
}

return CreateProblem(errors);
}

private static IResult CreateProblem(List<Error> errors)
{
var statusCode = errors.First().Type switch
{
ErrorType.Conflict => StatusCodes.Status409Conflict,
ErrorType.Validation => StatusCodes.Status400BadRequest,
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Unauthorized => StatusCodes.Status401Unauthorized,
_ => StatusCodes.Status500InternalServerError
};

return Results.ValidationProblem(
errors.ToDictionary(k => k.Code, v => new[] { v.Denoscription }),
statusCode: statusCode);
}
}

به این ترتیب، کد قبلی به این شکل تبدیل خواهد شد:
var response = await mediator.Send(command, cancellationToken);

if (response.IsError)
{
return response.Errors.ToProblem();
}

return Results.Ok(response.Value);
مزایا و معایب استفاده از الگوی Result ⚖️


مزایای استفاده از الگوی Result:
• مدیریت خطای صریح: فراخواننده باید به صراحت حالت‌های موفقیت و شکست را مدیریت کند. از امضای متد، واضح است که ممکن است یک خطا برگردانده شود.

• عملکرد بهبود یافته: سربار مرتبط با استثناها را کاهش می‌دهد.

• تست بهتر: تست واحد را ساده می‌کند زیرا mock کردن آبجکت Result بسیار آسان‌تر از پرتاب و مدیریت استثناها است.

• ایمنی: یک آبجکت result باید حاوی اطلاعاتی باشد که بتواند به دنیای خارج نمایش داده شود. در حالی که شما می‌توانید تمام جزئیات را با استفاده از Logger یا ابزارهای دیگر ذخیره کنید.

معایب بالقوه:
• پرحرفی (Verbosity): می‌تواند در مقایسه با استفاده از استثناها کد بیشتری را به همراه داشته باشد، زیرا شما باید تمام متدها در stacktrace را برای برگرداندن آبجکت Result علامت‌گذاری کنید.

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

الگوی Result عالی به نظر می‌رسد، اما آیا باید استثناها را فراموش کنیم؟ قطعاً نه! استثناها هنوز کاربرد خود را دارند. بیایید در مورد آن صحبت کنیم.

چه زمانی از استثناها (Exceptions) استفاده کنیم؟ 🤔

استثناها برای موارد استثنایی هستند و من موارد استفاده زیر را می‌بینم که در آن‌ها ممکن است مناسب باشند:

🔹 مدیریت خطای سراسری

🔹 کد کتابخانه

🔹 اعتبارسنجی محافظ (Guard Validation) در انتیتی‌های دامین

بیایید نگاهی دقیق‌تر به این ۳ مورد بیندازیم:

مدیریت خطای سراسری (Global Exception Handling) 🌍

در اپلیکیشن‌های asp.net core شما، قطعاً باید استثناها را مدیریت کنید. آن‌ها می‌توانند از هر جایی پرتاب شوند: دسترسی به دیتابیس، فراخوانی‌های شبکه، عملیات I/O، کتابخانه‌ها و غیره.
شما باید آماده باشید که استثناها رخ خواهند داد و آن‌ها را باظرافت مدیریت کنید. برای این کار من IExceptionHandler را پیاده‌سازی می‌کنم که از NET 8. به بعد در دسترس است:
internal sealed class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;

public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
{
_logger = logger;
}

public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
_logger.LogError(exception, "Exception occurred: {Message}", exception.Message);

var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Server error"
};

httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;

await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);

return true;
}
}


کد کتابخانه (Library Code) 📚

در کتابخانه‌ها، معمولاً وقتی چیزی اشتباه پیش می‌رود، استثناها پرتاب می‌شوند.
دلایل این امر عبارتند از:

🔹 اکثر توسعه‌دهندگان با استثناها آشنا هستند و می‌دانند چگونه آن‌ها را مدیریت کنند.

🔹 کتابخانه‌ها نمی‌خواهند نظرکرده (opinionated) باشند و از یک کتابخانه الگوی Result خاص استفاده کنند یا نسخه خود را پیاده‌سازی کنند.

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

اعتبارسنجی محافظ در انتیتی‌های دامین (Domain Entities Guard Validation) 🛡

هنگام پیروی از اصول طراحی دامنه محور (DDD)، شما مدل‌های دامین خود را با استفاده از سازنده‌ها یا متدهای factory می‌سازید. اگر شما داده‌ای را برای ساخت آبجکت دامین پاس دهید و این داده نامعتبر باشد (که هرگز نباید باشد) - شما می‌توانید یک استثنا پرتاب کنید. این نشانه‌ای خواهد بود که اعتبارسنجی ورودی، مپینگ یا دیگر لایه‌های اپلیکیشن شما یک باگ دارند که باید برطرف شود.

خلاصه 📝

جایگزین کردن استثناها با الگوی Result در NET. می‌تواند به کد قوی‌تر و قابل نگهداری‌تر منجر شود.
با مدیریت صریح موارد موفقیت و شکست، توسعه‌دهندگان می‌توانند کد واضح‌تری بنویسند که تست و درک آن آسان‌تر است.
در حالی که ممکن است برای همه سناریوها مناسب نباشد، گنجاندن الگوی Result می‌تواند به طور قابل توجهی مدیریت خطا را در اپلیکیشن‌های شما بهبود ببخشد.

امیدوارم این مقاله برایتان مفید باشد.
Forwarded from Code With HSN
Media is too big
VIEW IN TELEGRAM
کتابخونه Prometheus-net کیلو چنده؟ OTEL رو بچسب! و بدون نیاز به داکر گرافانا بیار بالا

داشتن یک دشبورد برای مانیتورینگ برنامه DotNet شما اینبار بدون نیاز به داکر و گرافانا با Grafana Cloud دشبورد خودتو هرجای جهان که هستی بساز!

ویژگی های این دشبورد:
1. همراه داشتن نحوه ستاپ
2. تقریبا جامع و کامل است
3. دسترسی آسان به آن
4. داشتن توضیحات و راهکار برای حل مشکلات در خود دشبورد

🔗 دشبورد در Grafana: مشاهده دشبورد
📱 لینک مشاهده ویدئو: مشاهده ویدئو
Please open Telegram to view this post
VIEW IN TELEGRAM
🔒 پیاده‌سازی APIهای REST هم‌توان (Idempotent) در ASP.NET Core


هم‌توانی (Idempotency) یک مفهوم بسیار حیاتی برای APIهای REST است که قابلیت اطمینان و سازگاری سیستم شما را تضمین می‌کند. 💡 یک عملیات هم‌توان را می‌توان چندین بار تکرار کرد بدون اینکه نتیجه فراتر از درخواست اولیه API تغییر کند. این ویژگی به ویژه در سیستم‌های توزیع شده، جایی که شکست‌های شبکه یا تایم‌اوت‌ها می‌توانند منجر به درخواست‌های تکراری شوند، اهمیت بالایی دارد.

🌟 مزایای پیاده‌سازی هم‌توانی

پیاده‌سازی هم‌توانی در API شما چندین مزیت کلیدی به همراه دارد: 👇

• از عملیات‌های تکراری ناخواسته جلوگیری می‌کند.

• قابلیت اطمینان را در سیستم‌های توزیع شده بهبود می‌بخشد.

• به مدیریت درست مشکلات شبکه و تلاش‌های مجدد (Retries) کمک می‌کند.

در ادامه، این مفهوم را عمیق‌تر بررسی می‌کنیم تا سیستم شما قوی و قابل اعتماد باقی بماند. 🛡

🤔 هم‌توانی چیست؟

هم‌توانی، در بستر APIهای وب، به این معنی است که ارسال چندین درخواست یکسان باید همان تأثیر ارسال یک درخواست واحد را داشته باشد. 🔄 به عبارت دیگر، مهم نیست که یک کلاینت چند بار درخواست مشابهی ارسال کند، تأثیر سمت سرور باید فقط یک بار رخ دهد.

استاندارد RFC 9110 درباره HTTP Semantics، این تعریف را ارائه می‌دهد:

یک متد درخواست زمانی «هم‌توان» در نظر گرفته می‌شود که تأثیر مورد نظر بر سرور از چندین درخواست یکسان با آن متد، همانند تأثیر برای یک درخواست واحد باشد.

متدهای هم‌توان HTTP: 🏷
طبق RFC 9110، متدهای زیر به طور پیش‌فرض هم‌توان هستند:
• PUT
•DELETE
متدهای ایمن (Safe Methods): GET, HEAD, OPTIONS, و TRACE

🧐 اثرات جانبی غیرهم‌توان مجاز

پاراگراف بعدی RFC نکته جالبی را روشن می‌کند: سرور می‌تواند «سایر اثرات جانبی غیرهم‌توان» را پیاده‌سازی کند که به منبع (resource) اعمال نمی‌شوند.

... ویژگی هم‌توان فقط در مورد چیزی اعمال می‌شود که توسط کاربر درخواست شده است؛ سرور آزاد است که هر درخواست را به طور جداگانه لاگ کند، تاریخچه کنترل بازبینی را نگه دارد، یا سایر اثرات جانبی غیرهم‌توان را برای هر درخواست هم‌توان پیاده‌سازی کند.

نتیجه‌گیری: مزایای پیاده‌سازی هم‌توانی فراتر از صرفاً رعایت معناشناسی متد HTTP است. 📈 این مزیت به طور قابل توجهی قابلیت اطمینان API شما را بهبود می‌بخشد، به خصوص در سیستم‌های توزیع شده. با پیاده‌سازی هم‌توانی، شما از عملیات‌های تکراری که می‌توانند به دلیل تلاش‌های مجدد کلاینت رخ دهند، جلوگیری می‌کنید.
🚦 کدام متدهای HTTP هم‌توان (Idempotent) هستند؟


شناخت اینکه کدام متدهای HTTP ذاتاً هم‌توان (Idempotent) هستند، برای طراحی APIهای قوی و قابل اعتماد ضروری است. هم‌توانی یعنی تکرار عملیات، نتیجه‌ای فراتر از اجرای اول نداشته باشد. 🛡

متدهای HTTP که ذاتاً هم‌توان هستند:
چندین متد HTTP به طور ذاتی هم‌توان در نظر گرفته می‌شوند:

GET و HEAD: 🔍

این متدها برای بازیابی داده‌ها استفاده می‌شوند و هرگز وضعیت سرور را تغییر نمی‌دهند.

PUT: 🔄

این متد برای به‌روزرسانی یا جایگزینی کامل یک منبع استفاده می‌شود. تکرار آن همیشه منجر به یک وضعیت نهایی یکسان برای منبع می‌شود.

DELETE: 🗑

برای حذف یک منبع استفاده می‌شود. نتیجه حذف یک منبع (حذف شده باشد یا خیر)، در تمام دفعات بعدی یکسان است (منبع وجود ندارد).

OPTIONS: ⚙️

این متد برای بازیابی اطلاعات مربوط به گزینه‌های ارتباطی موجود برای یک منبع است و هیچ تغییری در وضعیت سرور ایجاد نمی‌کند.

متد POST و مسئله هم‌توانی
POST: ⚠️

متد POST ذاتاً هم‌توان نیست، زیرا به طور معمول برای ایجاد منابع جدید یا پردازش داده‌ها به روشی استفاده می‌شود که می‌تواند با هر درخواست تکرار شود.

درخواست‌های مکرر POST معمولاً منجر به ایجاد چندین منبع تکراری یا راه‌اندازی چندین اقدام جداگانه می‌شوند. 💣

💡 راه‌حل: هم‌توان‌سازی POST با منطق سفارشی

خبر خوب این است که می‌توانیم با استفاده از منطق سفارشی (Custom Logic)، هم‌توانی را برای متدهای POST پیاده‌سازی کنیم. 🔧

نکته: برای مثال، با بررسی وجود منبع قبل از ایجاد آن، می‌توانیم اطمینان حاصل کنیم که درخواست‌های مکرر POST منجر به اقدامات یا منابع تکراری نمی‌شوند و API ما همچنان قابل اعتماد باقی می‌ماند.
🛠 پیاده‌سازی هم‌توانی (Idempotency) در ASP.NET Core


برای پیاده‌سازی هم‌توانی، از یک استراتژی شامل کلیدهای هم‌توانی (Idempotency Keys) استفاده خواهیم کرد: 🔑

🔹️کلاینت یک کلید یکتای Guid برای هر عملیات تولید کرده و آن را در هدر سفارشی ارسال می‌کند.

🔹️سرور بررسی می‌کند که آیا این کلید را قبلاً دیده است:

🔹️برای یک کلید جدید، درخواست را پردازش کرده و نتیجه را ذخیره می‌کند.

🔹️برای یک کلید شناخته شده، نتیجه ذخیره شده را بدون پردازش مجدد برمی‌گرداند.

این مکانیسم تضمین می‌کند که درخواست‌های تکراری (مثلاً به دلیل خطاهای شبکه) فقط یک بار در سرور پردازش شوند. 🛡

💫 ترکیب Attribute و IAsyncActionFilter

ما می‌توانیم هم‌توانی را برای متدهای کنترلر با ترکیب یک Attribute و IAsyncActionFilter پیاده‌سازی کنیم. با این کار، می‌توانیم IdempotentAttribute را به سادگی برای اعمال هم‌توانی به یک اِندپوینت مشخص کنیم.

نکته مهم در مورد شکست: ⚠️ وقتی یک درخواست شکست می‌خورد (کد وضعیت 4xx/5xx برمی‌گرداند)، ما پاسخ را کش (Cache) نمی‌کنیم. این به کلاینت‌ها اجازه می‌دهد تا با همان کلید هم‌توانی دوباره تلاش کنند. با این حال، باید در نظر داشت که این رفتار به این معنی است که یک درخواست شکست‌خورده که با یک درخواست موفق (با همان کلید) دنبال شود، موفق خواهد شد. حتماً بررسی کنید که این رفتار با الزامات کسب و کار شما هماهنگ باشد.

📜 کد: IdempotentAttribute و منطق کشینگ

در اینجا منطق اصلی فیلتر اکشن ما آمده است:
[AttributeUsage(AttributeTargets.Method)]
internal sealed class IdempotentAttribute : Attribute, IAsyncActionFilter
{
private const int DefaultCacheTimeInMinutes = 60;
private readonly TimeSpan _cacheDuration;

public IdempotentAttribute(int cacheTimeInMinutes = DefaultCacheTimeInMinutes)
{
_cacheDuration = TimeSpan.FromMinutes(cacheTimeInMinutes);
}

public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
// Parse the Idempotence-Key header from the request
if (!context.HttpContext.Request.Headers.TryGetValue(
"Idempotence-Key",
out StringValues idempotenceKeyValue) ||
!Guid.TryParse(idempotenceKeyValue, out Guid idempotenceKey))
{
context.Result = new BadRequestObjectResult("Invalid or missing Idempotence-Key header");
return;
}

IDistributedCache cache = context.HttpContext
.RequestServices.GetRequiredService<IDistributedCache>();

// Check if we already processed this request and return a cached response (if it exists)
string cacheKey = $"Idempotent_{idempotenceKey}";
string? cachedResult = await cache.GetStringAsync(cacheKey);
if (cachedResult is not null)
{
IdempotentResponse response = JsonSerializer.Deserialize<IdempotentResponse>(cachedResult)!;

var result = new ObjectResult(response.Value) { StatusCode = response.StatusCode };
context.Result = result;

return;
}

// Execute the request and cache the response for the specified duration
ActionExecutedContext executedContext = await next();

if (executedContext.Result is ObjectResult { StatusCode: >= 200 and < 300 } objectResult)
{
int statusCode = objectResult.StatusCode ?? StatusCodes.Status200OK;
IdempotentResponse response = new(statusCode, objectResult.Value);

await cache.SetStringAsync(
cacheKey,
JsonSerializer.Serialize(response),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = _cacheDuration }
);
}
}
}

internal sealed class IdempotentResponse
{
[JsonConstructor]
public IdempotentResponse(int statusCode, object? value)
{
StatusCode = statusCode;
Value = value;
}

public int StatusCode { get; }
public object? Value { get; }
}
🔒 ملاحظات حرفه‌ای: شرط رقابت (Race Condition)


یک پنجره کوچک شرط رقابت (Race Condition) بین بررسی کش و تنظیم آن وجود دارد. برای سازگاری مطلق، باید الگوی قفل توزیع شده (Distributed Lock) را در نظر بگیریم، اگرچه این کار باعث افزایش پیچیدگی و تأخیر (Latency) خواهد شد. ⚠️

🚀 اعمال Attribute به اکشن‌های کنترلر
اکنون، می‌توانیم این Attribute را به اکشن‌های کنترلر خود اعمال کنیم:
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
[HttpPost]
[Idempotent(cacheTimeInMinutes: 60)]
public IActionResult CreateOrder([FromBody] CreateOrderRequest request)
{
// Process the order...

return CreatedAtAction(nameof(GetOrder), new { id = orderDto.Id }, orderDto);
}
}


هم‌توانی با Minimal APIs

برای پیاده‌سازی هم‌توانی با Minimal APIs، می‌توانیم از IEndpointFilter استفاده کنیم. 🛠

📜 کد: IdempotencyFilter
internal sealed class IdempotencyFilter(int cacheTimeInMinutes = 60)
: IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
// Parse the Idempotence-Key header from the request
if (TryGetIdempotenceKey(out Guid idempotenceKey))
{
return Results.BadRequest("Invalid or missing Idempotence-Key header");
}

IDistributedCache cache = context.HttpContext
.RequestServices.GetRequiredService<IDistributedCache>();

// Check if we already processed this request and return a cached response (if it exists)
string cacheKey = $"Idempotent_{idempotenceKey}";
string? cachedResult = await cache.GetStringAsync(cacheKey);
if (cachedResult is not null)
{
IdempotentResponse response = JsonSerializer.Deserialize<IdempotentResponse>(cachedResult)!;
return new IdempotentResult(response.StatusCode, response.Value);
}

object? result = await next(context);

// Execute the request and cache the response for the specified duration
if (result is IStatusCodeHttpResult { StatusCode: >= 200 and < 300 } statusCodeResult
and IValueHttpResult valueResult)
{
int statusCode = statusCodeResult.StatusCode ?? StatusCodes.Status200OK;
IdempotentResponse response = new(statusCode, valueResult.Value);

await cache.SetStringAsync(
cacheKey,
JsonSerializer.Serialize(response),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(cacheTimeInMinutes)
}
);
}

return result;
}
}

// We have to implement a custom result to write the status code
internal sealed class IdempotentResult : IResult
{
private readonly int _statusCode;
private readonly object? _value;

public IdempotentResult(int statusCode, object? value)
{
_statusCode = statusCode;
_value = value;
}

public Task ExecuteAsync(HttpContext httpContext)
{
httpContext.Response.StatusCode = _statusCode;

return httpContext.Response.WriteAsJsonAsync(_value);
}
}


🔗 اعمال فیلتر به اِندپوینت

اکنون، می‌توانیم این فیلتر اِندپوینت را به اِندپوینت Minimal API خود اعمال کنیم:
app.MapPost("/api/orders", CreateOrder)
.RequireAuthorization()
.WithOpenApi()
.AddEndpointFilter<IdempotencyFilter>();


💡 جایگزین دیگر

یک جایگزین برای دو پیاده‌سازی قبلی (Attribute و Endpoint Filter) پیاده‌سازی منطق هم‌توانی در یک Middleware سفارشی است. 🌐