به نظرم یه پست دیگه در مورد Vertical Slice باید بریم هنوز جا داره✌🏻
پست امشب:
Vertical Slice Architecture Is Easier Than You Think🔥
پست امشب:
Vertical Slice Architecture Is Easier Than You Think🔥
معماری برش عمودی آسانتر از آن چیزی است که فکر میکنید 🔪
فرض کنید شما باید یک ویژگی "خروجی گرفتن از دادههای کاربر" 📤 را به اپلیکیشن NET. خود اضافه کنید. کاربران روی یک دکمه کلیک میکنند، سیستم شما خروجی دادههایشان را تولید میکند، آن را در فضای ذخیرهسازی ابری آپلود میکند و یک لینک دانلود امن به آنها ایمیل میکند.
در معماری لایهای فعلی شما با یک ساختار پوشه فنی، احتمالاً شش پوشه مختلف را لمس خواهید کرد: Controllers, Services, Models, DTOs, Repositories, و Validators. شما در solution explorer خود بالا و پایین اسکرول خواهید کرد، رشته افکار خود را از دست خواهید داد، و از خود خواهید پرسید که چرا افزودن یک ویژگی نیازمند ویرایش فایلهایی است که در سراسر پایگاه کد شما پراکنده شدهاند. 🤯
اگر این برایتان آشنا به نظر میرسد، شما تنها نیستید. اکثر توسعهدهندگان NET. با معماری لایهای "استاندارد" شروع میکنند، و کد را بر اساس دغدغههای فنی به جای ویژگیهای بیزینسی سازماندهی میکنند.
اما یک راه بهتر وجود دارد: معماری برش عمودی. ✨
معماری برش عمودی چیست؟🤔
به جای سازماندهی کد خود بر اساس لایههای فنی (Controllers, Services, Repositories)، معماری برش عمودی آن را بر اساس ویژگیهای بیزینسی سازماندهی میکند. هر ویژگی به یک "برش" خودکفا تبدیل میشود که شامل همه چیز مورد نیاز برای آن عملکرد خاص است.
اینطور به آن فکر کنید: معماری لایهای سنتی مانند سازماندهی یک کتابخانه بر اساس اندازه یا رنگ کتاب است 📚، در حالی که برشهای عمودی مانند سازماندهی بر اساس موضوع است. وقتی میخواهید در مورد تاریخ یاد بگیرید، نمیخواهید در کل کتابخانه جستجو کنید، شما تمام کتابهای تاریخ را در یک مکان میخواهید.
رویکرد سنتی در برابر برشهای عمودی
بیایید به مثال خروجی داده ما نگاه کنیم. در اینجا نحوه ساختاردهی این ویژگی در یک پروژه معمولی NET. آمده است:
ساختار لایهای سنتی: 👎
📁 Controllers/
└── UsersController.cs (export endpoint)
📁 Services/
├── IDataExportService.cs
├── DataExportService.cs
├── ICloudStorageService.cs
├── CloudStorageService.cs
├── IEmailService.cs
└── EmailService.cs
📁 Models/
├── ExportDataRequest.cs
└── ExportDataResponse.cs
📁 Repositories/
├── IUserRepository.cs
└── UserRepository.cs
حالا همان عملکرد که به صورت برشهای عمودی سازماندهی شده است:
ساختار برش عمودی: 👍
📁 Features/
└──📁 Users/
└──📁 ExportData/
├── ExportUserData.cs
└── ExportUserDataEndpoint.cs
📁 Create/
└── CreateUser.cs
📁 GetById/
└── GetUserById.cs
پوشه ExportData شامل همه چیز مربوط به خروجی گرفتن از دادههای کاربر است: درخواست، پاسخ، منطق بیزینس و endpoint API.
توجه داشته باشید که من هنوز هم ICloudStorageClient و IEmailSender را تزریق میکنم به جای اینکه آن منطق را مستقیماً در handler قرار دهم. اینها دغدغههای مشترک (cross-cutting concerns) واقعی هستند که چندین ویژگی از آنها استفاده خواهند کرد. نکته کلیدی 🔑، تمایز بین "اشتراکی چون باید باشد" در مقابل "اشتراکی چون این الگو به من گفت" است.
کد را به من نشان بده 👨💻
من ابتدا بر اساس دامنه (Users) و سپس بر اساس ویژگی (ExportData) سازماندهی میکنم. 📂 برخی تیمها مستقیماً Features/ExportUserData را ترجیح میدهند، اما من متوجه شدهام که گروهبندی دامنه زمانی که ویژگیهای زیادی دارید، کمک میکند. ویژگیهای مرتبط به صورت بصری گروهبندی شده باقی میمانند.
در اینجا ظاهر برش ویژگی (feature slice) خروجی داده ما با استفاده از یک request، handler و minimal APIها آمده است: 👇
Features/Users/ExportData/ExportUserData.cs
public static class ExportUserData
{
public record Request(Guid UserId) : IRequest<Response>;
public record Response(string DownloadUrl, DateTime ExpiresAt);
public class Handler(
AppDbContext dbContext,
ICloudStorageClient storageClient,
IEmailSender emailSender)
: IRequestHandler<Request, Response>
{
public async Task<Response> Handle(Request request, CancellationToken ct = default)
{
// Get user data
var user = await dbContext.Users
.Include(u => u.Orders)
.Include(u => u.Preferences)
.FirstOrDefaultAsync(u => u.Id == request.UserId, ct);
if (user == null)
{
throw new NotFoundException($"User {request.UserId} not found");
}
// Generate export data
var exportData = new
{
user.Email,
user.Name,
user.CreatedAt,
Orders = user.Orders.Select(o => new { o.Id, o.Total, o.Date }),
Preferences = user.Preferences
};
// Upload to cloud storage
var fileName = $"user-data-{user.Id}-{DateTime.UtcNow:yyyyMMdd}.json";
var expiresAtUtc = DateTime.UtcNow.AddDays(7);
var downloadUrl = await storageClient.UploadAsJsonAsync(
fileName,
exportData,
expiresAtUtc,
ct);
// Send email notification
await emailSender.SendDataExportEmailAsync(user.Email, downloadUrl, ct);
return new Response(downloadUrl, expiresAtUtc);
}
}
// Simple validation using FluentValidation
public sealed class Validator : AbstractValidator<Request>
{
public Validator()
{
RuleFor(r => r.UserId).NotEmpty();
}
}
}
همه چیز مربوط به خروجی گرفتن از دادههای کاربر در یک مکان قرار دارد: کوئری دیتابیس، اعتبارسنجی، منطق بیزینس، یکپارچهسازی با فضای ذخیرهسازی ابری، و نوتیفیکیشن ایمیل.
endpoint Minimal API سرراست است: 👇
public static class ExportUserDataEndpoint
{
public static void Map(IEndpointRouteBuilder app)
{
app.MapPost("/users/{userId}/export", async (
Guid userId,
IRequestHandler<ExportUserData.Request, ExportUserData.Response> handler) =>
{
var response = await handler.Handle(new ExportUserData.Request(userId));
return Results.Ok(response);
});
}
}
ما حتی میتوانستیم endpoint را داخل فایل ExportUserData.cs تعریف کنیم اگر میخواستیم همه چیز را کنار هم نگه داریم. این بیشتر یک موضوع ترجیح و قراردادهای تیمی است. هر دو رویکرد به خوبی کار میکنند. 👍
یک فایل در برابر چند فایل: انتخاب شما 📂 vs 🗂
شاید متوجه چیزی شده باشید: من همه چیز را در یک فایل واحد قرار دادم. این یک انتخاب طراحی با مزایا و معایب است.
رویکرد یک فایل (ExportUserData.cs):
public static class ExportUserData
{
public record Request(Guid UserId) : IRequest<Response>;
public record Response(string DownloadUrl, DateTime ExpiresAt);
public class Handler : IRequestHandler<Request, Response> { /* ... */ }
public class Validator : AbstractValidator<Request> { /* ... */ }
}
رویکرد چند فایل:
📁 ExportData/
├── ExportUserDataCommand.cs
├── ExportUserDataResponse.cs
├── ExportUserDataHandler.cs
├── ExportUserDataValidator.cs
└── ExportUserDataEndpoint.cs
یک فایل عالی است وقتی: ویژگی سرراست است، شما حداکثر نزدیکی و انسجام مکانی (locality) را میخواهید، و فایل از چند صد خط کد فراتر نمیرود.
تعداد خطوط کد یک قانون سختگیرانه نیست، اما اگر یک فایل فراتر از ۳۰۰-۴۰۰ خط رشد کند، برای خوانایی بهتر، تقسیم آن را در نظر بگیرید. باز هم، این یک موضوع ترجیح تیمی است و یک قانون سخت که من از آن پیروی کنم، نیست. مهم است که به غرایز خود و آنچه برای تیم شما درست به نظر میرسد، اعتماد کنید.
چند فایل بهتر کار میکند وقتی: شما منطق اعتبارسنجی پیچیده، چندین نوع پاسخ دارید، یا وقتی handler به اندازهای بزرگ میشود که میخواهید هر بار روی یک دغدغه تمرکز کنید.
شما حتی میتوانید هر دو رویکرد را در یک پروژه ترکیب کنید.
هر دو رویکرد کدهای مرتبط را کنار هم نگه میدارند. و این همان چیزی است که در معماری برش عمودی بیشترین اهمیت را دارد.
چرا این واقعاً کار میکند (و چگونه شروع کنیم) 🧠
مزایای برشهای عمودی به محض اینکه آن را امتحان کنید، آشکار میشود. مغز شما نیازی ندارد به خاطر بسپارد که کدام فایلها به کدام ویژگیها مرتبط هستند. همه چیز با هم زندگی میکند.
نیاز به اصلاح ویژگی خروجی داده دارید؟ همه چیز در پوشه ExportData است. نیازی به جستجو در لایههای Controllers, Services, و Repositories نیست. هر برش میتواند به طور مستقل تکامل یابد، بنابراین عملیات ساده CRUD ساده باقی میمانند در حالی که ویژگیهای پیچیده مانند خروجی داده میتوانند از رویکردهای پیشرفته استفاده کنند.
شما نیازی ندارید کل اپلیکیشن خود را یک شبه بازنویسی کنید. 🚀 با ویژگیهای جدید با استفاده از برشهای عمودی شروع کنید. همانطور که به کدهای موجود دست میزنید، به تدریج قطعات مرتبط را به پوشههای ویژگی منتقل کنید.
معماری خوب یعنی آسانتر کردن درک و اصلاح پایگاه کد شما. وقتی تمام کدها برای یک ویژگی با هم زندگی میکنند، شما انرژی ذهنی کمتری را صرف ناوبری در سولوشن خود میکنید و زمان بیشتری را صرف حل مشکلات واقعی میکنید.
تمام این مفاهیم به هم گره خوردهاند تا به شما در ساخت اپلیکیشنهای NET. قابل نگهداری و مقیاسپذیر کمک کنند.
📖 سری آموزشی کتاب C# 12 in a Nutshell
به این کد نگاه کنید. به نظر کاملاً منطقی میاد، ولی کامپایل نمیشه! چرا؟
امروز میخوایم یه نکته خیلی عمیق و فنی در مورد کست کردن پارامترهای جنریک (T) رو یاد بگیریم.
مشکل اینه که کامپایلر در زمان کامپایل، نمیدونه T قراره چه نوعی باشه. وقتی شما مینویسید (SomeType)arg، کامپایلر نمیدونه منظور شما کدوم یکی از ایناست:
🔹 تبدیل عددی (Numeric)
🔹 تبدیل رفرنس (Reference)
🔹 تبدیل Boxing/Unboxing
🔹 تبدیل سفارشی (Custom)
این ابهام باعث میشه کامپایلر جلوی شما رو بگیره تا از خطاهای پیشبینی نشده جلوگیری کنه.
برای رفع این ابهام، باید به کامپایلر بگیم دقیقاً منظورمون چه نوع تبدیلیه. دو راه حل اصلی وجود داره:
اپراتور as فقط و فقط برای تبدیلهای رفرنس استفاده میشه. چون ابهامی نداره، کامپایلر قبولش میکنه. اگه تبدیل شکست بخوره، null برمیگردونه.
راه حل عمومیتر که برای Value Typeها (Unboxing) هم کار میکنه، اینه که اول متغیر رو به object کست کنید. این کار به کامپایلر میگه "منظور من یه تبدیل رفرنس یا unboxing هست، نه عددی یا سفارشی".
برای Reference Type:
این یه نکته ظریف ولی خیلی مهمه که نشون میده کامپایلر #C چقدر به ایمنی نوع و رفع ابهام اهمیت میده.
🤯 تلهی کستینگ در جنریکها: چرا کامپایلر گیج میشود و راه حل چیست؟
به این کد نگاه کنید. به نظر کاملاً منطقی میاد، ولی کامپایل نمیشه! چرا؟
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 نهفتهست.
به زبان ساده، Covariance میگه شما میتونید یه نوع جنریک با پارامتر فرزند (مثل <string>) رو به همون نوع جنریک با پارامتر پدر (مثل <object>) تبدیل کنید. (چون string یک نوع از object است).
این قابلیت در #C فقط برای اینترفیسها و دلیگیتها فعاله، نه برای کلاسها.
چرا نمیتونیم یه <List<Bear رو به <List<Animal تبدیل کنیم؟ چون اگه این کار مجاز بود، میتونستیم یه فاجعه منطقی به بار بیاریم و ایمنی نوع (Type Safety) رو که #C بهش افتخار میکنه، از بین ببریم:
برای جلوگیری از این خطای منطقی، کلاسهای جنریک مثل <
حالا فرض کنید یه متد Wash داریم که میخواد یه لیستی از حیوانات رو بشوره. اگه ورودی رو <List<Animal بذاریم، نمیتونیم بهش <List<Bear پاس بدیم.
راه حل، جنریک کردن خود متد با استفاده از قیدهاست:
Covariance
یه مفهوم عمیقه که اساس کارکرد خیلی از اینترفیسهای مهم داتنت مثل <IEnumerable<T هست. درک اون، شما رو به درک عمیقتری از سیستم تایپینگ #C میرسونه.
🧬 جنریکهای پیشرفته: 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
مصرفکننده 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 تنبل با جریانی که در الگوریتم بالا نشان داده شد، مطابقت دارد.
"تنبل" به نحوه ذخیرهسازی شناسه پیام برای علامتگذاری آن به عنوان پردازش شده، اشاره دارد.
در مسیر خوشحال (happy path) ✅، ما پیام را مدیریت کرده و شناسه پیام را ذخیره میکنیم.
اگر message handler یک استثنا پرتاب کند 💥، ما هرگز شناسه پیام را ذخیره نمیکنیم و مصرفکننده میتواند دوباره اجرا شود.
در اینجا شکل پیادهسازی آمده است: 👇
با این حال، جالب است که چگونه ما مدیریت پیام و ذخیرهسازی شناسه پیام پردازش شده را پیادهسازی میکنیم.
شما میتوانید مصرفکننده 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.:وقتی شما اپلیکیشنهایی میسازید که روی چندین سرور یا فرآیند اجرا میشوند، در نهایت با مشکل دسترسی همزمان مواجه میشوید. چندین worker سعی میکنند یک منبع یکسان را در یک زمان بهروز کنند، و شما با شرایط رقابتی (race conditions)، کار تکراری، یا دادههای خراب مواجه میشوید. 💥
هماهنگسازی کار در چندین نمونه (Instance) 🔐
داتنت اصول اولیه کنترل همزمانی (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 دارد.
در حالی که رویکرد DIY کار میکند، اپلیکیشنهای پروداکشن به ویژگیهای پیچیدهتری نیاز دارند. کتابخانه DistributedLock موارد استثنایی (edge cases) را مدیریت کرده و چندین گزینه بکاند (Postgres, Redis, SqlServer و غیره) را فراهم میکند. شما میدانید که من طرفدار اختراع مجدد چرخ نیستم، بنابراین این یک انتخاب عالی است.
پکیج را نصب کنید: 📦
من از رویکردی با IDistributedLockProvider استفاده خواهم کرد که به خوبی با DI کار میکند. شما میتوانید یک قفل را بدون اینکه چیزی در مورد زیرساخت زیرین بدانید، به دست آورید. تمام کاری که باید انجام دهید این است که یک پیادهسازی از lock provider را در کانتینر DI خود ثبت کنید.
برای مثال، با استفاده از Postgres: 🔧
یا اگر میخواهید از Redis با الگوریتم Redlock استفاده کنید:
نحوه استفاده سرراست است: 👨💻
این کتابخانه تمام بخشهای دشوار را مدیریت میکند 👍: تایماوتها، تلاشهای مجدد، و تضمین اینکه قفلها حتی در سناریوهای شکست نیز آزاد میشوند.
این همچنین از بسیاری از بکاندها (SQL Server, Azure, ZooKeeper و غیره) پشتیبانی میکند، که آن را به یک انتخاب محکم برای بارهای کاری پروداکشن تبدیل میکند.
قفلگذاری توزیعشده چیزی نیست که هر روز به آن نیاز داشته باشید. اما وقتی به آن نیاز پیدا میکنید، شما را از باگهای ظریف و دردناکی که فقط تحت بار یا در پروداکشن ظاهر میشوند، نجات میدهد.
🔹 ساده شروع کنید: اگر از قبل از Postgres استفاده میکنید، advisory locks یک ابزار قدرتمند است.
🔹 برای یک تجربه توسعهدهنده تمیزتر، به سراغ کتابخانه DistributedLock بروید.
🔹 بکاندی را انتخاب کنید که متناسب با زیرساخت شما باشد (Postgres, Redis, SQL Server و غیره).
قفل مناسب در زمان مناسب تضمین میکند که سیستم شما سازگار، قابل اعتماد و انعطافپذیر باقی بماند، حتی در چندین فرآیند و سرور.(منبع)
💡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 است، بیایید آن را بررسی کنیم.
همانطور که نویسنده پکیج بیان کرده است: Error-Or یک discriminated union ساده و روان از یک خطا یا یک نتیجه است. کلاس Result در این کتابخانه <ErrorOr<T نامیده میشود.
در اینجا نحوه استفاده از آن برای کنترل جریان آمده است:
در اینجا نوع بازگشتی <ErrorOr<ShipmentResponse است که نشان میدهد آیا یک خطا برگردانده شده است یا ShipmentResponse.
کلاس Error خطاهای داخلی برای انواع زیر دارد، که یک متد برای هر نوع خطا فراهم میکند: 🏷
این کتابخانه به شما اجازه میدهد در صورت نیاز خطاهای سفارشی ایجاد کنید.
در endpoint API میتوانید خطا را مشابه کاری که قبلاً با آبجکت Result سفارشی خودمان انجام دادیم، مدیریت کنید: 👇
هنگام کار با Error-Or، من دوست دارم یک متد استاتیک ToProblem بسازم که خطا را به کد وضعیت (status code) مناسب تبدیل میکند: ✨
به این ترتیب، کد قبلی به این شکل تبدیل خواهد شد:
پکیجهای 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 میتواند به طور قابل توجهی مدیریت خطا را در اپلیکیشنهای شما بهبود ببخشد.
امیدوارم این مقاله برایتان مفید باشد.