قفلگذاری توزیعشده (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 میتواند به طور قابل توجهی مدیریت خطا را در اپلیکیشنهای شما بهبود ببخشد.
امیدوارم این مقاله برایتان مفید باشد.
Forwarded from Code With HSN
Media is too big
VIEW IN TELEGRAM
کتابخونه Prometheus-net کیلو چنده؟ OTEL رو بچسب! و بدون نیاز به داکر گرافانا بیار بالا
داشتن یک دشبورد برای مانیتورینگ برنامه DotNet شما اینبار بدون نیاز به داکر و گرافانا با Grafana Cloud دشبورد خودتو هرجای جهان که هستی بساز!
ویژگی های این دشبورد:
1. همراه داشتن نحوه ستاپ
2. تقریبا جامع و کامل است
3. دسترسی آسان به آن
4. داشتن توضیحات و راهکار برای حل مشکلات در خود دشبورد
🔗 دشبورد در Grafana: مشاهده دشبورد
📱 لینک مشاهده ویدئو: مشاهده ویدئو
داشتن یک دشبورد برای مانیتورینگ برنامه DotNet شما اینبار بدون نیاز به داکر و گرافانا با Grafana Cloud دشبورد خودتو هرجای جهان که هستی بساز!
ویژگی های این دشبورد:
1. همراه داشتن نحوه ستاپ
2. تقریبا جامع و کامل است
3. دسترسی آسان به آن
4. داشتن توضیحات و راهکار برای حل مشکلات در خود دشبورد
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 سفارشی است. 🌐
🔒 ملاحظات حرفهای و بهترین شیوهها (Best Practices and Considerations)
در اینجا نکات کلیدی وجود دارد که بهتر است همیشه هنگام پیادهسازی همتوانی (Idempotency) در نظر بگیریم: 👇
⏰ عمر کش (Cache Duration)
مدت زمان کش یک موضوع حساس است. ⏳ هدف من پوشش دادن پنجرههای تلاش مجدد معقول بدون نگهداری دادههای منسوخ است. یک زمان کش معقول معمولاً از چند دقیقه تا ۲۴-۴۸ ساعت متغیر است و این بسته به مورد استفاده خاص شما دارد.
🧵 مدیریت همروندی (Concurrency)
همروندی میتواند دردسرساز باشد، به ویژه در APIهایی با ترافیک بالا. 🤯 یک پیادهسازی ایمن از نظر ریسمان (thread-safe) با استفاده از قفل توزیع شده (Distributed Lock) عالی عمل میکند. این کار کنترل امور را هنگامی که چندین درخواست همزمان وارد میشوند، حفظ میکند. اما این اتفاق باید یک رخداد نادر باشد.
💾 بکاند توزیع شده: Redis
برای تنظیمات توزیع شده، Redis انتخاب من است. 🚀 این ابزار به عنوان یک کش مشترک، عالی عمل میکند و همتوانی را در تمام نمونههای (Instances) API شما سازگار نگه میدارد. علاوه بر این، Redis قابلیت قفل توزیع شده را نیز مدیریت میکند.
🚫 جلوگیری از سوء استفاده از کلید
چه اتفاقی میافتد اگر یک کلاینت، کلید همتوانی را با یک بدنه درخواست (Request Body) متفاوت مجدداً استفاده کند؟ 🧐 در این حالت، من یک خطا برمیگردانم. رویکرد من این است که بدنه درخواست را هش (Hash) کنم و آن را با کلید همتوانی ذخیره کنم. هنگامی که یک درخواست وارد میشود، هشهای بدنه درخواست را مقایسه میکنم. اگر متفاوت باشند، خطا برمیگردانم. این کار از سوء استفاده از کلیدهای همتوانی جلوگیری کرده و یکپارچگی (Integrity) API شما را حفظ میکند.
📝 جمعبندی (Summary)
پیادهسازی همتوانی در REST APIها قابلیت اطمینان و سازگاری سرویس را افزایش میدهد. 📈 این تضمین میکند که درخواستهای یکسان، نتیجهای مشابه دارند و از تکرارهای ناخواسته جلوگیری کرده و مشکلات شبکه را به خوبی مدیریت میکنند.
در حالی که پیادهسازی ما یک پایه و اساس را فراهم میکند، توصیه میکنم آن را با نیازهای خود تطبیق دهید. 🎯 بر عملیاتهای حیاتی در APIهای خود تمرکز کنید، به ویژه آنهایی که وضعیت سیستم را تغییر میدهند یا فرآیندهای مهم کسب و کار را راهاندازی میکنند.
با پذیرش همتوانی، شما در حال ساختن APIهایی قویتر و کاربرپسندتر هستید. 💪
🔖 هشتگها:
#ASPNetCore #Idempotency
به نظرم Nick با همین سه خط کارو تموم کرد. ما دیگه کاری با اون more... نداریم🤙🏻
اگه قبول داری روی more... کلیک نکن که ادامش مهم نیست😅
اگه قبول داری روی more... کلیک نکن که ادامش مهم نیست😅
📖 سری آموزشی کتاب C# 12 in a Nutshell
Delegate
یک شیء است که میداند چگونه یک متد را فراخوانی کند. 📞
یک نوع دلیگیت (Delegate Type) نوع متدی را که نمونههای آن دلیگیت میتوانند فراخوانی کنند، تعریف میکند. به طور خاص، نوع بازگشتی متد و انواع پارامترهای آن را مشخص میکند. تعریف زیر، یک نوع دلیگیت به نام Transformer را تعریف میکند:
Transformer
با هر متدی که دارای نوع بازگشتی int و یک پارامتر int باشد، سازگار است، مانند این متد:
تخصیص یک متد به یک متغیر دلیگیت، یک نمونه دلیگیت (Delegate Instance) ایجاد میکند:
شما میتوانید یک نمونه دلیگیت را دقیقاً مانند یک متد فراخوانی کنید:
مثال کامل:
یک نمونه دلیگیت، به معنای واقعی کلمه، به عنوان نماینده (Delegate) برای فراخواننده عمل میکند: فراخواننده دلیگیت را فراخوانی میکند، و سپس دلیگیت متد هدف را صدا میزند. 🔄 این عدم وابستگی (Indirection)، فراخواننده را از متد هدف جدا میکند.
نکته فنی: عبارت
Transformer t = Square;
در حقیقت کوتاه شدهی
Transformer t = new Transformer (Square);
است. هنگامی که به Square بدون پرانتز اشاره میکنیم، از یک "Method Group" استفاده میکنیم. اگر متد Overload شده باشد، #C بر اساس امضای دلیگیتی که به آن اختصاص داده میشود، Overload صحیح را انتخاب میکند.
همچنین، عبارت t(3) کوتاه شدهی t.Invoke(3) است. 💡
دلیگیتها شبیه به Callback هستند که یک اصطلاح کلیتر است و ساختارهایی مانند اشارهگرهای تابع C را شامل میشود.
یک متغیر دلیگیت در زمان اجرا، متد را به خود میگیرد. این قابلیت برای نوشتن متدهای Plug-in بسیار مفید است.
در مثال زیر، ما یک متد کاربردی به نام Transform داریم که یک تابع تبدیل (Transform) را روی هر عنصر از یک آرایه اعمال میکند. متد Transform دارای یک پارامتر دلیگیت است که میتوانید از آن برای تعیین تابع Plug-in استفاده کنید:
ما میتوانیم به سادگی با تغییر Square به Cube در خط دوم کد، نوع تبدیل را عوض کنیم. 🔄
متد Transform ما یک تابع مرتبه بالاتر (Higher-Order Function) است، زیرا تابعی است که یک تابع دیگر را به عنوان آرگومان میپذیرد. (یک متد که یک دلیگیت را برمیگرداند نیز یک تابع مرتبه بالاتر محسوب میشود.) 🧠
به نظر شما، مهمترین کاربرد دلیگیتها (به خصوص قبل از معرفی Lambda Expressions و Action/Func) در معماری کدهای #C چه بود؟
💡 مفهوم Delegates (دلیگیتها): شیئی که متدها را صدا میزند
Delegate
یک شیء است که میداند چگونه یک متد را فراخوانی کند. 📞
یک نوع دلیگیت (Delegate Type) نوع متدی را که نمونههای آن دلیگیت میتوانند فراخوانی کنند، تعریف میکند. به طور خاص، نوع بازگشتی متد و انواع پارامترهای آن را مشخص میکند. تعریف زیر، یک نوع دلیگیت به نام Transformer را تعریف میکند:
delegate int Transformer (int x);
Transformer
با هر متدی که دارای نوع بازگشتی int و یک پارامتر int باشد، سازگار است، مانند این متد:
int Square (int x) { return x * x; }
// یا به شکل کوتاهتر:
int Square (int x) => x * x;🤝 ایجاد و فراخوانی دلیگیت
تخصیص یک متد به یک متغیر دلیگیت، یک نمونه دلیگیت (Delegate Instance) ایجاد میکند:
Transformer t = Square; // ایجاد نمونه دلیگیت
شما میتوانید یک نمونه دلیگیت را دقیقاً مانند یک متد فراخوانی کنید:
int answer = t(3); // answer برابر 9 میشود
مثال کامل:
Transformer t = Square; // Create delegate instance
int result = t(3); // Invoke delegate
Console.WriteLine (result); // 9
int Square (int x) => x * x;
delegate int Transformer (int x); // Delegate type declaration
یک نمونه دلیگیت، به معنای واقعی کلمه، به عنوان نماینده (Delegate) برای فراخواننده عمل میکند: فراخواننده دلیگیت را فراخوانی میکند، و سپس دلیگیت متد هدف را صدا میزند. 🔄 این عدم وابستگی (Indirection)، فراخواننده را از متد هدف جدا میکند.
نکته فنی: عبارت
Transformer t = Square;
در حقیقت کوتاه شدهی
Transformer t = new Transformer (Square);
است. هنگامی که به Square بدون پرانتز اشاره میکنیم، از یک "Method Group" استفاده میکنیم. اگر متد Overload شده باشد، #C بر اساس امضای دلیگیتی که به آن اختصاص داده میشود، Overload صحیح را انتخاب میکند.
همچنین، عبارت t(3) کوتاه شدهی t.Invoke(3) است. 💡
دلیگیتها شبیه به Callback هستند که یک اصطلاح کلیتر است و ساختارهایی مانند اشارهگرهای تابع C را شامل میشود.
🔌 نوشتن متدهای Plug-in با دلیگیتها
یک متغیر دلیگیت در زمان اجرا، متد را به خود میگیرد. این قابلیت برای نوشتن متدهای Plug-in بسیار مفید است.
در مثال زیر، ما یک متد کاربردی به نام Transform داریم که یک تابع تبدیل (Transform) را روی هر عنصر از یک آرایه اعمال میکند. متد Transform دارای یک پارامتر دلیگیت است که میتوانید از آن برای تعیین تابع Plug-in استفاده کنید:
int[] values = { 1, 2, 3 };
Transform (values, Square); // Hook in the Square method
foreach (int i in values) Console.Write (i + " "); // خروجی: 1 4 9
// پیادهسازی متد Transform
void Transform (int[] values, Transformer t)
{
for (int i = 0; i < values.Length; i++)
values[i] = t (values[i]);
}
int Square (int x) => x * x;
int Cube (int x) => x * x * x;
delegate int Transformer (int x);ما میتوانیم به سادگی با تغییر Square به Cube در خط دوم کد، نوع تبدیل را عوض کنیم. 🔄
متد Transform ما یک تابع مرتبه بالاتر (Higher-Order Function) است، زیرا تابعی است که یک تابع دیگر را به عنوان آرگومان میپذیرد. (یک متد که یک دلیگیت را برمیگرداند نیز یک تابع مرتبه بالاتر محسوب میشود.) 🧠
🤔 جمعبندی و تجربه شما
به نظر شما، مهمترین کاربرد دلیگیتها (به خصوص قبل از معرفی Lambda Expressions و Action/Func) در معماری کدهای #C چه بود؟
🔖 هشتگها:
#CSharp #Delegates #AdvancedCSharp #OOP #Callback
📖 سری آموزشی کتاب C# 12 in a Nutshell
متد هدف یک دلیگیت (Delegate) میتواند یک متد محلی (local)، Static یا Instance باشد. 💡
مثال زیر یک متد هدف Static را نشان میدهد:
مثال زیر یک متد هدف Instance را نشان میدهد:
هنگامی که یک متد Instance به یک شیء دلیگیت اختصاص داده میشود، آن شیء نه تنها یک رفرنس به خود متد، بلکه یک رفرنس به نمونه (Instance) که متد به آن تعلق دارد، را نیز حفظ میکند. 🤝
خاصیت Target از کلاس System.Delegate نشاندهنده همین نمونه است (و برای دلیگیتی که به یک متد Static اشاره میکند، null خواهد بود).
مثالی از حفظ Instance:
از آنجایی که نمونه در خاصیت Target دلیگیت ذخیره میشود، طول عمر آن حداقل تا زمانی که طول عمر دلیگیت است، تمدید میشود. 🕰
تمامی نمونههای دلیگیت قابلیت چندپخشی (Multicast) دارند. این بدان معناست که یک نمونه دلیگیت میتواند نه تنها به یک متد هدف، بلکه به لیستی از متدهای هدف اشاره کند.
اپراتورهای + و += نمونههای دلیگیت را ترکیب میکنند:
فراخوانی d اکنون هر دو متد SomeMethod1 و SomeMethod2 را صدا میزند. دلیگیتها به همان ترتیبی که اضافه شدهاند، فراخوانی میشوند. ➡️
اپراتورهای - و -= عملوند دلیگیت سمت راست را از عملوند دلیگیت سمت چپ حذف میکنند:
فراخوانی d اکنون تنها باعث فراخوانی SomeMethod2 میشود.
فراخوانی + یا += روی یک متغیر دلیگیت با مقدار null کار میکند و معادل تخصیص یک مقدار جدید به متغیر است.
دلیگیتها تغییرناپذیر (Immutable) هستند، بنابراین وقتی شما += یا -= را فراخوانی میکنید، در واقع یک نمونه دلیگیت جدید ایجاد کرده و آن را به متغیر موجود اختصاص میدهید. 🔄
اگر یک دلیگیت چندپخشی نوع بازگشتی غیر void داشته باشد، فراخواننده مقدار بازگشتی را از آخرین متدی که فراخوانی شده است، دریافت میکند. متدهای قبلی همچنان فراخوانی میشوند، اما مقادیر بازگشتی آنها نادیده گرفته میشوند.
متد HardWork یک پارامتر دلیگیت ProgressReporter دارد که برای گزارش پیشرفت کار از آن استفاده میشود:
برای نظارت بر پیشرفت، میتوانیم دو متد مستقل را ترکیب کنیم:
🎯 متدهای هدف (Target Methods): Static یا Instance؟
متد هدف یک دلیگیت (Delegate) میتواند یک متد محلی (local)، Static یا Instance باشد. 💡
🔹 متد هدف Static
مثال زیر یک متد هدف Static را نشان میدهد:
Transformer t = Test.Square;
Console.WriteLine (t(10)); // خروجی: 100
class Test { public static int Square (int x) => x * x; }
delegate int Transformer (int x);
🔸 متد هدف Instance
مثال زیر یک متد هدف Instance را نشان میدهد:
Test test = new Test();
Transformer t = test.Square;
Console.WriteLine (t(10)); // خروجی: 100
class Test { public int Square (int x) => x * x; }
delegate int Transformer (int x);
🧠 نحوه عملکرد با Instance Methodها
هنگامی که یک متد Instance به یک شیء دلیگیت اختصاص داده میشود، آن شیء نه تنها یک رفرنس به خود متد، بلکه یک رفرنس به نمونه (Instance) که متد به آن تعلق دارد، را نیز حفظ میکند. 🤝
خاصیت Target از کلاس System.Delegate نشاندهنده همین نمونه است (و برای دلیگیتی که به یک متد Static اشاره میکند، null خواهد بود).
مثالی از حفظ Instance:
MyReporter r = new MyReporter();
r.Prefix = "%Complete: ";
ProgressReporter p = r.ReportProgress;
p(99); // خروجی: %Complete: 99
Console.WriteLine (p.Target == r); // خروجی: True
Console.WriteLine (p.Method); // خروجی: Void ReportProgress(Int32)
r.Prefix = "";
p(99); // خروجی: 99
public delegate void ProgressReporter (int percentComplete);
class MyReporter
{
public string Prefix = "";
public void ReportProgress (int percentComplete)
=> Console.WriteLine (Prefix + percentComplete);
}
از آنجایی که نمونه در خاصیت Target دلیگیت ذخیره میشود، طول عمر آن حداقل تا زمانی که طول عمر دلیگیت است، تمدید میشود. 🕰
⛓️ دلیگیتهای چندپخشی (Multicast Delegates)
تمامی نمونههای دلیگیت قابلیت چندپخشی (Multicast) دارند. این بدان معناست که یک نمونه دلیگیت میتواند نه تنها به یک متد هدف، بلکه به لیستی از متدهای هدف اشاره کند.
➕ ترکیب (Combine) دلیگیتها
اپراتورهای + و += نمونههای دلیگیت را ترکیب میکنند:
SomeDelegate d = SomeMethod1;
d += SomeMethod2;
فراخوانی d اکنون هر دو متد SomeMethod1 و SomeMethod2 را صدا میزند. دلیگیتها به همان ترتیبی که اضافه شدهاند، فراخوانی میشوند. ➡️
➖ حذف (Remove) دلیگیتها
اپراتورهای - و -= عملوند دلیگیت سمت راست را از عملوند دلیگیت سمت چپ حذف میکنند:
d -= SomeMethod1;
فراخوانی d اکنون تنها باعث فراخوانی SomeMethod2 میشود.
💡 نکات تکمیلی
فراخوانی + یا += روی یک متغیر دلیگیت با مقدار null کار میکند و معادل تخصیص یک مقدار جدید به متغیر است.
دلیگیتها تغییرناپذیر (Immutable) هستند، بنابراین وقتی شما += یا -= را فراخوانی میکنید، در واقع یک نمونه دلیگیت جدید ایجاد کرده و آن را به متغیر موجود اختصاص میدهید. 🔄
⚠️ مقادیر بازگشتی در Multicast
اگر یک دلیگیت چندپخشی نوع بازگشتی غیر void داشته باشد، فراخواننده مقدار بازگشتی را از آخرین متدی که فراخوانی شده است، دریافت میکند. متدهای قبلی همچنان فراخوانی میشوند، اما مقادیر بازگشتی آنها نادیده گرفته میشوند.
🖥 مثال عملی از Multicast Delegate
متد HardWork یک پارامتر دلیگیت ProgressReporter دارد که برای گزارش پیشرفت کار از آن استفاده میشود:
public delegate void ProgressReporter (int percentComplete);
public class Util
{
public static void HardWork (ProgressReporter p)
{
for (int i = 0; i < 10; i++)
{
p (i * 10); // Invoke delegate
System.Threading.Thread.Sleep (100); // Simulate hard work
}
}
}
برای نظارت بر پیشرفت، میتوانیم دو متد مستقل را ترکیب کنیم:
ProgressReporter p = WriteProgressToConsole;
p += WriteProgressToFile; // ترکیب دو متد
Util.HardWork (p);
void WriteProgressToConsole (int percentComplete)
=> Console.WriteLine (percentComplete);
void WriteProgressToFile (int percentComplete)
=> System.IO.File.WriteAllText ("progress.txt",
percentComplete.ToString());
🔖 هشتگها:
#CSharp #Delegates #Multicast #OOP #AdvancedCSharp
⚙️ موارد پیشرفته در Rate Limiting (محدودسازی نرخ) در NET.
محدودسازی نرخ (Rate Limiting) به معنای محدود کردن تعداد درخواستها به برنامه شماست. این محدودیت معمولاً در یک بازه زمانی خاص یا بر اساس معیارهای دیگر اعمال میشود. ⏱️
🛡 دلایل اهمیت Rate Limiting
محدودسازی نرخ به دلایل زیر بسیار مفید است:
• امنیت را بهبود میبخشد.
• در برابر حملات DDoS محافظت میکند.
• از Overloading (بارگذاری بیش از حد) سرورهای برنامه جلوگیری میکند.
• با جلوگیری از مصرف غیرضروری منابع، هزینهها را کاهش میدهد.
نکته: در حالی که NET 7. با یک Rate Limiter داخلی عرضه شد، اما باید بدانید که چگونه آن را به درستی پیادهسازی کنید تا سیستم شما دچار توقف نشود. 🛑
🎯 در این پست خواهید آموخت:
🔹️ چگونه کاربران را بر اساس آدرس IP محدود کنیم.
🔹️ چگونه کاربران را بر اساس هویت (Identity) آنها محدود کنیم.
🔹️ چگونه Rate Limiting را روی Reverse Proxy اعمال کنیم.
بنابراین، بیایید شیرجه بزنیم! 👇
✨ Rate Limiting داخلی در .NET 7
از نسخه NET 7. به بعد، ما به Middleware محدودسازی نرخ داخلی در فضای نام Microsoft.AspNetCore.RateLimiting دسترسی داریم. API آن ساده است و شما میتوانید با چند خط کد، یک سیاست محدودسازی نرخ (Rate Limit Policy) ایجاد کنید.
ما میتوانیم از یکی از چهار الگوریتم محدودسازی نرخ استفاده کنیم:
• Fixed window (پنجره ثابت)
• Sliding window (پنجره کشویی)
• Token bucket (سطل توکن)
• Concurrency (همروندی)
💻 تعریف سیاست Token Bucket
در اینجا نحوه تعریف یک سیاست محدودسازی نرخ با فراخوانی متد AddTokenBucketLimiter آمده است:
builder.Services.AddRateLimiter(rateLimiterOptions =>
{
rateLimiterOptions.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
rateLimiterOptions.AddTokenBucketLimiter("token", options =>
{
options.TokenLimit = 1000;
options.ReplenishmentPeriod = TimeSpan.FromHours(1);
options.TokensPerPeriod = 700;
options.AutoReplenishment = true;
});
});
حالا میتوانید سیاست محدودسازی نرخ با نام token را در اِندپوینت یا کنترلر خود ارجاع دهید.
شما همچنین باید RateLimitingMiddleware را به خط لوله درخواست (Request Pipeline) اضافه کنید:
app.UseRateLimiter();
شما میتوانید جزئیات بیشتری درباره Rate Limiting در NET 7. کسب کنید.
📡 محدودسازی نرخ کاربران بر اساس آدرس IP
رویکردی که پیشتر نشان داده شد، یک مشکل دارد - سیاست محدودسازی نرخ سراسری (Global) است و بر همه کاربران اعمال میشود. ❌
اغلب اوقات، شما نمیخواهید این کار را انجام دهید. محدودسازی نرخ باید جزئی (Granular) باشد و بر تکتک کاربران اعمال شود. 👤
خوشبختانه، میتوانید با ایجاد یک RateLimitPartition به این هدف برسید.
🧱 اجزای RateLimitPartition
RateLimitPartition دو جزء دارد:
🔹️ کلید پارتیشن (Partition key)
🔹️ سیاست محدودسازی نرخ (Rate limiter policy)
💻 پیادهسازی محدودسازی با IP Address
در اینجا نحوه تعریف یک Rate Limiter با سیاست Fixed Window آمده است، که در آن کلید پارتیشن، آدرس IP کاربر است:
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("fixed-by-ip", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString(),
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1)
}));
});
محدودسازی نرخ بر اساس آدرس IP میتواند یک لایه امنیتی خوب برای کاربران احراز هویت نشده (unauthenticated users) باشد. 🔐 شما نمیدانید چه کسی به سیستم شما دسترسی دارد و نمیتوانید محدودسازی نرخ جزئیتری اعمال کنید. این روش میتواند به محافظت از سیستم شما در برابر کاربران مخربی که سعی در انجام حمله DDoS دارند، کمک کند.
🔗 ایجاد محدودکنندههای زنجیرهای (Chained Limiters)
شما همچنین میتوانید با استفاده از API با نام CreateChained، محدودکنندههای زنجیرهای ایجاد کنید. این API به شما اجازه میدهد تا چندین PartitionedRateLimiter را ارسال کنید که در یک PartitionedRateLimiter ترکیب میشوند. محدودکننده زنجیرهای، تمام محدودکنندههای ورودی را به صورت متوالی (Sequence) (یکی پس از دیگری) اجرا میکند. 🔄
🛡 ملاحظات Reverse Proxy
اگر برنامه شما پشت یک Reverse Proxy در حال اجراست، باید مطمئن شوید که آدرس IP خود پروکسی را محدود نکنید. ⚠️ Reverse Proxyها معمولاً آدرس IP اصلی کاربر را با استفاده از هدر X-Forwarded-For ارسال میکنند. بنابراین، میتوانید از این هدر به عنوان کلید پارتیشن استفاده کنید:
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("fixed-by-ip", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
httpContext.Request.Headers["X-Forwarded-For"].ToString(), // استفاده از هدر
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1)
}));
});
🧑💻 محدودسازی نرخ کاربران بر اساس هویت (Identity)
اگر از کاربران میخواهید که در API شما احراز هویت (Authenticate) کنند، میتوانید تشخیص دهید که کاربر فعلی کیست. سپس میتوانید از هویت (Identity) کاربر به عنوان کلید پارتیشن (Partition Key) برای یک RateLimitPartition استفاده کنید. 🆔
💻 پیادهسازی محدودسازی با هویت کاربر
در اینجا نحوه ایجاد چنین سیاست محدودسازی نرخ آمده است:
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("fixed-by-user", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.User.Identity?.Name?.ToString(),
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1)
}));
});
من از مقدار User.Identity در HttpContext استفاده میکنم تا Claim مربوط به Name کاربر فعلی را به دست آورم. این معمولاً متناظر با Claim با نام sub درون یک JWT است - که همان شناسه کاربر میباشد.
🛡 اعمال Rate Limiting روی Reverse Proxy
در یک پیادهسازی قوی، شما میخواهید محدودسازی نرخ را در سطح Reverse Proxy اعمال کنید، پیش از آنکه درخواست به API شما برسد. و اگر یک سیستم توزیع شده دارید، این یک الزام است. در غیر این صورت، سیستم شما به درستی کار نخواهد کرد. 🚨
پیادهسازیهای متعددی برای Reverse Proxy وجود دارد که میتوانید از بین آنها انتخاب کنید.
YARP
یک Reverse Proxy با یکپارچگی عالی با NET. است. این امر تعجبآور نیست، زیرا YARP با #C نوشته شده است.
⚙️ اعمال Rate Limiting در تنظیمات YARP
برای پیادهسازی محدودسازی نرخ روی Reverse Proxy با استفاده از YARP، شما باید:
یک سیاست محدودسازی نرخ تعریف کنید (همانند مثالهای قبلی).
RateLimiterPolicy
را برای مسیر (Route) در تنظیمات YARP پیکربندی کنید:
"products-route": {
"ClusterId": "products-cluster",
"RateLimiterPolicy": "sixty-per-minute-fixed",
"Match": {
"Path": "/products/{**catch-all}"
},
"Transforms": [
{ "PathPattern": "{**catch-all}" }
]
}توجه به حافظه: Middleware محدودسازی نرخ داخلی، از یک حافظه in-memory برای ردیابی تعداد درخواستها استفاده میکند. اگر میخواهید Reverse Proxy خود را در یک راهاندازی با در دسترس بودن بالا (High-Availability) اجرا کنید، به استفاده از یک Distributed Cache نیاز خواهید داشت. استفاده از یک Redis backplane برای محدودسازی نرخ، یک گزینه خوب برای بررسی است. 💾
📝 سخن پایانی
با استفاده از PartitionedRateLimiter میتوانید به راحتی سیاستهای محدودسازی نرخ جزئی (Granular) ایجاد کنید.
دو رویکرد رایج عبارتند از:
محدودسازی نرخ بر اساس آدرس IP 🌐
محدودسازی نرخ بر اساس شناسه کاربر (User Identifier) 👤
تیم NET. محدودسازی نرخ را ارائه کرد که بسیار شگفت انگیز است. اما پیادهسازی فعلی کاستیهایی دارد. مشکل اصلی این است که فقط به صورت in-memory کار میکند. برای یک راهکار توزیع شده (distributed)، شما باید خودتان چیزی پیادهسازی کنید یا از یک کتابخانه خارجی استفاده نمایید.
شما میتوانید از Reverse Proxy YARP برای ساختن سیستمهای توزیع شده قوی و مقیاسپذیر استفاده کنید. و اضافه کردن Rate Limiting در سطح Reverse Proxy تنها به چند خط کد نیاز دارد. بهتر است که به طور گستردهای از آن در سیستمهایمان استفاده میکنیم.
از اینکه این مقاله را خواندید، متشکرم. و فوقالعاده بمانید! ✨
🔖 هشتگها:
#DotNet #RateLimiting #Security #ASPNETCore #ReverseProxy #IPAddress