C# Geeks (.NET) – Telegram
Forwarded from Code With HSN
Media is too big
VIEW IN TELEGRAM
کتابخونه Prometheus-net کیلو چنده؟ OTEL رو بچسب! و بدون نیاز به داکر گرافانا بیار بالا

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

GET و HEAD: 🔍

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

PUT: 🔄

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

DELETE: 🗑

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

OPTIONS: ⚙️

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

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

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

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

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

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

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


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

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

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

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

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

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

💫 ترکیب Attribute و IAsyncActionFilter

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

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

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

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

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

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

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

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

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

return;
}

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

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

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

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

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


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

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

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


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

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

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

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

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

object? result = await next(context);

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

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

return result;
}
}

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

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

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

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


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

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


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

یک جایگزین برای دو پیاده‌سازی قبلی (Attribute و Endpoint Filter) پیاده‌سازی منطق هم‌توانی در یک Middleware سفارشی است. 🌐
🔒 ملاحظات حرفه‌ای و بهترین شیوه‌ها (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... کلیک نکن که ادامش مهم نیست😅
📖 سری آموزشی کتاب C# 12 in a Nutshell

💡 مفهوم 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

🎯 متدهای هدف (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
💡 شکست واقعی در Backend Engineering چیست؟

شکست در Backend Engineering، نوشتن کد بد نیست.

خراب شدن یک دیپلوی (Deployment) نیست. 💥

طراحی یک شمای پایگاه داده (Schema) که مقیاس‌پذیر نیست، نیست. 📈

این‌ها فقط یادگیری هستند. 🧠
🛑 پس شکست واقعی چیست؟
شکست واقعی؟
این است که بخواهی یک توسعه‌دهنده (Dev) باشی، اما هرگز شروع نکنی. 🚪

بیشتر مردم منتظر می‌مانند تا:

• آموزش "کامل" را پیدا کنند.
• دوره "درست" را پیدا کنند.
• وقت آزاد بیشتری داشته باشند.

اما Backend Engineering تنها با انجام دادن، آموخته می‌شود: 💻

• نوشتن اولین API خود.
• شکستن اولین شمای پایگاه داده خود.
• دیپلوی کردن چیزی که به سختی کار می‌کند.

این اشتباهات بخشی از فرآیند هستند. 🧩
اجتناب از آن‌ها همان چیزی است که شما را عقب نگه می‌دارد. 🛑

آشفته شروع کن. کوچک شروع کن. فقط شروع کن.
📩 پیام‌رسانی ساده در NET. با Redis Pub/Sub


ردیس یک انتخاب محبوب برای کش کردن داده‌ها است، اما قابلیت‌های آن بسیار فراتر از این است. یکی از ویژگی‌های کمتر شناخته شده آن، پشتیبانی از Pub/Sub است. کانال‌های Redis یک رویکرد جذاب برای پیاده‌سازی پیام‌رسانی Real-time در برنامه‌های NET. شما ارائه می‌دهند. با این حال، همانطور که به زودی خواهید دید، کانال‌ها دارای نقاط ضعفی نیز هستند. 🧐

💡 در این پست بررسی خواهیم کرد:

• اصول اولیه کانال‌های Redis.

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

• پیاده‌سازی یک مثال Pub/Sub در NET.

• ابطال کش (Cache Invalidation) در سیستم‌های توزیع شده.

بیایید وارد جزئیات شویم! 👇

🌐 کانال‌های ردیس (Redis Channels)

Redis channels
کانال‌های ارتباطی نام‌گذاری شده‌ای هستند که الگوی پیام‌رسانی Publish/Subscribe را پیاده‌سازی می‌کنند. 🗣

هر کانال با یک نام منحصر به فرد (مانند notifications یا updates) شناسایی می‌شود. کانال‌ها تحویل پیام از Publishers (ناشران) به Subscribers (مشترکین) را تسهیل می‌کنند.

Publishers
از دستور PUBLISH برای ارسال پیام به یک کانال خاص استفاده می‌کنند.

Subscribers
از دستور SUBSCRIBE برای ثبت علاقه به دریافت پیام از یک کانال استفاده می‌کنند.

🔄 مدل Topic-Based

کانال‌های Redis از یک مدل Publish-Subscribe مبتنی بر Topic (موضوع) پیروی می‌کنند. چندین Publisher می‌توانند به یک کانال پیام ارسال کنند و چندین Subscriber می‌توانند پیام‌ها را از آن کانال دریافت کنند.

⚠️ محدودیت کلیدی: عدم ذخیره پیام
با این حال، توجه به این نکته بسیار حیاتی است که کانال‌های Redis پیام‌ها را ذخیره نمی‌کنند. اگر هنگام انتشار یک پیام، هیچ مشترکی برای یک کانال وجود نداشته باشد، آن پیام بلافاصله دور ریخته می‌شود.

کانال‌های Redis دارای معناشناسی "حداکثر یک بار تحویل" (at-most-once delivery) هستند.
📩 موارد استفاده عملی (Practical Use Cases)

با توجه به اینکه کانال‌های Redis با معناشناسی "حداکثر یک بار تحویل" (at-most-once delivery) کار می‌کنند (پیام‌ها در صورت عدم حضور مشترک ممکن است از دست بروند)، این کانال‌ها برای سناریوهایی مناسب هستند که از دست دادن گاه‌به‌گاه پیام قابل قبول بوده و ارتباط Real-time یا نزدیک به Real-time مطلوب است.

🎯 چند مورد استفاده ممکن:

🔹️فیدهای شبکه‌های اجتماعی: انتشار پست‌ها یا به‌روزرسانی‌های جدید برای کاربران. 📢

🔹️به‌روزرسانی امتیازات زنده: ارسال امتیازات زنده بازی‌ها یا به‌روزرسانی‌های ورزشی برای مشترکین. ⚽️

🔹️اپلیکیشن‌های چت: تحویل پیام‌های چت به صورت Real-time به شرکت‌کنندگان فعال. 💬

🔹️ویرایش مشارکتی: انتشار تغییرات در محیط‌های ویرایش مشارکتی.

🔹️به‌روزرسانی‌های Distributed Cache: ابطال ورودی‌های کش در سرورهای متعدد هنگام تغییر داده‌ها. (این مورد را در ادامه با جزئیات بیشتر پوشش خواهیم داد.)

💡نکته مهم: کانال‌های Redis بهترین انتخاب برای داده‌های حیاتی که از دست دادن پیام در آن‌ها غیرقابل قبول است، نیستند. در چنین مواردی، باید یک سیستم پیام‌رسانی با قابلیت اطمینان بالاتر را در نظر بگیرید. 🔐

💻 پیاده‌سازی Pub/Sub با Redis Channels در NET.


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

📥 نصب و راه‌اندازی
ابتدا آن را نصب می‌کنیم:
Install-Package StackExchange.Redis

شما می‌توانید Redis را به صورت محلی در یک کانتینر Docker اجرا کنید. پورت پیش‌فرض 6379 است:
docker run -it -p 6379:6379 redis


📤 سرویس Producer (ناشر)
در اینجا یک سرویس Background ساده آمده است که به عنوان Producer (ناشر) پیام‌های ما عمل می‌کند.

ما با اتصال به نمونه Redis خود یک ConnectionMultiplexer ایجاد می‌کنیم. این به ما امکان می‌دهد تا یک ISubscriber به دست آوریم که می‌توانیم از آن برای پیام‌رسانی Pub/Sub استفاده کنیم. ISubscriber ما را قادر می‌سازد تا با تعیین نام کانال، یک پیام را در آن کانال منتشر کنیم.
public class Producer(ILogger<Producer> logger) : BackgroundService
{
private static readonly string ConnectionString = "localhost:6379";
private static readonly ConnectionMultiplexer Connection =
ConnectionMultiplexer.Connect(ConnectionString);
private const string Channel = "messages";

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var subscriber = Connection.GetSubscriber();
while (!stoppingToken.IsCancellationRequested)
{
var message = new Message(Guid.NewGuid(), DateTime.UtcNow);
var json = JsonSerializer.Serialize(message);
await subscriber.PublishAsync(Channel, json);
logger.LogInformation(
"Sending message: {Channel} - {@Message}",
message);
await Task.Delay(5000, stoppingToken);
}
}
}

حالا نوبت به معرفی یک سرویس Background مجزا برای مصرف پیام‌ها می‌رسد. ⬇️
📩 پیام‌رسانی ساده در NET. با Redis Pub/Sub (ادامه)


📥 سرویس Consumer (مصرف‌کننده)
Consumer
به همان نمونه Redis متصل می‌شود و یک ISubscriber به دست می‌آورد. ISubscriber متد SubscribeAsync را در اختیار ما قرار می‌دهد که می‌توانیم از آن برای اشتراک در دریافت پیام از یک کانال مشخص استفاده کنیم. این متد یک Callback Delegate را می‌پذیرد که می‌توانیم از آن برای رسیدگی به پیام دریافتی استفاده کنیم. 🎧
public class Consumer(ILogger<Consumer> logger) : BackgroundService
{
private static readonly string ConnectionString = "localhost:6379";
private static readonly ConnectionMultiplexer Connection =
ConnectionMultiplexer.Connect(ConnectionString);
private const string Channel = "messages";

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var subscriber = Connection.GetSubscriber();
await subscriber.SubscribeAsync(Channel, (channel, message) =>
{
var message = JsonSerializer.Deserialize<Message>(message);
logger.LogInformation(
"Received message: {Channel} - {@Message}",
channel,
message);
});
}
}
💾 ابطال کش در سیستم‌های توزیع شده (Cache Invalidation)

در یک پروژه اخیر، من یک چالش رایج در سیستم‌های توزیع شده را حل کردم: همگام نگه داشتن کش‌ها. 🧩 ما از یک رویکرد کشینگ دو سطحی استفاده می‌کردیم:

کش in-memory روی هر وب سرور برای دسترسی فوق‌سریع.

کش مشترک Redis برای جلوگیری از ضربه زدن مکرر به پایگاه داده.

مشکل این بود که وقتی داده‌ای در پایگاه داده تغییر می‌کرد، به راهی نیاز داشتیم تا به سرعت به تمام وب سرورها بگوییم که کش‌های in-memory خود را پاک کنند. اینجا بود که Redis Pub/Sub به کمک آمد. ما یک کانال Redis را به طور خاص برای پیام‌های ابطال کش راه‌اندازی کردیم. 📣

💻 پیاده‌سازی CacheInvalidationBackgroundService

هر برنامه یک سرویس CacheInvalidationBackgroundService را اجرا می‌کند که در کانال ابطال کش عضو می‌شود (Subscribe می‌کند):
public class CacheInvalidationBackgroundService(
IServiceProvider serviceProvider)
: BackgroundService
{
public const string Channel = "cache-invalidation";

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await subscriber.SubscribeAsync(Channel, (channel, key) =>
{
var cache = serviceProvider.GetRequiredService<IMemoryCache>();
cache.Remove(key);
return Task.CompletedTask;
});
}
}

هر زمان که داده‌ای در پایگاه داده تغییر می‌کند، ما یک پیام را در این کانال با کلید کش داده به‌روز شده، منتشر می‌کنیم. تمام وب سرورها در این کانال عضو هستند، بنابراین فوراً از حذف داده قدیمی از کش‌های in-memory خود مطلع می‌شوند. از آنجایی که کش in-memory در صورت از کار افتادن برنامه پاک می‌شود، از دست دادن پیام‌های ابطال کش مشکلی ایجاد نمی‌کند. این کار کش‌های ما را سازگار نگه می‌دارد و تضمین می‌کند که کاربران ما همیشه به‌روزترین اطلاعات را می‌بینند.

📝 خلاصه

Redis Pub/Sub
یک راه‌حل جادویی برای هر نیاز پیام‌رسانی نیست، اما سادگی و سرعت آن، آن را به یک ابزار ارزشمند تبدیل می‌کند. کانال‌ها به ما امکان می‌دهند تا به راحتی ارتباط بین مؤلفه‌های با اتصال سست (loosely coupled components) را پیاده‌سازی کنیم. 🔗

کانال‌های Redis دارای معناشناسی "حداکثر یک بار تحویل" (at-most-once delivery) هستند، بنابراین برای مواردی که از دست دادن گاه‌به‌گاه پیام قابل قبول است، بهترین گزینه می‌باشند.

موفق باشید! 👋

🔖 هشتگ‌ها:
#Redis #PubSub #CacheInvalidation #DistributedSystems #DotNet #Messaging #BackgroundService
Forwarded from thisisnabi.dev [Farsi]
کتاب خوب بخونیم.
🌐Overview of HTTP