🛠 پیادهسازی همتوانی (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; }
}